Merge branch 'main' into keys-wrongly-displayed

This commit is contained in:
VariableVince
2026-05-01 00:51:13 +02:00
committed by GitHub
474 changed files with 6433 additions and 2081 deletions
+15 -14
View File
@@ -110,7 +110,7 @@ export class AccountModal extends BaseModal {
<div class="flex items-center gap-2">
<span
class="text-xs text-blue-400 font-bold uppercase tracking-wider"
>${translateText("account_modal.personal_player_id")}</span
>${translateText("account_modal.public_player_id")}</span
>
<copy-button
.lobbyId=${publicId}
@@ -241,12 +241,12 @@ export class AccountModal extends BaseModal {
private renderLogoutButton(): TemplateResult {
return html`
<button
@click="${this.handleLogout}"
class="px-6 py-2 text-sm font-bold text-white uppercase tracking-wider bg-red-600/80 hover:bg-red-600 border border-red-500/50 rounded-lg transition-all shadow-lg hover:shadow-red-900/40"
>
${translateText("account_modal.log_out")}
</button>
<o-button
variant="danger"
size="md"
translationKey="account_modal.log_out"
@click=${this.handleLogout}
></o-button>
`;
}
@@ -318,19 +318,20 @@ export class AccountModal extends BaseModal {
name="email"
.value="${this.email}"
@input="${this.handleEmailInput}"
class="w-full pl-4 pr-12 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/20 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500/50 transition-all font-medium hover:bg-white/10"
class="w-full pl-4 pr-12 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/20 focus:outline-none focus:ring-2 focus:ring-malibu-blue/50 focus:border-malibu-blue/50 transition-all font-medium hover:bg-white/10"
placeholder="${translateText(
"account_modal.email_placeholder",
)}"
required
/>
</div>
<button
@click="${this.handleSubmit}"
class="w-full px-6 py-3 text-sm font-bold text-white uppercase tracking-wider bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-500 hover:to-blue-600 rounded-xl transition-all shadow-lg hover:shadow-blue-900/40 border border-white/5"
>
${translateText("account_modal.get_magic_link")}
</button>
<o-button
variant="primary"
width="block"
size="md"
translationKey="account_modal.get_magic_link"
@click=${this.handleSubmit}
></o-button>
</div>
</div>
+13 -2
View File
@@ -53,7 +53,7 @@ import {
} from "./Transport";
import { createCanvas } from "./Utils";
import { createRenderer, GameRenderer } from "./graphics/GameRenderer";
import { GoToPlayerEvent } from "./graphics/layers/Leaderboard";
import { GoToPlayerEvent } from "./graphics/TransformHandler";
import { SoundManager } from "./sound/SoundManager";
export interface LobbyConfig {
@@ -441,6 +441,8 @@ export class ClientGameRunner {
console.log("Connected to game server!");
this.transport.rejoinGame(this.turnsSeen);
};
let hasGoneToPlayer = false;
const onmessage = (message: ServerMessage) => {
this.lastMessageTime = Date.now();
if (message.type === "start") {
@@ -472,7 +474,7 @@ export class ClientGameRunner {
return;
}
this.eventBus.emit(new GoToPlayerEvent(myPlayer));
this.eventBus.emit(new GoToPlayerEvent(myPlayer, 10));
};
goToPlayer();
@@ -519,6 +521,15 @@ export class ClientGameRunner {
);
}
if (message.type === "turn") {
if (
!this.gameView.inSpawnPhase() &&
!hasGoneToPlayer &&
this.gameView.myPlayer()
) {
hasGoneToPlayer = true;
this.eventBus.emit(new GoToPlayerEvent(this.gameView.myPlayer()!, 8));
}
// Track when we receive the turn to calculate delay
const now = Date.now();
if (this.lastTickReceiveTime > 0) {
+1 -1
View File
@@ -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-[color-mix(in_oklab,var(--frenchBlue)_75%,black)] 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] rounded-lg overflow-hidden"
title=${buttonTitle}
@click=${this.onInputClick}
>
+6 -5
View File
@@ -149,15 +149,16 @@ export class FlagInputModal extends BaseModal {
</div>
</div>
<div class="flex justify-center py-3 shrink-0">
<button
class="no-crazygames px-4 py-2 text-sm font-bold uppercase tracking-wider rounded-lg bg-blue-600 hover:bg-blue-700 text-white cursor-pointer transition-colors"
<o-button
class="no-crazygames"
variant="primary"
size="sm"
translationKey="main.store"
@click=${() => {
this.close();
window.showPage?.("page-item-store");
}}
>
${translateText("main.store")}
</button>
></o-button>
</div>
<div
+17 -13
View File
@@ -10,6 +10,7 @@ import {
Trios,
} from "../core/game/Game";
import { PublicGameInfo, PublicGames } from "../core/Schemas";
import "./components/IOSAddToHomeScreenBanner";
import { crazyGamesSDK } from "./CrazyGamesSDK";
import { HostLobbyModal } from "./HostLobbyModal";
import { JoinLobbyModal } from "./JoinLobbyModal";
@@ -27,7 +28,7 @@ import {
translateText,
} from "./Utils";
const CARD_BG = "bg-sky-950";
const CARD_BG = "bg-surface";
@customElement("game-mode-selector")
export class GameModeSelector extends LitElement {
@@ -119,7 +120,7 @@ export class GameModeSelector extends LitElement {
${this.renderSmallActionCard(
translateText("main.solo"),
this.openSinglePlayerModal,
"bg-[#0073b7] hover:bg-sky-500 active:bg-sky-700",
"bg-malibu-blue hover:bg-aquarius active:bg-malibu-blue/80",
)}
</div>
<!-- Create/ranked/join: mobile only, below solo -->
@@ -127,21 +128,24 @@ export class GameModeSelector extends LitElement {
${this.renderSmallActionCard(
translateText("main.create"),
this.openHostLobby,
"bg-slate-600 hover:bg-slate-500 active:bg-slate-700",
"bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105",
)}
${!crazyGamesSDK.isOnCrazyGames()
? this.renderSmallActionCard(
translateText("mode_selector.ranked_title"),
this.openRankedMenu,
"bg-slate-600 hover:bg-slate-500 active:bg-slate-700",
"bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105",
)
: html`<div class="invisible"></div>`}
${this.renderSmallActionCard(
translateText("main.join"),
this.openJoinLobby,
"bg-slate-600 hover:bg-slate-500 active:bg-slate-700",
"bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105",
)}
</div>
<!-- iOS Add to Home Screen banner -->
<ios-add-to-home-screen-banner></ios-add-to-home-screen-banner>
<!-- Game cards grid -->
<div
class="grid grid-cols-1 sm:grid-cols-[2fr_1fr] gap-4 sm:h-[min(24rem,40vh)]"
@@ -188,7 +192,7 @@ export class GameModeSelector extends LitElement {
${this.renderSmallActionCard(
translateText("main.solo"),
this.openSinglePlayerModal,
"bg-[#0073b7] hover:bg-sky-500 active:bg-sky-700",
"bg-malibu-blue hover:bg-aquarius active:bg-malibu-blue/80",
)}
</div>
<!-- Bottom row: create + ranked + join (desktop only) -->
@@ -196,19 +200,19 @@ export class GameModeSelector extends LitElement {
${this.renderSmallActionCard(
translateText("main.create"),
this.openHostLobby,
"bg-slate-600 hover:bg-slate-500 active:bg-slate-700",
"bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105",
)}
${!crazyGamesSDK.isOnCrazyGames()
? this.renderSmallActionCard(
translateText("mode_selector.ranked_title"),
this.openRankedMenu,
"bg-slate-600 hover:bg-slate-500 active:bg-slate-700",
"bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105",
)
: html`<div class="invisible"></div>`}
${this.renderSmallActionCard(
translateText("main.join"),
this.openJoinLobby,
"bg-slate-600 hover:bg-slate-500 active:bg-slate-700",
"bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105",
)}
</div>
</div>
@@ -249,7 +253,7 @@ export class GameModeSelector extends LitElement {
return html`
<button
@click=${onClick}
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"
class="flex items-center justify-center w-full h-full rounded-lg ${bgClass} transition-all duration-200 text-sm lg:text-base font-medium text-white uppercase tracking-wider text-center"
>
${title}
</button>
@@ -295,7 +299,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] bg-sky-950"
class="group relative w-full h-44 sm:h-full text-white uppercase rounded-2xl transition-all duration-200 hover:scale-[1.02] active:scale-[0.98] bg-surface hover:shadow-[var(--shadow-lobby-card-hover)]"
>
<!-- Image clipped separately so overflow-hidden doesn't block absolute children -->
<div
@@ -321,7 +325,7 @@ export class GameModeSelector extends LitElement {
${modifierLabels.map(
(label) =>
html`<span
class="px-2 py-1 rounded text-xs font-bold uppercase tracking-widest bg-[#0073b7] text-white shadow-[0_0_6px_rgba(14,165,233,0.35)]"
class="px-2 py-1 rounded text-xs font-bold uppercase tracking-widest bg-malibu-blue text-white shadow-[var(--shadow-malibu-blue-pill)]"
>${label}</span
>`,
)}
@@ -331,7 +335,7 @@ export class GameModeSelector extends LitElement {
<span
class="text-xs font-bold tracking-widest ${timeDisplayUppercase
? "uppercase"
: "normal-case"} bg-[#0073b7] text-white px-2 py-1 rounded"
: "normal-case"} bg-malibu-blue text-white px-2 py-1 rounded"
>${timeDisplay}</span
>
</div>
+1 -1
View File
@@ -33,7 +33,7 @@ export class GameStartingModal extends LitElement {
href="https://github.com/openfrontio/OpenFrontIO/blob/main/CREDITS.md"
target="_blank"
rel="noopener noreferrer"
class="block mb-4 text-lg font-medium tracking-wider uppercase text-sky-400 no-underline transition-colors duration-200 hover:text-sky-300"
class="block mb-4 text-lg font-medium tracking-wider uppercase text-malibu-blue no-underline transition-colors duration-200 hover:text-aquarius"
>${translateText("game_starting_modal.credits")}</a
>
<p class="text-base text-white/40 mb-4">
+1 -1
View File
@@ -128,7 +128,7 @@ export class HelpModal extends BaseModal {
</p>
<button
id="troubleshooting-button"
class="hover:bg-white/5 px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest bg-blue-500/20 text-blue-400 border border-blue-500/30 shadow-[0_0_15px_rgba(59,130,246,0.2)]"
class="hover:bg-white/5 px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest bg-malibu-blue/20 text-aquarius border border-malibu-blue/30 shadow-[var(--shadow-malibu-blue)]"
data-page="page-troubleshooting"
@click="${this.openTroubleshooting}"
data-i18n="main.go_to_troubleshooting"
+26 -24
View File
@@ -22,7 +22,7 @@ import { generateID } from "../core/Util";
import { getPlayToken } from "./Auth";
import "./components/baseComponents/Modal";
import { BaseModal } from "./components/BaseModal";
import "./components/CopyButton";
import { CopyButton } from "./components/CopyButton";
import "./components/GameConfigSettings";
import "./components/LobbyPlayerView";
import "./components/ToggleInputCard";
@@ -404,15 +404,16 @@ 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-[#0073b7] 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}
>
${this.clients.length === 1
<o-button
variant="primary"
width="block"
size="lg"
.title=${this.clients.length === 1
? translateText("host_modal.waiting")
: translateText("host_modal.start")}
</button>
?disable=${this.clients.length < 2}
@click=${this.startGame}
></o-button>
</div>
</div>
`;
@@ -439,6 +440,14 @@ export class HostLobbyModal extends BaseModal {
// Note: clientID will be assigned by server when we join the lobby
// lobbyCreatorClientID stays empty until then
// Copy immediately so the host can share the link without waiting for the
// server. If lobby creation fails, clear the clipboard to avoid a dead link.
void this.constructUrl().then(async (url) => {
this.updateHistory(url);
await this.updateComplete;
void (this.querySelector("copy-button") as CopyButton)?.handleCopy();
});
// Pass auth token for creator identification (server extracts persistentID from it)
createLobby(this.lobbyId)
.then(async (lobby) => {
@@ -447,8 +456,6 @@ export class HostLobbyModal extends BaseModal {
throw new Error(`Invalid lobby ID format: ${this.lobbyId}`);
}
crazyGamesSDK.showInviteButton(this.lobbyId);
const url = await this.constructUrl();
this.updateHistory(url);
})
.then(() => {
this.dispatchEvent(
@@ -461,6 +468,10 @@ export class HostLobbyModal extends BaseModal {
composed: true,
}),
);
})
.catch(() => {
// Clear clipboard so the host doesn't accidentally share a dead link
void navigator.clipboard.writeText("").catch(() => {});
});
if (this.modalEl) {
this.modalEl.onClose = () => {
@@ -1000,21 +1011,12 @@ export class HostLobbyModal extends BaseModal {
// If the modal closes as part of starting the game, do not leave the lobby
this.leaveLobbyOnClose = false;
const config = await getRuntimeClientServerConfig();
const response = await fetch(
`${window.location.origin}/${config.workerPath(this.lobbyId)}/api/start_game/${this.lobbyId}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
},
this.dispatchEvent(
new CustomEvent("start-game", {
bubbles: true,
composed: true,
}),
);
if (!response.ok) {
this.leaveLobbyOnClose = true;
}
return response;
}
private kickPlayer(clientID: string) {
+11 -11
View File
@@ -160,7 +160,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-[#0073b7] 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"
class="w-full py-4 text-sm font-bold text-white uppercase tracking-widest bg-malibu-blue hover:bg-aquarius 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")}
@@ -240,13 +240,12 @@ export class JoinLobbyModal extends BaseModal {
@keyup=${this.handleChange}
class="flex-1 px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all font-mono text-sm tracking-wider"
/>
<button
@click=${this.pasteFromClipboard}
class="px-4 py-3 bg-white/5 hover:bg-white/10 border border-white/10 hover:border-white/20 rounded-xl transition-all group"
title=${translateText("common.paste")}
>
<svg
class="text-white/60 group-hover:text-white transition-colors"
<o-button
variant="ghost"
size="md"
iconPosition="only"
.title=${translateText("common.paste")}
.icon=${html`<svg
stroke="currentColor"
fill="currentColor"
stroke-width="0"
@@ -258,12 +257,13 @@ export class JoinLobbyModal extends BaseModal {
<path
d="M 15 3 C 13.742188 3 12.847656 3.890625 12.40625 5 L 5 5 L 5 28 L 13 28 L 13 30 L 27 30 L 27 14 L 25 14 L 25 5 L 17.59375 5 C 17.152344 3.890625 16.257813 3 15 3 Z M 15 5 C 15.554688 5 16 5.445313 16 6 L 16 7 L 19 7 L 19 9 L 11 9 L 11 7 L 14 7 L 14 6 C 14 5.445313 14.445313 5 15 5 Z M 7 7 L 9 7 L 9 11 L 21 11 L 21 7 L 23 7 L 23 14 L 13 14 L 13 26 L 7 26 Z M 15 16 L 25 16 L 25 28 L 15 28 Z"
></path>
</svg>
</button>
</svg>`}
@click=${this.pasteFromClipboard}
></o-button>
</div>
<o-button
title=${translateText("private_lobby.join_lobby")}
block
width="block"
submit
></o-button>
</div>
+1 -1
View File
@@ -58,7 +58,7 @@ export class LanguageModal extends BaseModal {
buttonClasses +=
" animate-pulse font-bold text-white border-2 border-dashed border-cyan-400 shadow-[0_0_15px_rgba(34,211,238,0.2)] bg-gradient-to-r from-red-600 via-yellow-600 via-green-600 via-blue-600 to-purple-600";
} else if (isActive) {
buttonClasses += " bg-blue-500/20 border-blue-500/50";
buttonClasses += " bg-malibu-blue/20 border-malibu-blue/50";
} else {
buttonClasses +=
" bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20";
+26
View File
@@ -1,5 +1,6 @@
import version from "resources/version.txt?raw";
import { UserMeResponse } from "../core/ApiSchemas";
import { assetUrl } from "../core/AssetUrls";
import { EventBus } from "../core/EventBus";
import {
GAME_ID_REGEX,
@@ -51,6 +52,7 @@ import { TerritoryPatternsModal } from "./TerritoryPatternsModal";
import { TokenLoginModal } from "./TokenLoginModal";
import {
SendKickPlayerIntentEvent,
SendStartGameEvent,
SendUpdateGameConfigIntentEvent,
} from "./Transport";
import { UserSettingModal } from "./UserSettingModal";
@@ -216,8 +218,17 @@ declare global {
interface DocumentEventMap {
"join-lobby": CustomEvent<JoinLobbyEvent>;
"kick-player": CustomEvent;
"start-game": CustomEvent;
"join-changed": CustomEvent;
"open-matchmaking": CustomEvent<undefined>;
userMeResponse: CustomEvent<UserMeResponse | false>;
"leave-lobby": CustomEvent;
"update-game-config": CustomEvent;
}
// Fixes the globalThis.addEventListener errors
interface WindowEventMap {
"event:user-settings-changed:settings.darkMode": CustomEvent<string>;
}
}
@@ -267,6 +278,13 @@ class Client {
await customElements.whenDefined("mobile-nav-bar");
await customElements.whenDefined("desktop-nav-bar");
const openFrontFont = new FontFace(
"OpenFront",
`url(${assetUrl("fonts/OpenFront.ttf")})`,
);
document.fonts.add(openFrontFont);
openFrontFont.load().catch(() => {});
const versionElements = document.querySelectorAll(
"#game-version, .game-version-display",
);
@@ -276,6 +294,7 @@ class Client {
const trimmed = version.trim();
const displayVersion = trimmed.startsWith("v") ? trimmed : `v${trimmed}`;
versionElements.forEach((el) => {
(el as HTMLElement).style.fontFamily = '"OpenFront", Inter, sans-serif';
el.textContent = displayVersion;
});
}
@@ -314,6 +333,7 @@ class Client {
document.addEventListener("join-lobby", this.handleJoinLobby.bind(this));
document.addEventListener("leave-lobby", this.handleLeaveLobby.bind(this));
document.addEventListener("kick-player", this.handleKickPlayer.bind(this));
document.addEventListener("start-game", this.handleStartGame.bind(this));
document.addEventListener(
"update-game-config",
this.handleUpdateGameConfig.bind(this),
@@ -935,6 +955,12 @@ class Client {
}
}
private handleStartGame() {
if (this.eventBus) {
this.eventBus.emit(new SendStartGameEvent());
}
}
private handleUpdateGameConfig(event: CustomEvent) {
const { config } = event.detail;
+1 -1
View File
@@ -123,7 +123,7 @@ export class MatchmakingModal extends BaseModal {
this.gameCheckInterval = setInterval(() => this.checkGame(), 1000);
}
};
this.socket.onerror = (event: ErrorEvent) => {
this.socket.onerror = (event: Event) => {
console.error("WebSocket error occurred:", event);
};
this.socket.onclose = () => {
+2 -2
View File
@@ -111,7 +111,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 bg-[color-mix(in_oklab,var(--frenchBlue)_75%,black)] 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 bg-surface rounded-lg overflow-hidden"
disabled
>
<span
@@ -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-[color-mix(in_oklab,var(--frenchBlue)_75%,black)] 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] rounded-lg overflow-hidden"
title=${buttonTitle}
@click=${this.onInputClick}
>
+6 -5
View File
@@ -348,12 +348,13 @@ export class SinglePlayerModal extends BaseModal {
${translateText("single_modal.options_changed_no_achievements")}
</div>`
: null}
<button
<o-button
variant="primary"
width="block"
size="lg"
translationKey="single_modal.start"
@click=${this.startGame}
class="w-full py-4 text-sm font-bold text-white uppercase tracking-widest bg-[#0073b7] 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>
></o-button>
</div>
</div>
`;
+3 -3
View File
@@ -52,7 +52,7 @@ export class StoreModal extends BaseModal {
<button
class="px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest ${this
.activeTab === "packs"
? "bg-blue-500/20 text-blue-400 border border-blue-500/30 shadow-[0_0_15px_rgba(59,130,246,0.2)]"
? "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")}
>
@@ -61,7 +61,7 @@ export class StoreModal extends BaseModal {
<button
class="px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest ${this
.activeTab === "patterns"
? "bg-blue-500/20 text-blue-400 border border-blue-500/30 shadow-[0_0_15px_rgba(59,130,246,0.2)]"
? "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")}
>
@@ -70,7 +70,7 @@ export class StoreModal extends BaseModal {
<button
class="px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest ${this
.activeTab === "flags"
? "bg-blue-500/20 text-blue-400 border border-blue-500/30 shadow-[0_0_15px_rgba(59,130,246,0.2)]"
? "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")}
>
+6 -5
View File
@@ -149,15 +149,16 @@ export class TerritoryPatternsModal extends BaseModal {
</div>
</div>
<div class="flex justify-center py-3 shrink-0">
<button
class="no-crazygames px-4 py-2 text-sm font-bold uppercase tracking-wider rounded-lg bg-blue-600 hover:bg-blue-700 text-white cursor-pointer transition-colors"
<o-button
class="no-crazygames"
variant="primary"
size="sm"
translationKey="main.store"
@click=${() => {
this.close();
window.showPage?.("page-item-store");
}}
>
${translateText("main.store")}
</button>
></o-button>
</div>
<div
class="flex-1 overflow-y-auto px-3 pb-3 scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent mr-1"
+8
View File
@@ -173,6 +173,8 @@ export class SendUpdateGameConfigIntentEvent implements GameEvent {
constructor(public readonly config: Partial<GameConfig>) {}
}
export class SendStartGameEvent implements GameEvent {}
export class Transport {
private socket: WebSocket | null = null;
@@ -262,6 +264,8 @@ export class Transport {
this.eventBus.on(SendUpdateGameConfigIntentEvent, (e) =>
this.onSendUpdateGameConfigIntent(e),
);
this.eventBus.on(SendStartGameEvent, () => this.onSendStartGame());
}
private startPing() {
@@ -644,6 +648,10 @@ export class Transport {
});
}
private onSendStartGame() {
this.sendIntent({ type: "start_game" });
}
private sendIntent(intent: Intent) {
if (this.isLocal || this.socket?.readyState === WebSocket.OPEN) {
const msg = {
+5 -5
View File
@@ -46,12 +46,12 @@ export class TroubleshootingModal extends BaseModal {
>
/ ${translateText("troubleshooting.title")}
</span>
<button
class="hover:bg-white/5 px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest bg-blue-500/20 text-blue-400 border border-blue-500/30 shadow-[0_0_15px_rgba(59,130,246,0.2)]"
<o-button
variant="primary"
size="sm"
translationKey="common.copy"
@click=${this.copyDiagnostics}
>
${translateText("common.copy")}
</button>
></o-button>
</div>`,
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
+2 -2
View File
@@ -351,7 +351,7 @@ export class UserSettingModal extends BaseModal {
<button
class="px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest ${this
.activeTab === "basic"
? "bg-blue-500/20 text-blue-400 border border-blue-500/30 shadow-[0_0_15px_rgba(59,130,246,0.2)]"
? "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")}
>
@@ -360,7 +360,7 @@ export class UserSettingModal extends BaseModal {
<button
class="px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest ${this
.activeTab === "keybinds"
? "bg-blue-500/20 text-blue-400 border border-blue-500/30 shadow-[0_0_15px_rgba(59,130,246,0.2)]"
? "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")}
>
+1 -1
View File
@@ -80,7 +80,7 @@ export class CopyButton extends LitElement {
return await this.buildCopyUrl();
}
private async handleCopy() {
async handleCopy() {
const text = await this.resolveCopyText();
if (!text) {
alert("Error copying game id");
+8 -8
View File
@@ -53,11 +53,11 @@ export class DesktopNavBar extends LitElement {
class="hidden lg:flex w-full bg-zinc-900/90 backdrop-blur-md items-center justify-center gap-8 py-4 shrink-0 z-50 relative"
>
<div class="flex flex-col items-center justify-center">
<div class="h-8 text-[#0073b7]">
<div class="h-8">
<img
class="block h-full aspect-[1364/259]"
src=${assetUrl("images/OpenFrontLogo.svg")}
alt="OpenFront"
class="h-full w-auto"
/>
</div>
<div
@@ -68,7 +68,7 @@ export class DesktopNavBar extends LitElement {
<button
class="nav-menu-item ${currentPage === "page-play"
? "active"
: ""} text-white/70 hover:text-[#0073b7] font-medium tracking-wider uppercase cursor-pointer transition-colors [&.active]:text-[#0073b7] "
: ""} text-white/70 hover:text-malibu-blue font-medium tracking-wider uppercase cursor-pointer transition-colors [&.active]:text-malibu-blue "
data-page="page-play"
data-i18n="main.play"
></button>
@@ -77,7 +77,7 @@ export class DesktopNavBar extends LitElement {
<button
class="nav-menu-item ${currentPage === "page-news"
? "active"
: ""} text-white/70 hover:text-[#0073b7] font-medium tracking-wider uppercase cursor-pointer transition-colors [&.active]:text-[#0073b7] "
: ""} text-white/70 hover:text-malibu-blue font-medium tracking-wider uppercase cursor-pointer transition-colors [&.active]:text-malibu-blue "
data-page="page-news"
data-i18n="main.news"
@click=${this._notifications.onNewsClick}
@@ -97,7 +97,7 @@ export class DesktopNavBar extends LitElement {
<button
class="nav-menu-item ${currentPage === "page-item-store"
? "active"
: ""} text-white/70 hover:text-[#0073b7] font-medium tracking-wider uppercase cursor-pointer transition-colors [&.active]:text-[#0073b7] "
: ""} text-white/70 hover:text-malibu-blue font-medium tracking-wider uppercase cursor-pointer transition-colors [&.active]:text-malibu-blue "
data-page="page-item-store"
data-i18n="main.store"
@click=${this._notifications.onStoreClick}
@@ -114,18 +114,18 @@ export class DesktopNavBar extends LitElement {
: ""}
</div>
<button
class="nav-menu-item text-white/70 hover:text-[#0073b7] font-medium tracking-wider uppercase cursor-pointer transition-colors [&.active]:text-[#0073b7] "
class="nav-menu-item text-white/70 hover:text-malibu-blue font-medium tracking-wider uppercase cursor-pointer transition-colors [&.active]:text-malibu-blue "
data-page="page-settings"
data-i18n="main.settings"
></button>
<button
class="nav-menu-item text-white/70 hover:text-[#0073b7] font-medium tracking-wider uppercase cursor-pointer transition-colors [&.active]:text-[#0073b7] "
class="nav-menu-item text-white/70 hover:text-malibu-blue font-medium tracking-wider uppercase cursor-pointer transition-colors [&.active]:text-malibu-blue "
data-page="page-leaderboard"
data-i18n="main.leaderboard"
></button>
<div class="relative">
<button
class="nav-menu-item text-white/70 hover:text-[#0073b7] font-medium tracking-wider uppercase cursor-pointer transition-colors [&.active]:text-[#0073b7] "
class="nav-menu-item text-white/70 hover:text-malibu-blue font-medium tracking-wider uppercase cursor-pointer transition-colors [&.active]:text-malibu-blue "
data-page="page-help"
data-i18n="main.help"
@click=${this._notifications.onHelpClick}
+3 -3
View File
@@ -86,12 +86,12 @@ export class FluentSlider extends LitElement {
.max=${this.max}
.step=${this.step}
.valueAsNumber=${this.value}
style="background: linear-gradient(to right, #3b82f6 0%, #3b82f6 ${percentage}%, rgba(255, 255, 255, 0.15) ${percentage}%, rgba(255, 255, 255, 0.15) 100%); background-size: 100% 6px; background-repeat: no-repeat; background-position: center; border-radius: 9999px;"
style="background: linear-gradient(to right, var(--color-malibu-blue) 0%, var(--color-malibu-blue) ${percentage}%, rgba(255, 255, 255, 0.15) ${percentage}%, rgba(255, 255, 255, 0.15) 100%); background-size: 100% 6px; background-repeat: no-repeat; background-position: center; border-radius: 9999px;"
class="w-full h-6 p-0 m-0 bg-transparent appearance-none cursor-pointer focus:outline-none
[&::-webkit-slider-runnable-track]:w-full [&::-webkit-slider-runnable-track]:h-[6px] [&::-webkit-slider-runnable-track]:cursor-pointer [&::-webkit-slider-runnable-track]:bg-transparent [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:transition-colors
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-[18px] [&::-webkit-slider-thumb]:w-[18px] [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-blue-500 [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:-mt-[6px] [&::-webkit-slider-thumb]:shadow-[0_0_0_4px_rgba(59,130,246,0.2)] [&::-webkit-slider-thumb]:transition-all active:[&::-webkit-slider-thumb]:scale-110 active:[&::-webkit-slider-thumb]:shadow-[0_0_0_6px_rgba(59,130,246,0.3)]
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-[18px] [&::-webkit-slider-thumb]:w-[18px] [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-malibu-blue [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:-mt-[6px] [&::-webkit-slider-thumb]:shadow-[var(--shadow-malibu-blue-ring-sm)] [&::-webkit-slider-thumb]:transition-all active:[&::-webkit-slider-thumb]:scale-110 active:[&::-webkit-slider-thumb]:shadow-[var(--shadow-malibu-blue-ring-lg)]
[&::-moz-range-track]:w-full [&::-moz-range-track]:h-[6px] [&::-moz-range-track]:cursor-pointer [&::-moz-range-track]:bg-transparent [&::-moz-range-track]:rounded-full [&::-moz-range-track]:transition-colors
[&::-moz-range-thumb]:h-[18px] [&::-moz-range-thumb]:w-[18px] [&::-moz-range-thumb]:border-none [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-blue-500 [&::-moz-range-thumb]:cursor-pointer [&::-moz-range-thumb]:shadow-[0_0_0_4px_rgba(59,130,246,0.2)] [&::-moz-range-thumb]:transition-all active:[&::-moz-range-thumb]:scale-110 active:[&::-moz-range-thumb]:shadow-[0_0_0_6px_rgba(59,130,246,0.3)]"
[&::-moz-range-thumb]:h-[18px] [&::-moz-range-thumb]:w-[18px] [&::-moz-range-thumb]:border-none [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-malibu-blue [&::-moz-range-thumb]:cursor-pointer [&::-moz-range-thumb]:shadow-[var(--shadow-malibu-blue-ring-sm)] [&::-moz-range-thumb]:transition-all active:[&::-moz-range-thumb]:scale-110 active:[&::-moz-range-thumb]:shadow-[var(--shadow-malibu-blue-ring-lg)]"
@input=${this.handleSliderInput}
@change=${this.handleSliderChange}
/>
+3 -3
View File
@@ -24,7 +24,7 @@ import "./FluentSlider";
import "./map/MapPicker";
const ACTIVE_CARD =
"bg-blue-500/20 border-blue-500/50 shadow-[0_0_15px_rgba(59,130,246,0.2)]";
"bg-malibu-blue/20 border-malibu-blue/50 shadow-[var(--shadow-malibu-blue)]";
const INACTIVE_CARD =
"bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20";
@@ -317,8 +317,8 @@ export class GameConfigSettings extends LitElement {
<div class=${this.sectionGapClass}>
${renderSection(
MAP_ICON,
"text-blue-400",
"bg-blue-500/20",
"text-aquarius",
"bg-malibu-blue/20",
"map.map",
html`<map-picker
.selectedMap=${settings.map.selected}
@@ -0,0 +1,195 @@
import { LitElement, html, nothing } from "lit";
import { customElement, state } from "lit/decorators.js";
import { Platform } from "../Platform";
import { translateText } from "../Utils";
const DISMISSED_KEY = "ios_a2hs_banner_dismissed";
const LATER_KEY = "ios_a2hs_banner_later";
@customElement("ios-add-to-home-screen-banner")
export class IOSAddToHomeScreenBanner extends LitElement {
@state() private dismissed = false;
@state() private later = false;
@state() private showGuide = false;
createRenderRoot() {
return this;
}
connectedCallback() {
super.connectedCallback();
try {
this.dismissed = localStorage.getItem(DISMISSED_KEY) === "true";
} catch {
this.dismissed = false;
}
try {
this.later = sessionStorage.getItem(LATER_KEY) === "true";
} catch {
this.later = false;
}
}
private never() {
try {
localStorage.setItem(DISMISSED_KEY, "true");
} catch {
// localStorage unavailable — dismiss for session only
}
this.dismissed = true;
}
private later_() {
try {
sessionStorage.setItem(LATER_KEY, "true");
} catch {
// ignore — this.later still set in memory
}
this.later = true;
}
private openGuide() {
this.showGuide = true;
}
private closeGuide() {
this.showGuide = false;
}
private renderGuideModal() {
if (!this.showGuide) return nothing;
return html`
<div
class="fixed inset-0 bg-black/70 backdrop-blur-sm z-[9999] flex items-end sm:items-center justify-center p-4"
@click=${(e: Event) => {
if (e.target === e.currentTarget) this.closeGuide();
}}
>
<div class="relative w-full max-w-sm">
<div
class="bg-slate-800 border border-slate-600 rounded-2xl w-full p-5 pb-6 flex flex-col gap-4"
role="dialog"
aria-modal="true"
aria-labelledby="ios-banner-modal-title"
>
<div class="flex items-center justify-between">
<h2
id="ios-banner-modal-title"
class="text-white font-bold text-lg"
>
${translateText("ios_banner.modal_title")}
</h2>
<button
class="text-slate-400 hover:text-white text-2xl leading-none"
@click=${this.closeGuide}
aria-label=${translateText("common.close")}
>
×
</button>
</div>
<p class="text-slate-300 text-sm">
${translateText("ios_banner.modal_desc")}
</p>
<ol class="flex flex-col gap-3 text-sm text-slate-200">
<li class="flex items-start gap-3">
<span
class="shrink-0 w-6 h-6 rounded-full bg-malibu-blue flex items-center justify-center text-white font-bold text-xs"
>1</span
>
<span>${translateText("ios_banner.step_share")}</span>
</li>
<li class="flex items-start gap-3">
<span
class="shrink-0 w-6 h-6 rounded-full bg-malibu-blue flex items-center justify-center text-white font-bold text-xs"
>2</span
>
<span
>${translateText("ios_banner.step_scroll_and_tap")}
<strong class="text-white"
>${translateText(
"ios_banner.step_add_to_home_label",
)}</strong
></span
>
</li>
<li class="flex items-start gap-3">
<span
class="shrink-0 w-6 h-6 rounded-full bg-malibu-blue flex items-center justify-center text-white font-bold text-xs"
>3</span
>
<span>${translateText("ios_banner.step_open")}</span>
</li>
</ol>
<button
class="w-full py-2.5 rounded-lg bg-malibu-blue hover:bg-aquarius active:bg-malibu-blue/80 text-white font-semibold transition-colors"
@click=${this.closeGuide}
>
${translateText("ios_banner.got_it")}
</button>
</div>
</div>
</div>
`;
}
render() {
if (!Platform.isIOS) return nothing;
if (this.dismissed || this.later) return nothing;
if (
(navigator as any).standalone === true ||
window.matchMedia("(display-mode: standalone)").matches
) {
return nothing;
}
return html`
${this.renderGuideModal()}
<div
class="flex flex-col gap-3 w-full px-3 py-3 rounded-xl bg-slate-800/90 border border-slate-600 text-sm text-slate-200"
>
<div class="flex gap-3 items-center">
<svg
xmlns="http://www.w3.org/2000/svg"
class="shrink-0 w-8 h-8 text-malibu-blue"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<rect x="5" y="2" width="14" height="20" rx="2" ry="2" />
<line x1="12" y1="18" x2="12.01" y2="18" />
</svg>
<span>${translateText("ios_banner.text")}</span>
</div>
<div class="flex flex-col gap-1.5">
<button
class="w-full py-1.5 rounded-lg bg-malibu-blue hover:bg-aquarius active:bg-malibu-blue/80 text-white font-semibold text-sm transition-colors"
@click=${this.openGuide}
>
${translateText("ios_banner.how")}
</button>
<button
class="w-full py-1.5 rounded-lg bg-slate-700 hover:bg-slate-600 active:bg-slate-800 text-slate-300 text-sm transition-colors"
@click=${this.later_}
>
${translateText("ios_banner.later")}
</button>
<button
class="w-full py-1.5 rounded-lg text-slate-500 hover:text-slate-400 text-xs transition-colors"
@click=${this.never}
>
${translateText("ios_banner.never")}
</button>
</div>
</div>
`;
}
}
+2 -2
View File
@@ -126,7 +126,7 @@ export class LobbyTeamView extends LitElement {
return html`<div
class="px-2 py-1 rounded-sm mb-1 text-xs text-white border
${this.isCurrentPlayer(client)
? "bg-[#0073b7]/20 border-sky-500/40"
? "bg-malibu-blue/20 border-sky-500/40"
: "bg-gray-700/70 border-transparent"}"
>
${displayName}
@@ -242,7 +242,7 @@ export class LobbyTeamView extends LitElement {
return html` <div
class="px-2 py-1 rounded-sm text-xs flex items-center justify-between border
${this.isCurrentPlayer(p)
? "bg-[#0073b7]/20 border-sky-500/40"
? "bg-malibu-blue/20 border-sky-500/40"
: "bg-gray-700/70 border-transparent"}"
>
<span class="truncate text-white">${displayName}</span>
+1 -1
View File
@@ -73,7 +73,7 @@ export class MobileNavBar extends LitElement {
>
<!-- Logo + Menu -->
<div
class="flex flex-col text-[#0073b7] mb-[clamp(1rem,2vh,2rem)] ml-[clamp(0.2rem,0.4vw,0.4vh)]"
class="flex flex-col text-malibu-blue mb-[clamp(1rem,2vh,2rem)] ml-[clamp(0.2rem,0.4vw,0.4vh)]"
>
<div class="flex flex-col items-center gap-2">
<img
+1 -1
View File
@@ -115,7 +115,7 @@ export class NewsBox extends LitElement {
return html`
<div
class="px-2 py-2 bg-[color-mix(in_oklab,var(--frenchBlue)_75%,black)] border-y border-white/10 lg:border-y-0 lg:rounded-xl lg:p-3"
class="px-2 py-2 bg-surface border-y border-white/10 lg:border-y-0 lg:rounded-xl lg:p-3"
>
<div class="flex items-center gap-3">
<span
+3 -3
View File
@@ -19,7 +19,7 @@ export class PlayPage extends LitElement {
<!-- Mobile: Fixed top bar -->
<div
class="lg:hidden fixed left-0 right-0 top-0 z-40 pt-[env(safe-area-inset-top)] bg-[color-mix(in_oklab,var(--frenchBlue)_75%,black)] border-b border-white/10"
class="lg:hidden fixed left-0 right-0 top-0 z-40 pt-[env(safe-area-inset-top)] bg-surface border-b border-white/10"
>
<div
class="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center h-14 px-2 gap-2"
@@ -50,7 +50,7 @@ export class PlayPage extends LitElement {
</button>
<div
class="col-start-2 flex items-center justify-center text-[#0073b7] min-w-0"
class="col-start-2 flex items-center justify-center text-malibu-blue min-w-0"
>
<img
src=${assetUrl("images/OpenFrontLogo.svg")}
@@ -79,7 +79,7 @@ export class PlayPage extends LitElement {
<!-- Username: left col -->
<div
class="px-2 py-2 bg-[color-mix(in_oklab,var(--frenchBlue)_75%,black)] border-y border-white/10 overflow-visible lg:flex lg:items-center lg:gap-x-2 lg:h-[60px] lg:p-3 lg:relative lg:z-20 lg:border-y-0 lg:rounded-xl"
class="px-2 py-2 bg-surface border-y border-white/10 overflow-visible lg:flex lg:items-center lg:gap-x-2 lg:h-[60px] lg:p-3 lg:relative lg:z-20 lg:border-y-0 lg:rounded-xl"
>
<div class="flex items-center gap-2 min-w-0 w-full">
<username-input
+1 -1
View File
@@ -128,7 +128,7 @@ export class RankedModal extends BaseModal {
return html`
<button
@click=${onClick}
class="flex flex-col w-full h-28 sm:h-32 rounded-2xl bg-[color-mix(in_oklab,var(--frenchBlue)_70%,black)] border-0 transition-transform hover:scale-[1.02] active:scale-[0.98] p-6 items-center justify-center gap-3"
class="flex flex-col w-full h-28 sm:h-32 rounded-2xl bg-surface border-0 transition-transform hover:scale-[1.02] active:scale-[0.98] p-6 items-center justify-center gap-3"
>
<div class="flex flex-col items-center gap-1 text-center">
<h3
+2 -2
View File
@@ -3,11 +3,11 @@ import { customElement, property } from "lit/decorators.js";
import { translateText } from "../Utils";
const ACTIVE_CARD =
"bg-blue-500/20 border-blue-500/50 shadow-[0_0_15px_rgba(59,130,246,0.2)]";
"bg-malibu-blue/20 border-malibu-blue/50 shadow-[var(--shadow-malibu-blue)]";
const INACTIVE_CARD =
"bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20";
const INPUT_CLASS =
"w-full text-center rounded bg-black/60 text-white text-sm font-bold border border-white/20 focus:outline-none focus:border-blue-500 p-1 my-1";
"w-full text-center rounded bg-black/60 text-white text-sm font-bold border border-white/20 focus:outline-none focus:border-malibu-blue p-1 my-1";
const CARD_LABEL_CLASS =
"text-xs uppercase font-bold tracking-wider leading-tight break-words hyphens-auto";
+76 -28
View File
@@ -1,51 +1,99 @@
import { LitElement, html } from "lit";
import { LitElement, TemplateResult, html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import { classMap } from "lit/directives/class-map.js";
import { translateText } from "../../Utils";
type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
type ButtonSize = "sm" | "md" | "lg";
type ButtonWidth = "auto" | "block" | "blockDesktop" | "fill";
type IconPosition = "left" | "right" | "only";
@customElement("o-button")
export class OButton extends LitElement {
@property({ type: String }) title = "";
@property({ type: String }) translationKey = "";
@property({ type: Boolean }) secondary = false;
@property({ type: Boolean }) block = false;
@property({ type: Boolean }) blockDesktop = false;
@property() title = "";
@property() translationKey = "";
@property() variant: ButtonVariant = "primary";
@property() size: ButtonSize = "md";
@property() width: ButtonWidth = "auto";
@property() iconPosition: IconPosition = "left";
@property({ attribute: false }) icon?: TemplateResult;
@property({ type: Boolean }) disable = false;
@property({ type: Boolean }) fill = false;
@property({ type: Boolean }) submit = false;
private static readonly BASE_CLASS =
"bg-[#0073b7] 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;
}
private getButtonClasses(): Record<string, boolean> {
return {
[OButton.BASE_CLASS]: true,
"w-full block": this.block,
"h-full w-full flex items-center justify-center": this.fill,
"lg:w-auto lg:inline-block":
!this.block && !this.blockDesktop && !this.fill,
"lg:w-1/2 lg:mx-auto lg:block": this.blockDesktop,
"bg-gray-700 text-gray-100 hover:bg-gray-600": this.secondary,
"disabled:opacity-70 disabled:cursor-not-allowed disabled:transform-none disabled:bg-gray-600":
this.disable,
};
private readonly BASE =
"font-bold uppercase tracking-wider rounded-xl border border-transparent " +
"transition-all duration-300 transform hover:-translate-y-px " +
"outline-none text-center whitespace-normal break-words leading-tight overflow-hidden relative " +
"disabled:cursor-not-allowed disabled:hover:translate-y-0 disabled:opacity-70";
private variantClasses(): string {
switch (this.variant) {
case "primary":
return "bg-malibu-blue hover:bg-aquarius text-white disabled:bg-gray-600 disabled:text-gray-300";
case "secondary":
return "bg-gray-700 hover:bg-gray-600 text-white disabled:bg-gray-800 disabled:text-gray-400";
case "danger":
return "bg-red-600 hover:bg-red-500 text-white disabled:bg-red-900 disabled:text-gray-300";
case "ghost":
return "bg-transparent hover:bg-white/10 text-malibu-blue disabled:text-gray-500 disabled:hover:bg-transparent";
}
}
private sizeClasses(): string {
if (this.iconPosition === "only") {
switch (this.size) {
case "sm":
return "w-8 h-8 text-sm";
case "md":
return "w-10 h-10 text-base";
case "lg":
return "w-12 h-12 text-lg";
}
}
switch (this.size) {
case "sm":
return "py-1.5 px-3 text-sm";
case "md":
return "py-3 px-4 text-base lg:text-lg";
case "lg":
return "py-4 px-6 text-lg lg:text-xl";
}
}
private widthClasses(): string {
switch (this.width) {
case "auto":
return "inline-flex items-center justify-center gap-2";
case "block":
return "flex w-full items-center justify-center gap-2";
case "blockDesktop":
return "flex w-full items-center justify-center gap-2 lg:w-1/2 lg:mx-auto";
case "fill":
return "flex w-full h-full items-center justify-center gap-2";
}
}
render() {
const label =
this.translationKey === ""
? this.title
: translateText(this.translationKey);
const iconOnly = this.iconPosition === "only";
const classes = `${this.BASE} ${this.variantClasses()} ${this.sizeClasses()} ${this.widthClasses()}`;
return html`
<button
class=${classMap(this.getButtonClasses())}
class=${classes}
?disabled=${this.disable}
type=${this.submit ? "submit" : "button"}
aria-label=${iconOnly ? label : nothing}
>
<span class="block min-w-0">
${this.translationKey === ""
? this.title
: translateText(this.translationKey)}
</span>
${this.icon && this.iconPosition !== "right" ? this.icon : nothing}
${iconOnly ? nothing : html`<span class="min-w-0">${label}</span>`}
${this.icon && this.iconPosition === "right" ? this.icon : nothing}
</button>
`;
}
@@ -240,7 +240,7 @@ export class PlayerRow extends LitElement {
private renderTag(tag: string) {
return html`
<div
class="px-2.5 py-1 rounded bg-blue-500/10 border border-blue-500/20 text-blue-300 font-bold text-xs tracking-wide group-hover:bg-blue-500/20 transition-colors"
class="px-2.5 py-1 rounded bg-malibu-blue/10 border border-malibu-blue/20 text-aquarius font-bold text-xs tracking-wide group-hover:bg-malibu-blue/20 transition-colors"
>
${tag}
</div>
@@ -64,7 +64,7 @@ export class RankingControls extends LitElement {
return html`
<button
class="px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest hover:text-white hover:bg-white/5 border ${active
? "bg-blue-500/20 text-blue-400 border-blue-500/30 shadow-[0_0_15px_rgba(59,130,246,0.2)]"
? "bg-malibu-blue/20 text-aquarius border-malibu-blue/30 shadow-[var(--shadow-malibu-blue)]"
: "text-white/40 border-transparent"}"
@click=${() => this.onSort(type)}
>
@@ -70,11 +70,11 @@ export class SettingSlider extends LitElement {
<input
type="range"
class="flex-1 w-auto appearance-none h-2 bg-transparent rounded outline-none
[&::-webkit-slider-runnable-track]:h-2 [&::-webkit-slider-runnable-track]:rounded [&::-webkit-slider-runnable-track]:bg-[image:linear-gradient(to_right,#3b82f6_0%,#3b82f6_var(--fill),rgba(255,255,255,0.1)_var(--fill),rgba(255,255,255,0.1)_100%)]
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-[18px] [&::-webkit-slider-thumb]:w-[18px] [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-blue-500 [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:-mt-[6px] [&::-webkit-slider-thumb]:shadow-[0_0_0_4px_rgba(59,130,246,0.2)] [&::-webkit-slider-thumb]:transition-all active:[&::-webkit-slider-thumb]:scale-110 active:[&::-webkit-slider-thumb]:shadow-[0_0_0_6px_rgba(59,130,246,0.3)]
[&::-webkit-slider-runnable-track]:h-2 [&::-webkit-slider-runnable-track]:rounded [&::-webkit-slider-runnable-track]:bg-[image:linear-gradient(to_right,var(--color-malibu-blue)_0%,var(--color-malibu-blue)_var(--fill),rgba(255,255,255,0.1)_var(--fill),rgba(255,255,255,0.1)_100%)]
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-[18px] [&::-webkit-slider-thumb]:w-[18px] [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-malibu-blue [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:-mt-[6px] [&::-webkit-slider-thumb]:shadow-[var(--shadow-malibu-blue-ring-sm)] [&::-webkit-slider-thumb]:transition-all active:[&::-webkit-slider-thumb]:scale-110 active:[&::-webkit-slider-thumb]:shadow-[var(--shadow-malibu-blue-ring-lg)]
[&::-moz-range-track]:h-2 [&::-moz-range-track]:rounded [&::-moz-range-track]:bg-white/10
[&::-moz-range-progress]:h-2 [&::-moz-range-progress]:rounded [&::-moz-range-progress]:bg-blue-500
[&::-moz-range-thumb]:h-[18px] [&::-moz-range-thumb]:w-[18px] [&::-moz-range-thumb]:border-none [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-blue-500 [&::-moz-range-thumb]:cursor-pointer [&::-moz-range-thumb]:shadow-[0_0_0_4px_rgba(59,130,246,0.2)] [&::-moz-range-thumb]:transition-all active:[&::-moz-range-thumb]:scale-110 active:[&::-moz-range-thumb]:shadow-[0_0_0_6px_rgba(59,130,246,0.3)]"
[&::-moz-range-progress]:h-2 [&::-moz-range-progress]:rounded [&::-moz-range-progress]:bg-malibu-blue
[&::-moz-range-thumb]:h-[18px] [&::-moz-range-thumb]:w-[18px] [&::-moz-range-thumb]:border-none [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-malibu-blue [&::-moz-range-thumb]:cursor-pointer [&::-moz-range-thumb]:shadow-[var(--shadow-malibu-blue-ring-sm)] [&::-moz-range-thumb]:transition-all active:[&::-moz-range-thumb]:scale-110 active:[&::-moz-range-thumb]:shadow-[var(--shadow-malibu-blue-ring-lg)]"
min=${this.min}
max=${this.max}
.value=${String(this.value)}
@@ -4,6 +4,7 @@ import { PlayerGame } from "../../../../core/ApiSchemas";
import { GameMode } from "../../../../core/game/Game";
import { GameInfoModal } from "../../../GameInfoModal";
import { translateText } from "../../../Utils";
import "../../CopyButton";
@customElement("game-list")
export class GameList extends LitElement {
@@ -46,7 +47,7 @@ export class GameList extends LitElement {
>
<div class="flex items-center gap-4">
<button
class="p-2 bg-blue-500/20 rounded-lg text-blue-400"
class="p-2 bg-malibu-blue/20 rounded-lg text-aquarius"
@click=${() => this.onViewGame?.(game.gameId)}
>
<svg
@@ -115,7 +116,10 @@ export class GameList extends LitElement {
>
${translateText("game_list.game_id")}
</div>
<div class="text-white font-mono">${game.gameId}</div>
<copy-button
.copyText="${game.gameId}"
compact
></copy-button>
</div>
<div>
<div
+1 -1
View File
@@ -86,7 +86,7 @@ export class MapDisplay extends LitElement {
@keydown="${this.handleKeydown}"
class="w-full h-full p-3 flex flex-col items-center justify-between rounded-xl border cursor-pointer transition-all duration-200 active:scale-95 gap-3 group ${this
.selected
? "bg-blue-500/20 border-blue-500/50 shadow-[0_0_15px_rgba(59,130,246,0.3)]"
? "bg-malibu-blue/20 border-malibu-blue/50 shadow-[var(--shadow-malibu-blue-strong)]"
: "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20 hover:-translate-y-1"}"
>
${this.isLoading
+3 -3
View File
@@ -125,7 +125,7 @@ export class MapPicker extends LitElement {
class="px-3 py-2 rounded-lg text-xs font-bold uppercase tracking-wider transition-all active:scale-95 ${this
.showAllMaps
? "text-white/60 hover:text-white"
: "bg-blue-500/20 text-blue-100 shadow-[0_0_12px_rgba(59,130,246,0.2)]"}"
: "bg-malibu-blue/20 text-white shadow-[var(--shadow-malibu-blue-soft)]"}"
@click=${() => (this.showAllMaps = false)}
>
${translateText("map.featured")}
@@ -136,7 +136,7 @@ export class MapPicker extends LitElement {
aria-selected=${this.showAllMaps}
class="px-3 py-2 rounded-lg text-xs font-bold uppercase tracking-wider transition-all active:scale-95 ${this
.showAllMaps
? "bg-blue-500/20 text-blue-100 shadow-[0_0_12px_rgba(59,130,246,0.2)]"
? "bg-malibu-blue/20 text-white shadow-[var(--shadow-malibu-blue-soft)]"
: "text-white/60 hover:text-white"}"
@click=${() => (this.showAllMaps = true)}
>
@@ -160,7 +160,7 @@ export class MapPicker extends LitElement {
type="button"
class="w-full h-full p-3 flex flex-col items-center justify-between rounded-xl border cursor-pointer transition-all duration-200 active:scale-95 gap-3 group ${this
.useRandomMap
? "bg-blue-500/20 border-blue-500/50 shadow-[0_0_15px_rgba(59,130,246,0.3)]"
? "bg-malibu-blue/20 border-malibu-blue/50 shadow-[var(--shadow-malibu-blue-strong)]"
: "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20 hover:-translate-y-1"}"
@click=${this.handleSelectRandomMap}
>
+50 -11
View File
@@ -1,12 +1,25 @@
import { EventBus } from "../../core/EventBus";
import { EventBus, GameEvent } from "../../core/EventBus";
import { Cell } from "../../core/game/Game";
import { GameView } from "../../core/game/GameView";
import { GameView, PlayerView, UnitView } from "../../core/game/GameView";
import { CenterCameraEvent, DragEvent, ZoomEvent } from "../InputHandler";
import {
GoToPlayerEvent,
GoToPositionEvent,
GoToUnitEvent,
} from "./layers/Leaderboard";
export class GoToPlayerEvent implements GameEvent {
constructor(
public player: PlayerView,
public zoom?: number,
) {}
}
export class GoToPositionEvent implements GameEvent {
constructor(
public x: number,
public y: number,
) {}
}
export class GoToUnitEvent implements GameEvent {
constructor(public unit: UnitView) {}
}
export const GOTO_INTERVAL_MS = 16;
export const CAMERA_MAX_SPEED = 15;
@@ -20,6 +33,7 @@ export class TransformHandler {
private lastGoToCallTime: number | null = null;
private target: Cell | null;
private targetScale: number | null = null;
private intervalID: NodeJS.Timeout | null = null;
private changed = false;
@@ -183,6 +197,7 @@ export class TransformHandler {
return;
}
this.target = new Cell(nameLocation.x, nameLocation.y);
this.targetScale = event.zoom ?? null;
this.intervalID = setInterval(() => this.goTo(), GOTO_INTERVAL_MS);
}
@@ -214,10 +229,12 @@ export class TransformHandler {
if (this.target === null) throw new Error("null target");
if (
Math.abs(this.target.x - screenX) + Math.abs(this.target.y - screenY) <
2
) {
const positionClose =
Math.abs(this.target.x - screenX) + Math.abs(this.target.y - screenY) < 2;
const scaleClose =
this.targetScale === null ||
Math.abs(this.scale - this.targetScale) < 0.01;
if (positionClose && scaleClose) {
this.clearTarget();
return;
}
@@ -242,6 +259,27 @@ export class TransformHandler {
-CAMERA_MAX_SPEED,
);
if (this.targetScale !== null) {
const oldScale = this.scale;
const zoomSmoothing = 0.7;
const zoomR = 1 - Math.pow(zoomSmoothing, dt / 1000);
const diff = this.targetScale - this.scale;
const smoothStep = diff * zoomR;
const minStep =
Math.sign(diff) * Math.min(Math.abs(diff), (6.0 * dt) / 1000);
this.scale +=
Math.abs(smoothStep) >= Math.abs(minStep) ? smoothStep : minStep;
// Keep screen center pinned as scale changes: (canvasSize - mapSize) / (2 * scale)
// shifts the apparent center when canvas != map dimensions (always on mobile).
const { width: canvasWidth, height: canvasHeight } = this.boundingRect();
this.offsetX +=
(canvasWidth - this.game.width()) *
(1 / (2 * oldScale) - 1 / (2 * this.scale));
this.offsetY +=
(canvasHeight - this.game.height()) *
(1 / (2 * oldScale) - 1 / (2 * this.scale));
}
this.changed = true;
}
@@ -321,6 +359,7 @@ export class TransformHandler {
this.intervalID = null;
}
this.target = null;
this.targetScale = null;
}
override(x: number = 0, y: number = 0, s: number = 1) {
@@ -7,21 +7,60 @@ import { AlternateViewEvent } from "../../InputHandler";
import { renderTroops } from "../../Utils";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
const shieldIcon = assetUrl("images/ShieldIconWhite.svg");
const swordIcon = assetUrl("images/SwordIconWhite.svg");
const soldierIcon = assetUrl("images/SoldierIcon.svg");
export function troopAttackColor(
attackerTroops: number,
defenderTroops: number,
): string {
return attackerTroops > defenderTroops ? "#66ff66" : "#ffbe3c";
// 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%)";
// 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.
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;
}
export function troopDefenceColor(
// Fraction (01) 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,
myTroops: number,
): string {
return attackerTroops > myTroops ? "#ff4444" : "#ff9944";
opposingTroops: number,
): number {
if (opposingTroops <= 0) return 1;
return Math.min(1, attackerTroops / opposingTroops / BAR_FULL_HEIGHT_RATIO);
}
// Worker returns clusters sorted by size; two near-equal-size fronts can flip
// ordering tick-to-tick. If swapping brings each new position closer to where
// its label already is, swap `next` in place. (clusteredPositions caps at 2.)
export function alignClusterOrder(next: Cell[], prev: (Cell | null)[]): void {
const [a, b] = prev;
if (next.length !== 2 || !a || !b) return;
const dist = (p: Cell, q: Cell) => Math.abs(p.x - q.x) + Math.abs(p.y - q.y);
const direct = dist(next[0], a) + dist(next[1], b);
const swapped = dist(next[1], a) + dist(next[0], b);
if (swapped < direct) [next[0], next[1]] = [next[1], next[0]];
}
// An attack can have multiple disconnected front-line segments, so elements
@@ -31,7 +70,7 @@ interface AttackLabel {
positions: (Cell | null)[];
isIncoming: boolean;
attackerTroops: number;
defenderTroops: number;
barStrength: number;
}
export class AttackingTroopsOverlay implements Layer {
@@ -42,6 +81,9 @@ export class AttackingTroopsOverlay implements Layer {
private inFlightRequest = false;
private isVisible = true;
private onAlternateView: (e: AlternateViewEvent) => void;
// Last transform string written per element; lets renderLayer skip identical
// re-assignments every frame (~60fps × N labels).
private lastTransform = new WeakMap<HTMLDivElement, string>();
constructor(
private readonly game: GameView,
@@ -84,6 +126,10 @@ export class AttackingTroopsOverlay implements Layer {
return 200;
}
private labelScale(): number {
return computeLabelScale(this.transformHandler.scale);
}
tick() {
if (!this.userSettings.attackingTroopsOverlay() || !this.isVisible) {
if (this.labels.size > 0) this.clearAllLabels();
@@ -98,7 +144,7 @@ export class AttackingTroopsOverlay implements Layer {
const activeIDs = new Set<string>();
// Outgoing attacks — green if winning, amber if losing.
// Outgoing: cyan bar widens as our attack outnumbers the defender.
for (const attack of myPlayer.outgoingAttacks()) {
activeIDs.add(attack.id);
if (!attack.targetID) {
@@ -110,10 +156,11 @@ export class AttackingTroopsOverlay implements Layer {
this.removeLabel(attack.id);
continue;
}
this.ensureLabel(attack.id, attack.troops, defender.troops(), false);
const barStrength = computeBarStrength(attack.troops, defender.troops());
this.ensureLabel(attack.id, attack.troops, false, barStrength);
}
// Incoming attacks — red if the attacker outnumbers the player, orange otherwise.
// Incoming: red bar widens as the attacker outnumbers the player.
for (const attack of myPlayer.incomingAttacks()) {
activeIDs.add(attack.id);
const attacker = this.game.playerBySmallID(attack.attackerID);
@@ -121,7 +168,8 @@ export class AttackingTroopsOverlay implements Layer {
this.removeLabel(attack.id);
continue;
}
this.ensureLabel(attack.id, attack.troops, myPlayer.troops(), true);
const barStrength = computeBarStrength(attack.troops, myPlayer.troops());
this.ensureLabel(attack.id, attack.troops, true, barStrength);
}
for (const [id] of this.labels) {
@@ -153,8 +201,8 @@ export class AttackingTroopsOverlay implements Layer {
private ensureLabel(
attackID: string,
attackerTroops: number,
defenderTroops: number,
isIncoming: boolean,
barStrength: number,
) {
let label = this.labels.get(attackID);
if (!label) {
@@ -163,15 +211,15 @@ export class AttackingTroopsOverlay implements Layer {
positions: [],
isIncoming,
attackerTroops,
defenderTroops,
barStrength,
};
this.labels.set(attackID, label);
} else {
label.attackerTroops = attackerTroops;
label.defenderTroops = defenderTroops;
label.barStrength = barStrength;
}
for (const el of label.elements) {
this.updateLabelContent(el, attackerTroops, defenderTroops, isIncoming);
this.updateLabelContent(el, attackerTroops, barStrength);
}
}
@@ -185,6 +233,8 @@ export class AttackingTroopsOverlay implements Layer {
);
this.container.style.transform = `translate(${screenPos.x}px, ${screenPos.y}px) scale(${this.transformHandler.scale})`;
// Hoist the per-frame label scale once; zoom is constant within a frame.
const scale = this.labelScale();
for (const label of this.labels.values()) {
for (let i = 0; i < label.elements.length; i++) {
const el = label.elements[i];
@@ -196,9 +246,14 @@ export class AttackingTroopsOverlay implements Layer {
}
el.style.display = "inline-flex";
// Centre the label on its world position and counter-scale so text
// stays the same screen size regardless of zoom level.
el.style.transform = `translate(${pos.x}px, ${pos.y}px) translate(-50%, -50%) scale(${1 / this.transformHandler.scale})`;
// 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);
}
}
}
}
@@ -209,8 +264,8 @@ export class AttackingTroopsOverlay implements Layer {
lbl.elements.push(
this.createLabelElement(
lbl.attackerTroops,
lbl.defenderTroops,
lbl.isIncoming,
lbl.barStrength,
),
);
lbl.positions.push(null);
@@ -222,16 +277,20 @@ export class AttackingTroopsOverlay implements Layer {
lbl.positions.pop();
}
// Snap large jumps instantly; let the CSS transition handle small advances.
alignClusterOrder(positions, lbl.positions);
// Snap teleport-sized jumps instantly; let the CSS transition handle the rest.
for (let i = 0; i < positions.length; i++) {
const old = lbl.positions[i];
const next = positions[i];
if (old && Math.hypot(next.x - old.x, next.y - old.y) > 50) {
if (old && Math.hypot(next.x - old.x, next.y - old.y) > 200) {
const el = lbl.elements[i];
el.style.transition = "none";
el.style.transform = `translate(${next.x}px, ${next.y}px) translate(-50%, -50%) scale(${1 / this.transformHandler.scale})`;
const transform = `translate(${next.x}px, ${next.y}px) translate(-50%, -50%) scale(${this.labelScale()})`;
el.style.transform = transform;
this.lastTransform.set(el, transform);
requestAnimationFrame(() => {
el.style.transition = "transform 0.2s ease-out";
el.style.transition = "transform 0.25s linear";
});
}
lbl.positions[i] = next;
@@ -245,33 +304,53 @@ export class AttackingTroopsOverlay implements Layer {
el.style.alignItems = "center";
el.style.gap = "3px";
el.style.whiteSpace = "nowrap";
el.style.fontSize = "11px";
el.style.fontSize = "14px";
el.style.fontWeight = "bold";
el.style.padding = "1px 4px";
el.style.padding = "2px 5px";
el.style.borderRadius = "3px";
el.style.backgroundColor = "rgba(0,0,0,0.55)";
el.style.backgroundColor = "rgba(0,0,0,0.85)";
el.style.pointerEvents = "none";
el.style.lineHeight = "1.3";
el.style.transition = "transform 0.2s ease-out";
el.style.transition = "transform 0.25s linear";
el.style.width = "max-content";
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 icon = document.createElement("img");
icon.style.width = "10px";
icon.style.height = "10px";
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;
}
private createLabelElement(
attackerTroops: number,
defenderTroops: number,
isIncoming: boolean,
barStrength: number,
): HTMLDivElement {
const el = this.labelTemplate.cloneNode(true) as HTMLDivElement;
el.style.fontFamily = this.game.config().theme().font();
this.updateLabelContent(el, attackerTroops, defenderTroops, isIncoming);
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`;
this.container.appendChild(el);
return el;
}
@@ -279,20 +358,12 @@ export class AttackingTroopsOverlay implements Layer {
private updateLabelContent(
el: HTMLDivElement,
attackerTroops: number,
defenderTroops: number,
isIncoming: boolean,
barStrength: number,
) {
const icon = el.children[0] as HTMLImageElement;
const span = el.children[1] as HTMLSpanElement;
if (isIncoming) {
icon.src = shieldIcon;
span.style.color = troopDefenceColor(attackerTroops, defenderTroops);
span.textContent = renderTroops(attackerTroops);
} else {
icon.src = swordIcon;
span.style.color = troopAttackColor(attackerTroops, defenderTroops);
span.textContent = renderTroops(attackerTroops);
}
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 removeLabel(attackID: string) {
+16 -16
View File
@@ -16,13 +16,13 @@ import {
} from "../../Transport";
import { renderTroops, translateText } from "../../Utils";
import { getColoredSprite } from "../SpriteLoader";
import { UIState } from "../UIState";
import { Layer } from "./Layer";
import {
GoToPlayerEvent,
GoToPositionEvent,
GoToUnitEvent,
} from "./Leaderboard";
} from "../TransformHandler";
import { UIState } from "../UIState";
import { Layer } from "./Layer";
const soldierIcon = assetUrl("images/SoldierIcon.svg");
const swordIcon = assetUrl("images/SwordIcon.svg");
@@ -283,7 +283,7 @@ export class AttacksDisplay extends LitElement implements Layer {
> `,
onClick: async () => this.attackWarningOnClick(attack),
className:
"text-left text-sky-400 inline-flex items-center gap-0.5 lg:gap-1 min-w-0",
"text-left text-aquarius inline-flex items-center gap-0.5 lg:gap-1 min-w-0",
translate: false,
})}
${!attack.retreating
@@ -293,7 +293,7 @@ export class AttacksDisplay extends LitElement implements Layer {
className: "ml-auto text-left shrink-0",
disabled: attack.retreating,
})
: html`<span class="ml-auto truncate text-blue-400"
: html`<span class="ml-auto truncate text-aquarius"
>(${translateText("events_display.retreating")}...)</span
>`}
</div>
@@ -319,7 +319,7 @@ export class AttacksDisplay extends LitElement implements Layer {
><span class="ml-1">${renderTroops(landAttack.troops)}</span>
${translateText("help_modal.ui_wilderness")}`,
className:
"text-left text-sky-400 inline-flex items-center gap-0.5 lg:gap-1 min-w-0",
"text-left text-aquarius inline-flex items-center gap-0.5 lg:gap-1 min-w-0",
translate: false,
})}
${!landAttack.retreating
@@ -329,7 +329,7 @@ export class AttacksDisplay extends LitElement implements Layer {
className: "ml-auto text-left shrink-0",
disabled: landAttack.retreating,
})
: html`<span class="ml-auto truncate text-blue-400"
: html`<span class="ml-auto truncate text-aquarius"
>(${translateText("events_display.retreating")}...)</span
>`}
</div>
@@ -374,19 +374,19 @@ export class AttacksDisplay extends LitElement implements Layer {
>`,
onClick: () => this.eventBus.emit(new GoToUnitEvent(boat)),
className:
"text-left text-blue-400 inline-flex items-center gap-0.5 lg:gap-1 min-w-0",
"text-left text-aquarius inline-flex items-center gap-0.5 lg:gap-1 min-w-0",
translate: false,
})}
${!boat.retreating()
? this.renderButton({
content: "❌",
${boat.transportShipState().isRetreating
? html`<span class="ml-auto truncate text-aquarius"
>(${translateText("events_display.retreating")}...)</span
>`
: this.renderButton({
content: "\u274C",
onClick: () => this.emitBoatCancelIntent(boat.id()),
className: "ml-auto text-left shrink-0",
disabled: boat.retreating(),
})
: html`<span class="ml-auto truncate text-blue-400"
>(${translateText("events_display.retreating")}...)</span
>`}
disabled: boat.transportShipState().isRetreating,
})}
</div>
`,
);
+1 -1
View File
@@ -4,7 +4,7 @@ import { customElement, query } from "lit/decorators.js";
import { PlayerType } from "../../../core/game/Game";
import { GameView, PlayerView } from "../../../core/game/GameView";
import quickChatData from "resources/QuickChat.json" with { type: "json" };
import quickChatData from "resources/QuickChat.json";
import { EventBus } from "../../../core/EventBus";
import { CloseViewEvent } from "../../InputHandler";
import { SendQuickChatEvent } from "../../Transport";
+6 -6
View File
@@ -158,13 +158,13 @@ export class ControlPanel extends LitElement implements Layer {
<div class="h-full flex">
${greenPercent > 0
? html`<div
class="h-full bg-sky-700 transition-[width] duration-200"
class="h-full bg-malibu-blue transition-[width] duration-200"
style="width: ${greenPercent}%;"
></div>`
: ""}
${orangePercent > 0
? html`<div
class="h-full bg-[#0073b7] transition-[width] duration-200"
class="h-full bg-aquarius transition-[width] duration-200"
style="width: ${orangePercent}%;"
></div>`
: ""}
@@ -213,13 +213,13 @@ export class ControlPanel extends LitElement implements Layer {
<div class="h-full flex">
${greenPercent > 0
? html`<div
class="h-full bg-sky-700 transition-[width] duration-200"
class="h-full bg-malibu-blue transition-[width] duration-200"
style="width: ${greenPercent}%;"
></div>`
: ""}
${orangePercent > 0
? html`<div
class="h-full bg-[#0073b7] transition-[width] duration-200"
class="h-full bg-aquarius transition-[width] duration-200"
style="width: ${orangePercent}%;"
></div>`
: ""}
@@ -326,7 +326,7 @@ export class ControlPanel extends LitElement implements Layer {
.value=${String(Math.round(this.attackRatio * 100))}
@input=${(e: Event) => this.handleRatioSliderInput(e)}
@pointerup=${(e: Event) => this.handleRatioSliderPointerUp(e)}
class="flex-1 h-1.5 accent-blue-500 cursor-pointer"
class="flex-1 h-1.5 accent-aquarius cursor-pointer"
/>
</div>
`;
@@ -373,7 +373,7 @@ export class ControlPanel extends LitElement implements Layer {
.value=${String(Math.round(this.attackRatio * 100))}
@input=${(e: Event) => this.handleRatioSliderInput(e)}
@pointerup=${(e: Event) => this.handleRatioSliderPointerUp(e)}
class="w-full h-1.5 accent-blue-500 cursor-pointer"
class="w-full h-1.5 accent-aquarius cursor-pointer"
/>
</div>
</div>
+1 -1
View File
@@ -34,7 +34,7 @@ import { Layer } from "./Layer";
import { GameView, PlayerView, UnitView } from "../../../core/game/GameView";
import { onlyImages } from "../../../core/Util";
import { renderNumber } from "../../Utils";
import { GoToPlayerEvent, GoToUnitEvent } from "./Leaderboard";
import { GoToPlayerEvent, GoToUnitEvent } from "../TransformHandler";
import { PlaySoundEffectEvent } from "../../sound/Sounds";
import { getMessageTypeClasses, translateText } from "../../Utils";
+30 -39
View File
@@ -26,7 +26,6 @@ export class FxLayer implements Layer {
private allFx: Fx[] = [];
private hasBufferedFrame = false;
private constructionState: Map<number, boolean> = new Map();
constructor(
private game: GameView,
@@ -114,6 +113,26 @@ export class FxLayer implements Layer {
this.eventBus.emit(new PlaySoundEffectEvent("build-warship"));
}
break;
case UnitType.City:
if (unit.owner() === this.game.myPlayer()) {
this.eventBus.emit(new PlaySoundEffectEvent("build-city"));
}
break;
case UnitType.Port:
if (unit.owner() === this.game.myPlayer()) {
this.eventBus.emit(new PlaySoundEffectEvent("build-port"));
}
break;
case UnitType.DefensePost:
if (unit.owner() === this.game.myPlayer()) {
this.eventBus.emit(new PlaySoundEffectEvent("build-defense-post"));
}
break;
case UnitType.SAMLauncher:
if (unit.owner() === this.game.myPlayer()) {
this.eventBus.emit(new PlaySoundEffectEvent("sam-built"));
}
break;
}
}
@@ -207,44 +226,16 @@ export class FxLayer implements Layer {
}
onStructureEvent(unit: UnitView) {
if (!unit.isActive()) {
if (this.fxEnabled()) {
const x = this.game.x(unit.lastTile());
const y = this.game.y(unit.lastTile());
const explosion = new SpriteFx(
this.animatedSpriteLoader,
x,
y,
FxType.BuildingExplosion,
);
this.allFx.push(explosion);
}
this.constructionState.delete(unit.id());
} else {
const wasUnderConstruction = this.constructionState.get(unit.id());
this.constructionState.set(unit.id(), unit.isUnderConstruction());
if (wasUnderConstruction && !unit.isUnderConstruction()) {
if (unit.owner() === this.game.myPlayer()) {
this.onStructureBuilt(unit);
}
}
}
}
onStructureBuilt(unit: UnitView) {
switch (unit.type()) {
case UnitType.City:
this.eventBus.emit(new PlaySoundEffectEvent("build-city"));
break;
case UnitType.Port:
this.eventBus.emit(new PlaySoundEffectEvent("build-port"));
break;
case UnitType.DefensePost:
this.eventBus.emit(new PlaySoundEffectEvent("build-defense-post"));
break;
case UnitType.SAMLauncher:
this.eventBus.emit(new PlaySoundEffectEvent("sam-built"));
break;
if (!unit.isActive() && this.fxEnabled()) {
const x = this.game.x(unit.lastTile());
const y = this.game.y(unit.lastTile());
const explosion = new SpriteFx(
this.animatedSpriteLoader,
x,
y,
FxType.BuildingExplosion,
);
this.allFx.push(explosion);
}
}
@@ -18,6 +18,8 @@ const FastForwardIconSolid = assetUrl("images/FastForwardIconSolidWhite.svg");
const pauseIcon = assetUrl("images/PauseIconWhite.svg");
const playIcon = assetUrl("images/PlayIconWhite.svg");
const settingsIcon = assetUrl("images/SettingIconWhite.svg");
const fullscreenIcon = assetUrl("images/FullscreenIconWhite.svg");
const exitFullscreenIcon = assetUrl("images/ExitFullscreenIconWhite.svg");
@customElement("game-right-sidebar")
export class GameRightSidebar extends LitElement implements Layer {
@@ -36,6 +38,9 @@ export class GameRightSidebar extends LitElement implements Layer {
@state()
private isPaused: boolean = false;
@state()
private isFullscreen: boolean = false;
@state()
private timer: number = 0;
@@ -80,6 +85,21 @@ export class GameRightSidebar extends LitElement implements Layer {
this.requestUpdate();
}
private onFullscreenChange = () => {
this.isFullscreen = !!document.fullscreenElement;
};
connectedCallback() {
super.connectedCallback();
document.addEventListener("fullscreenchange", this.onFullscreenChange);
this.onFullscreenChange();
}
disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener("fullscreenchange", this.onFullscreenChange);
}
getTickIntervalMs() {
return 250;
}
@@ -177,6 +197,18 @@ export class GameRightSidebar extends LitElement implements Layer {
);
}
private onFullscreenButtonClick() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch((err) => {
console.warn("Failed to enter fullscreen:", err);
});
} else {
document.exitFullscreen().catch((err) => {
console.warn("Failed to exit fullscreen:", err);
});
}
}
render() {
if (this.game === undefined) return html``;
@@ -204,6 +236,22 @@ export class GameRightSidebar extends LitElement implements Layer {
<img src=${settingsIcon} alt="settings" width="20" height="20" />
</div>
${document.fullscreenEnabled
? html`<div
class="cursor-pointer"
@click=${this.onFullscreenButtonClick}
>
<img
src=${this.isFullscreen ? exitFullscreenIcon : fullscreenIcon}
alt=${this.isFullscreen
? translateText("fullscreen.exit")
: translateText("fullscreen.enter")}
width="20"
height="20"
/>
</div>`
: ""}
<div class="cursor-pointer" @click=${this.onExitButtonClick}>
<img src=${exitIcon} alt="exit" width="20" height="20" />
</div>
+3 -17
View File
@@ -2,9 +2,10 @@ import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { repeat } from "lit/directives/repeat.js";
import { renderTroops, translateText } from "../../../client/Utils";
import { EventBus, GameEvent } from "../../../core/EventBus";
import { GameView, PlayerView, UnitView } from "../../../core/game/GameView";
import { EventBus } from "../../../core/EventBus";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { formatPercentage, renderNumber } from "../../Utils";
import { GoToPlayerEvent } from "../TransformHandler";
import { Layer } from "./Layer";
interface Entry {
@@ -18,21 +19,6 @@ interface Entry {
player: PlayerView;
}
export class GoToPlayerEvent implements GameEvent {
constructor(public player: PlayerView) {}
}
export class GoToPositionEvent implements GameEvent {
constructor(
public x: number,
public y: number,
) {}
}
export class GoToUnitEvent implements GameEvent {
constructor(public unit: UnitView) {}
}
@customElement("leader-board")
export class Leaderboard extends LitElement implements Layer {
public game: GameView | null = null;
@@ -510,7 +510,7 @@ export class PerformanceOverlay extends LitElement implements Layer {
if (!this.isUserSettingsListenerAttached) {
globalThis.addEventListener(
`${USER_SETTINGS_CHANGED_EVENT}:${PERFORMANCE_OVERLAY_KEY}`,
this.onUserSettingsChanged,
this.onUserSettingsChanged as EventListener,
);
this.isUserSettingsListenerAttached = true;
}
@@ -522,7 +522,7 @@ export class PerformanceOverlay extends LitElement implements Layer {
if (this.isUserSettingsListenerAttached) {
globalThis.removeEventListener(
`${USER_SETTINGS_CHANGED_EVENT}:${PERFORMANCE_OVERLAY_KEY}`,
this.onUserSettingsChanged,
this.onUserSettingsChanged as EventListener,
);
this.isUserSettingsListenerAttached = false;
}
+22 -36
View File
@@ -34,7 +34,9 @@ import { TransformHandler } from "../TransformHandler";
import { ImmunityBarVisibleEvent } from "./ImmunityTimer";
import { Layer } from "./Layer";
import { CloseRadialMenuEvent } from "./RadialMenu";
import "./RelationSmiley";
import { SpawnBarVisibleEvent } from "./SpawnTimer";
const soldierIconAquarius = assetUrl("images/SoldierIconAquarius.svg");
const allianceIcon = assetUrl("images/AllianceIcon.svg");
const warshipIcon = assetUrl("images/BattleshipIconWhite.svg");
const cityIcon = assetUrl("images/CityIconWhite.svg");
@@ -182,37 +184,21 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
this.requestUpdate();
}
private getPlayerNameColor(
player: PlayerView,
myPlayer: PlayerView | null | undefined,
isFriendly: boolean,
): string {
private getPlayerNameColor(isFriendly: boolean): string {
if (isFriendly) return "text-green-500";
if (
myPlayer &&
myPlayer !== player &&
player.type() === PlayerType.Nation
) {
const relation =
this.playerProfile?.relations[myPlayer.smallID()] ?? Relation.Neutral;
return this.getRelationClass(relation);
}
return "text-white";
}
private getRelationClass(relation: Relation): string {
switch (relation) {
case Relation.Hostile:
return "text-red-500";
case Relation.Distrustful:
return "text-red-300";
case Relation.Neutral:
return "text-white";
case Relation.Friendly:
return "text-green-500";
default:
return "text-white";
}
private getRelationSmiley(
player: PlayerView,
myPlayer: PlayerView | null | undefined,
): TemplateResult | string {
if (!myPlayer || myPlayer === player || player.type() !== PlayerType.Nation)
return "";
const relation =
this.playerProfile?.relations[myPlayer.smallID()] ?? Relation.Neutral;
if (relation === Relation.Neutral) return "";
return html`<relation-smiley .relation=${relation}></relation-smiley>`;
}
private getRelationName(relation: Relation): string {
@@ -337,17 +323,18 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
<div
class="flex flex-1 flex-col items-center justify-center text-xs font-bold ${attackingTroops >
0
? "text-sky-400"
? "text-aquarius"
: "text-white/40"} drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
translate="no"
>
<span class="flex items-center gap-px leading-none text-xs"
><img
src=${soldierIcon}
class="w-2.5 h-2.5"
style="${attackingTroops > 0
? "filter: brightness(0) saturate(100%) invert(62%) sepia(80%) saturate(500%) hue-rotate(175deg) brightness(100%); opacity:1"
: "filter: brightness(0) invert(1); opacity:0.4"}"
class="w-2.5 h-2.5 inline-block ${attackingTroops > 0
? ""
: "brightness-0 invert opacity-40"}"
src=${attackingTroops > 0 ? soldierIconAquarius : soldierIcon}
alt=""
aria-hidden="true"
/></span
>
<span class="tabular-nums leading-none text-sm mt-0.5"
@@ -363,8 +350,6 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
<div class="flex flex-col justify-between self-stretch">
<div
class="flex items-center gap-2 font-bold text-sm lg:text-lg ${this.getPlayerNameColor(
player,
myPlayer,
isFriendly ?? false,
)}"
>
@@ -375,6 +360,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
/>`
: html``}
<span>${player.displayName()}</span>
${this.getRelationSmiley(player, myPlayer)}
${playerTeam !== "" && player.type() !== PlayerType.Bot
? html`<div class="flex flex-col leading-tight">
<span class="text-gray-400 text-xs font-normal"
@@ -445,7 +431,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
: ""}
${orangePercent > 0
? html`<div
class="h-full bg-[#0073b7] transition-[width] duration-200"
class="h-full bg-malibu-blue transition-[width] duration-200"
style="width: ${orangePercent}%;"
></div>`
: ""}
+27 -6
View File
@@ -155,12 +155,30 @@ export class RailroadLayer implements Layer {
if (context === null) throw new Error("2d context not supported");
this.context = context;
// Firefox's GPU limit is 8192, only known browser issue
const maxTextureSize = 8192;
const scaleX = maxTextureSize / this.game.width();
const scaleY = maxTextureSize / this.game.height();
const targetScale = Math.min(2, scaleX, scaleY);
this.canvas.width = Math.max(
1,
Math.floor(this.game.width() * targetScale),
);
this.canvas.height = Math.max(
1,
Math.floor(this.game.height() * targetScale),
);
// Enable smooth scaling
this.context.imageSmoothingEnabled = true;
this.context.imageSmoothingQuality = "high";
this.canvas.width = this.game.width() * 2;
this.canvas.height = this.game.height() * 2;
// Scale context so existing *2 rendering math continues to work automatically
this.context.scale(
this.canvas.width / (this.game.width() * 2),
this.canvas.height / (this.game.height() * 2),
);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
for (const [_, rail] of this.existingRailroads) {
@@ -215,10 +233,13 @@ export class RailroadLayer implements Layer {
return;
}
const srcX = visLeft * 2;
const srcY = visTop * 2;
const srcW = visWidth * 2;
const srcH = visHeight * 2;
const actualScaleX = this.canvas.width / this.game.width();
const actualScaleY = this.canvas.height / this.game.height();
const srcX = visLeft * actualScaleX;
const srcY = visTop * actualScaleY;
const srcW = visWidth * actualScaleX;
const srcH = visHeight * actualScaleY;
const dstX = -this.game.width() / 2 + visLeft;
const dstY = -this.game.height() / 2 + visTop;
@@ -106,7 +106,6 @@ export function computeDirection(
if (dx1 === 1 && dx2 === 0 && dy2 === 1) return RailType.BOTTOM_LEFT;
if (dx1 === -1 && dx2 === 0 && dy2 === 1) return RailType.BOTTOM_RIGHT;
}
console.warn(`Invalid rail segment: ${dx1}:${dy1}, ${dx2}:${dy2}`);
return RailType.VERTICAL;
}
@@ -0,0 +1,79 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Relation } from "../../../core/game/Game";
type FaceData = {
color: string;
eyeCy: number;
mouth: string;
brows?: string[];
};
const RELATION_FACES: Partial<Record<Relation, FaceData>> = {
[Relation.Hostile]: {
color: "#ef4444",
eyeCy: 7.5,
mouth: "M5 12 Q8 9 11 12",
brows: ["M4 5.5 L6.5 7", "M12 5.5 L9.5 7"],
},
[Relation.Distrustful]: {
color: "#f97316",
eyeCy: 6.8,
mouth: "M5.5 11 Q8 9.2 10.5 11",
},
[Relation.Friendly]: {
color: "#22c55e",
eyeCy: 6.5,
mouth: "M5 10 Q8 13 11 10",
},
};
@customElement("relation-smiley")
export class RelationSmiley extends LitElement {
@property({ type: Number })
relation: Relation = Relation.Neutral;
createRenderRoot() {
return this;
}
render() {
const face = RELATION_FACES[this.relation];
if (!face) return html``;
const { color, eyeCy, mouth, brows } = face;
return html`<svg
width="16"
height="16"
viewBox="0 0 16 16"
xmlns="http://www.w3.org/2000/svg"
style="flex-shrink:0"
aria-hidden="true"
>
<circle
cx="8"
cy="8"
r="6.5"
stroke="${color}"
stroke-width="1.4"
fill="none"
/>
${brows?.map(
(d) =>
html`<path
d="${d}"
stroke="${color}"
stroke-width="1.4"
stroke-linecap="round"
/>`,
)}
<circle cx="5.8" cy="${eyeCy}" r="0.9" fill="${color}" />
<circle cx="10.2" cy="${eyeCy}" r="0.9" fill="${color}" />
<path
d="${mouth}"
stroke="${color}"
stroke-width="1.4"
fill="none"
stroke-linecap="round"
/>
</svg>`;
}
}
+1 -1
View File
@@ -98,7 +98,7 @@ export class ReplayPanel extends LitElement implements Layer {
private renderSpeedButton(value: ReplaySpeedMultiplier, label: string) {
const backgroundColor =
this._replaySpeedMultiplier === value ? "bg-blue-400" : "";
this._replaySpeedMultiplier === value ? "bg-malibu-blue" : "";
return html`
<button
+5 -2
View File
@@ -18,7 +18,10 @@ export class SpawnTimer extends LitElement implements Layer {
private ratios = [0];
private _barVisible = false;
private colors = ["rgba(0, 128, 255, 0.7)", "rgba(0, 0, 0, 0.5)"];
private colors = [
"rgb(from var(--color-malibu-blue) r g b / 0.7)",
"rgba(0, 0, 0, 0.5)",
];
private isVisible = false;
@@ -43,7 +46,7 @@ export class SpawnTimer extends LitElement implements Layer {
this.ratios = [
this.game.ticks() / this.game.config().numSpawnPhaseTurns(),
];
this.colors = ["rgba(0, 128, 255, 0.7)"];
this.colors = ["rgb(from var(--color-malibu-blue) r g b / 0.7)"];
} else {
this.ratios = [];
this.colors = [];
@@ -56,6 +56,8 @@ export class SpriteFactory {
private transformHandler: TransformHandler;
private renderSprites: boolean;
private readonly textureCache: Map<string, PIXI.Texture> = new Map();
private colorCanvas: HTMLCanvasElement | null = null;
private colorCtx: CanvasRenderingContext2D | null = null;
private readonly structuresInfos: Map<
UnitType,
@@ -81,6 +83,21 @@ export class SpriteFactory {
this.structuresInfos.forEach((u, unitType) => this.loadIcon(u, unitType));
}
public clearCache() {
for (const texture of this.textureCache.values()) {
if (texture && texture !== PIXI.Texture.EMPTY) {
try {
texture.destroy(true);
} catch (e) {
console.error("Error clearing texture cache:", e);
}
}
}
this.textureCache.clear();
this.colorCanvas = null;
this.colorCtx = null;
}
private loadIcon(
unitInfo: {
iconPath: string;
@@ -89,6 +106,10 @@ export class SpriteFactory {
unitType: UnitType,
) {
const image = new Image();
// crossOrigin must be set before src so the fetch is CORS-checked.
// Without this, an icon served from CDN_BASE taints structureCanvas
// and PIXI.Texture.from rejects the upload to WebGL.
image.crossOrigin = "anonymous";
image.src = unitInfo.iconPath;
image.onload = () => {
unitInfo.image = image;
@@ -104,6 +125,10 @@ export class SpriteFactory {
private invalidateTextureCache(unitType: UnitType) {
for (const key of Array.from(this.textureCache.keys())) {
if (key.includes(`-${unitType}`)) {
const tex = this.textureCache.get(key);
if (tex && tex !== PIXI.Texture.EMPTY) {
tex.destroy(true);
}
this.textureCache.delete(key);
}
}
@@ -281,7 +306,7 @@ export class SpriteFactory {
structureType: UnitType,
isConstruction: boolean,
isMarkedForDeletion: boolean,
shape: string,
shape: keyof typeof ICON_SIZE,
renderIcon: boolean,
): PIXI.Texture {
const structureCanvas = document.createElement("canvas");
@@ -451,7 +476,7 @@ export class SpriteFactory {
context.restore();
}
return PIXI.Texture.from(structureCanvas);
return PIXI.Texture.from(structureCanvas, true);
}
public createRange(
@@ -507,14 +532,18 @@ export class SpriteFactory {
image: HTMLImageElement,
color: string,
): HTMLCanvasElement {
const imageCanvas = document.createElement("canvas");
imageCanvas.width = image.width;
imageCanvas.height = image.height;
const ctx = imageCanvas.getContext("2d")!;
if (!this.colorCanvas || !this.colorCtx) {
this.colorCanvas = document.createElement("canvas");
this.colorCtx = this.colorCanvas.getContext("2d")!;
}
const { colorCanvas, colorCtx: ctx } = this;
if (colorCanvas.width !== image.width) colorCanvas.width = image.width;
if (colorCanvas.height !== image.height) colorCanvas.height = image.height;
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = color;
ctx.fillRect(0, 0, imageCanvas.width, imageCanvas.height);
ctx.fillRect(0, 0, colorCanvas.width, colorCanvas.height);
ctx.globalCompositeOperation = "destination-in";
ctx.drawImage(image, 0, 0);
return imageCanvas;
return colorCanvas;
}
}
+141 -56
View File
@@ -101,7 +101,11 @@ export class StructureIconsLayer implements Layer {
private visibilityStateDirty = true;
private pendingConfirm: MouseUpEvent | null = null;
private hasHiddenStructure = false;
private rebuildPending = false;
potentialUpgrade: StructureRenderInfo | undefined;
private filterRedArray: OutlineFilter[] = [];
private filterGreenArray: OutlineFilter[] = [];
private filterWhiteArray: OutlineFilter[] = [];
constructor(
private game: GameView,
@@ -119,16 +123,37 @@ export class StructureIconsLayer implements Layer {
}
async setupRenderer() {
if (this.renderer) {
this.renderer.destroy(true);
this.rootStage.removeChildren();
}
try {
await PIXI.Assets.load(bitmapFont);
} catch (error) {
console.error("Failed to load bitmap font:", error);
}
const renderer = new PIXI.WebGLRenderer();
this.pixicanvas = document.createElement("canvas");
this.pixicanvas.width = window.innerWidth;
this.pixicanvas.height = window.innerHeight;
// This will prefer WebGL, eventually WebGPU, and fallback to Canvas
// Restrict using 'preferences: ["WebGPU", "WebGL"]' or
// 'preferences: "WebGPU"' later if needed
const renderer = await PIXI.autoDetectRenderer({
canvas: this.pixicanvas,
resolution: 1,
width: this.pixicanvas.width,
height: this.pixicanvas.height,
antialias: false,
clearBeforeRender: true,
backgroundAlpha: 0,
backgroundColor: 0x00000000,
});
console.info(`Using ${renderer.name} for structure icons layer`);
this.iconsStage = new PIXI.Container();
this.iconsStage.position.set(0, 0);
this.iconsStage.setSize(this.pixicanvas.width, this.pixicanvas.height);
@@ -154,25 +179,87 @@ export class StructureIconsLayer implements Layer {
this.rootStage.position.set(0, 0);
this.rootStage.setSize(this.pixicanvas.width, this.pixicanvas.height);
await renderer.init({
canvas: this.pixicanvas,
resolution: 1,
width: this.pixicanvas.width,
height: this.pixicanvas.height,
antialias: false,
clearBeforeRender: true,
backgroundAlpha: 0,
backgroundColor: 0x00000000,
});
this.filterRedArray = [
new OutlineFilter({ thickness: 2, color: "rgba(255, 0, 0, 1)" }),
];
this.filterGreenArray = [
new OutlineFilter({ thickness: 2, color: "rgba(0, 255, 0, 1)" }),
];
this.filterWhiteArray = [
new OutlineFilter({ thickness: 2, color: "rgb(255, 255, 255)" }),
];
this.renderer = renderer;
if (this.renderer.name === "webgpu") {
// Listen to device loss as PixiJS doesn't handle WebGPU context loss itself
const gpuRenderer = this.renderer as PIXI.WebGPURenderer;
gpuRenderer.gpu.device.lost.then(() => {
this.redraw();
});
}
if (this.renderer.name === "webgl") {
this.renderer.runners.contextChange.add({
// Listen to contextChange as PixiJS handles WebGL context loss and restores itself.
// Don't listen to "webglcontextrestored" event directly as it can fire before PixiJS is ready.
contextChange: () => {
requestAnimationFrame(() => {
this.redraw();
});
},
});
}
this.rendererInitialized = true;
}
private rebuildAllIcons() {
this.clearGhostStructure();
this.factory.clearCache();
const allUnitIds = Array.from(this.seenUnitIds);
this.seenUnitIds.clear();
for (const unitId of allUnitIds) {
const render = this.rendersByUnitId.get(unitId);
if (render) {
render.iconContainer?.destroy({ children: true });
render.dotContainer?.destroy({ children: true });
render.levelContainer?.destroy({ children: true });
}
const unitView = this.game.unit(unitId);
if (unitView && unitView.isActive()) {
this.handleActiveUnit(unitView);
} else {
this.rendersByUnitId.delete(unitId);
}
}
}
shouldTransform(): boolean {
return false;
}
async redraw() {
if (this.rebuildPending) {
return;
}
if (this.rendererOrGLContextLost()) {
return;
}
this.rebuildPending = true;
try {
if (this.renderer?.name === "webgpu") {
this.rendererInitialized = false;
await this.setupRenderer();
}
this.resizeCanvas();
this.rebuildAllIcons();
} finally {
this.rebuildPending = false;
}
}
async init() {
this.eventBus.on(ToggleStructuresEvent, (e) =>
this.toggleStructures(e.structureTypes),
@@ -188,15 +275,27 @@ export class StructureIconsLayer implements Layer {
window.addEventListener("resize", () => this.resizeCanvas());
await this.setupRenderer();
this.redraw();
this.resizeCanvas();
}
private rendererOrGLContextLost(): boolean {
if (!this.renderer || !this.rendererInitialized) return true;
if (this.renderer.name === "webgl") {
// For WebGL, check isLost to prevent ungraceful handling by PixiJS:
// its GL > logPrettyShaderError throws, when getShaderSource returns null
// Needs to be fixed in PixiJS, in meantime prevent it from here
return (this.renderer as PIXI.WebGLRenderer).context?.isLost === true;
}
return false;
}
resizeCanvas() {
if (this.renderer) {
this.pixicanvas.width = window.innerWidth;
this.pixicanvas.height = window.innerHeight;
this.renderer.resize(innerWidth, innerHeight, 1);
if (this.rendererOrGLContextLost()) {
return;
}
this.pixicanvas.width = window.innerWidth;
this.pixicanvas.height = window.innerHeight;
this.renderer?.resize(innerWidth, innerHeight, 1);
}
tick() {
@@ -220,12 +319,8 @@ export class StructureIconsLayer implements Layer {
this.game.config().userSettings()?.structureSprites() ?? true;
}
redraw() {
this.resizeCanvas();
}
renderLayer(mainContext: CanvasRenderingContext2D) {
if (!this.renderer || !this.rendererInitialized) {
if (this.rendererOrGLContextLost()) {
return;
}
@@ -254,8 +349,10 @@ export class StructureIconsLayer implements Layer {
scale > DOTS_ZOOM_THRESHOLD &&
(scale <= ZOOM_THRESHOLD || !this.renderSprites);
this.levelsStage!.visible = scale > ZOOM_THRESHOLD && this.renderSprites;
this.renderer.render(this.rootStage);
mainContext.drawImage(this.renderer.canvas, 0, 0);
if (this.renderer) {
this.renderer?.render(this.rootStage);
mainContext.drawImage(this.renderer.canvas, 0, 0);
}
}
renderGhost() {
@@ -333,9 +430,7 @@ export class StructureIconsLayer implements Layer {
canUpgrade: false,
});
this.updateGhostPrice(0, showPrice);
this.ghostUnit.container.filters = [
new OutlineFilter({ thickness: 2, color: "rgba(255, 0, 0, 1)" }),
];
this.ghostUnit.container.filters = this.filterRedArray;
this.pendingConfirm = null;
return;
}
@@ -356,20 +451,14 @@ export class StructureIconsLayer implements Layer {
this.potentialUpgrade = undefined;
}
if (this.potentialUpgrade) {
this.potentialUpgrade.iconContainer.filters = [
new OutlineFilter({ thickness: 2, color: "rgba(0, 255, 0, 1)" }),
];
this.potentialUpgrade.dotContainer.filters = [
new OutlineFilter({ thickness: 2, color: "rgba(0, 255, 0, 1)" }),
];
this.potentialUpgrade.iconContainer.filters = this.filterGreenArray;
this.potentialUpgrade.dotContainer.filters = this.filterGreenArray;
}
// No overlapping when a structure is upgradable
this.uiState.overlappingRailroads = [];
this.uiState.ghostRailPaths = [];
} else if (unit.canBuild === false) {
this.ghostUnit.container.filters = [
new OutlineFilter({ thickness: 2, color: "rgba(255, 0, 0, 1)" }),
];
this.ghostUnit.container.filters = this.filterRedArray;
this.uiState.overlappingRailroads = [];
this.uiState.ghostRailPaths = [];
} else {
@@ -536,8 +625,8 @@ export class StructureIconsLayer implements Layer {
private clearGhostStructure() {
this.pendingConfirm = null;
if (this.ghostUnit) {
this.ghostUnit.container.destroy();
this.ghostUnit.range?.destroy();
this.ghostUnit.container.destroy({ children: true });
this.ghostUnit.range?.destroy({ children: true });
this.ghostUnit = null;
}
if (this.potentialUpgrade) {
@@ -585,7 +674,7 @@ export class StructureIconsLayer implements Layer {
return;
}
this.ghostUnit.range?.destroy();
this.ghostUnit.range?.destroy({ children: true });
this.ghostUnit.range = null;
this.ghostUnit.rangeLevel = level;
this.ghostUnit.targetingAlly = targetingAlly;
@@ -676,12 +765,8 @@ export class StructureIconsLayer implements Layer {
render.iconContainer.alpha = structureInfos.visible ? 1 : 0.3;
render.dotContainer.alpha = structureInfos.visible ? 1 : 0.3;
if (structureInfos.visible && this.hasHiddenStructure) {
render.iconContainer.filters = [
new OutlineFilter({ thickness: 2, color: "rgb(255, 255, 255)" }),
];
render.dotContainer.filters = [
new OutlineFilter({ thickness: 2, color: "rgb(255, 255, 255)" }),
];
render.iconContainer.filters = this.filterWhiteArray;
render.dotContainer.filters = this.filterWhiteArray;
} else {
render.iconContainer.filters = [];
render.dotContainer.filters = [];
@@ -691,8 +776,8 @@ export class StructureIconsLayer implements Layer {
private checkForDeletionState(render: StructureRenderInfo, unit: UnitView) {
if (unit.markedForDeletion() !== false) {
render.iconContainer?.destroy();
render.dotContainer?.destroy();
render.iconContainer?.destroy({ children: true });
render.dotContainer?.destroy({ children: true });
render.iconContainer = this.createIconSprite(unit);
render.dotContainer = this.createDotSprite(unit);
this.modifyVisibility(render);
@@ -705,8 +790,8 @@ export class StructureIconsLayer implements Layer {
) {
if (render.underConstruction && !unit.isUnderConstruction()) {
render.underConstruction = false;
render.iconContainer?.destroy();
render.dotContainer?.destroy();
render.iconContainer?.destroy({ children: true });
render.dotContainer?.destroy({ children: true });
render.iconContainer = this.createIconSprite(unit);
render.dotContainer = this.createDotSprite(unit);
this.modifyVisibility(render);
@@ -716,8 +801,8 @@ export class StructureIconsLayer implements Layer {
private checkForOwnershipChange(render: StructureRenderInfo, unit: UnitView) {
if (render.owner !== unit.owner().id()) {
render.owner = unit.owner().id();
render.iconContainer?.destroy();
render.dotContainer?.destroy();
render.iconContainer?.destroy({ children: true });
render.dotContainer?.destroy({ children: true });
render.iconContainer = this.createIconSprite(unit);
render.dotContainer = this.createDotSprite(unit);
this.modifyVisibility(render);
@@ -727,9 +812,9 @@ export class StructureIconsLayer implements Layer {
private checkForLevelChange(render: StructureRenderInfo, unit: UnitView) {
if (render.level !== unit.level()) {
render.level = unit.level();
render.iconContainer?.destroy();
render.levelContainer?.destroy();
render.dotContainer?.destroy();
render.iconContainer?.destroy({ children: true });
render.levelContainer?.destroy({ children: true });
render.dotContainer?.destroy({ children: true });
render.iconContainer = this.createIconSprite(unit);
render.levelContainer = this.createLevelSprite(unit);
render.dotContainer = this.createDotSprite(unit);
@@ -834,9 +919,9 @@ export class StructureIconsLayer implements Layer {
}
private deleteStructure(render: StructureRenderInfo) {
render.iconContainer?.destroy();
render.levelContainer?.destroy();
render.dotContainer?.destroy();
render.iconContainer?.destroy({ children: true });
render.levelContainer?.destroy({ children: true });
render.dotContainer?.destroy({ children: true });
const unitId = render.unit.id();
this.rendersByUnitId.delete(unitId);
this.seenUnitIds.delete(unitId);
@@ -87,6 +87,10 @@ export class StructureLayer implements Layer {
private loadIcon(unitType: string, config: UnitRenderConfig) {
const image = new Image();
// crossOrigin must be set before src so the fetch is CORS-checked.
// Without this, an icon served from CDN_BASE taints any canvas/texture
// it's drawn into, and WebGL refuses to upload it via texImage2D.
image.crossOrigin = "anonymous";
image.src = config.icon;
image.onload = () => {
this.unitIcons.set(unitType, image);
@@ -167,10 +167,6 @@ export class TerritoryLayer implements Layer {
}
private spawnHighlight() {
if (this.game.ticks() % 5 === 0) {
return;
}
this.highlightContext.clearRect(
0,
0,
+36 -4
View File
@@ -456,11 +456,43 @@ export class UnitLayer implements Layer {
}
private handleWarShipEvent(unit: UnitView) {
if (unit.targetUnitId()) {
this.drawSprite(unit, colord("rgb(200,0,0)"));
} else {
this.drawSprite(unit);
if (unit.warshipState().state !== "patrolling" && unit.isActive()) {
if (unit.warshipState().isInCombat) {
this.drawSprite(unit, colord("rgb(200,0,0)"));
} else {
this.drawSprite(unit);
}
this.drawRetreatCross(unit);
return;
}
if (unit.warshipState().isInCombat) {
this.drawSprite(unit, colord("rgb(200,0,0)"));
return;
}
this.drawSprite(unit);
}
private drawRetreatCross(unit: UnitView) {
// Blink: 500ms on, 500ms off
if (Math.floor(Date.now() / 500) % 2 === 0) return;
const x = this.game.x(unit.tile());
const y = this.game.y(unit.tile());
const ctx = this.context;
ctx.save();
const cx = x + 0.5;
const cy = y + 0.5;
ctx.lineCap = "square";
ctx.strokeStyle = "rgb(36,36,36)";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(cx, cy - 1.5);
ctx.lineTo(cx, cy + 1.5);
ctx.moveTo(cx - 1.5, cy);
ctx.lineTo(cx + 1.5, cy);
ctx.stroke();
ctx.restore();
}
private handleShellEvent(unit: UnitView) {
+19 -16
View File
@@ -73,30 +73,33 @@ export class WinModal extends LitElement implements Layer {
? "flex justify-between gap-2.5"
: "hidden"}"
>
<button
<o-button
variant="primary"
width="block"
class="flex-1"
translationKey="win_modal.exit"
@click=${this._handleExit}
class="flex-1 px-3 py-3 text-base cursor-pointer bg-blue-500/60 text-white border-0 rounded-sm transition-all duration-200 hover:bg-blue-500/80 hover:-translate-y-px active:translate-y-px"
>
${translateText("win_modal.exit")}
</button>
></o-button>
${this.isRankedGame
? html`
<button
<o-button
variant="primary"
width="block"
class="flex-1"
translationKey="win_modal.requeue"
@click=${this._handleRequeue}
class="flex-1 px-3 py-3 text-base cursor-pointer bg-purple-600 text-white border-0 rounded-sm transition-all duration-200 hover:bg-purple-500 hover:-translate-y-px active:translate-y-px"
>
${translateText("win_modal.requeue")}
</button>
></o-button>
`
: null}
<button
@click=${this.hide}
class="flex-1 px-3 py-3 text-base cursor-pointer bg-blue-500/60 text-white border-0 rounded-sm transition-all duration-200 hover:bg-blue-500/80 hover:-translate-y-px active:translate-y-px"
>
${this.game?.myPlayer()?.isAlive()
<o-button
variant="primary"
width="block"
class="flex-1"
.title=${this.game?.myPlayer()?.isAlive()
? translateText("win_modal.keep")
: translateText("win_modal.spectate")}
</button>
@click=${this.hide}
></o-button>
</div>
</div>
`;
+2 -1
View File
@@ -122,7 +122,8 @@ export class NavalTarget extends Target {
if (
!this.ended &&
(!this.unit.isActive() ||
(this.unit.type() === UnitType.TransportShip && this.unit.retreating()))
(this.unit.type() === UnitType.TransportShip &&
this.unit.transportShipState().isRetreating))
) {
this.ended = true;
}
+31 -4
View File
@@ -5,7 +5,34 @@
@theme {
--default-ring-width: 3px;
--default-ring-color: var(text-[#0073b7]);
--default-ring-color: var(--color-malibu-blue);
--font-display: "OpenFront", Inter, Arial, sans-serif;
/* Openfront.io brand palette — see Openfront_Brand_Guidelines.pdf §4 */
--color-malibu-blue: #0084d1;
--color-aquarius: #3fa9f5;
--color-dawn-blue: #cccccc;
--color-bright-white: #ffffff;
/* Openfront Masters sub-brand */
--color-cyber-yellow: #ffd700;
--color-limestone: #999999;
/* Background & decorative — not for standalone UI use */
--color-deep-navy: #0a1628;
--color-hex-cyan: #00c8ff;
--color-frame-orange: #f97316;
/* App-specific surface colors */
--color-surface: #0a1628;
--shadow-malibu-blue: 0 0 15px rgba(0, 132, 209, 0.2);
--shadow-malibu-blue-soft: 0 0 12px rgba(0, 132, 209, 0.2);
--shadow-malibu-blue-strong: 0 0 15px rgba(0, 132, 209, 0.3);
--shadow-malibu-blue-pill: 0 0 6px rgba(0, 132, 209, 0.35);
--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);
}
@layer base {
@@ -113,7 +140,7 @@ body {
padding: 15px 20px;
font-size: 16px;
cursor: pointer;
background-color: #0073b7;
background-color: var(--color-malibu-blue);
color: white;
border: none;
border-radius: 8px;
@@ -123,7 +150,7 @@ body {
}
.start-game-button:not(:disabled):hover {
background-color: #0073b7;
background-color: var(--color-aquarius);
}
.start-game-button:disabled {
@@ -580,7 +607,7 @@ label.option-card:hover {
/* News Button Notification */
news-button .active button {
position: relative;
border-color: #0073b7 !important;
border-color: var(--color-malibu-blue) !important;
border-width: 2px !important;
box-shadow:
0 0 0 1px rgba(37, 99, 235, 0.5),
-88
View File
@@ -1,88 +0,0 @@
.c-button {
background: var(--primaryColor);
color: #fff;
cursor: pointer;
outline: none;
display: inline-block;
font-size: 16px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
border: 1px solid transparent;
text-align: center;
padding: 0.8rem 1rem;
border-radius: 0.75rem;
transition: var(--transition);
@media (min-width: 1024px) {
font-size: 18px;
}
}
.c-button:hover,
.c-button:active,
.c-button:focus {
background: var(--primaryColorHover);
transition: var(--transition);
transform: translateY(-1px);
}
.c-button:disabled {
background: var(--primaryColorDisabled);
opacity: 0.7;
cursor: not-allowed;
transition: var(--transition);
}
.c-button--secondary {
background: var(--secondaryColor);
color: var(--fontColor);
}
.c-button--secondary:hover,
.c-button--secondary:active,
.c-button--secondary:focus {
background: var(--secondaryColorHover);
}
.c-button--block {
display: block;
width: 100%;
}
.c-button--blockDesktop {
display: block;
width: 100%;
@media (min-width: 1024px) {
width: auto;
margin: 0 auto;
}
}
.dark .c-button {
background: var(--primaryColorDark);
color: var(--fontColorLight);
}
.dark .c-button:hover,
.dark .c-button:active,
.dark .c-button:focus {
background: var(--primaryColorHoverDark);
}
.dark .c-button:disabled {
background: var(--primaryColorDisabledDark);
opacity: 0.7;
}
.dark .c-button--secondary {
background: var(--secondaryColorDark);
color: var(--fontColorDark);
}
.dark .c-button--secondary:hover,
.dark .c-button--secondary:active,
.dark .c-button--secondary:focus {
background: var(--secondaryColorHoverDark);
}
+7 -14
View File
@@ -7,33 +7,26 @@
--fontColor: #202020;
--fontColorLight: #fff;
/* Palette: Deep French Blue / Muted Cyan / Black / Forest Teal */
--frenchBlue: #1f3a70; /* Deeper French Blue */
--cyanBlue: #0f6ca3; /* Muted Cyan secondary */
--tealAccent: #1f6c5a; /* Darker Teal accent */
--primaryColor: var(--frenchBlue);
--primaryColorHover: var(--tealAccent);
--primaryColorHover: var(--color-malibu-blue);
--primaryColorDisabled: linear-gradient(
to right,
rgb(74, 74, 74),
rgb(61, 61, 61)
);
--secondaryColor: var(--cyanBlue);
--secondaryColorHover: var(--cyanBlue);
--secondaryColor: var(--color-malibu-blue);
--secondaryColorHover: var(--color-aquarius);
--transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
--primaryColorDark: var(--frenchBlue);
--primaryColorHoverDark: var(--tealAccent);
--primaryColorHoverDark: var(--color-malibu-blue);
--primaryColorDisabledDark: #4b5563;
--secondaryColorDark: var(--tealAccent);
--secondaryColorHoverDark: var(--frenchBlue);
--secondaryColorDark: var(--color-malibu-blue);
--secondaryColorHoverDark: var(--color-aquarius);
--fontColorDark: #f3f4f6;
/* Achievements */
--medal-easy: #cd7f32;
--medal-medium: #c0c0c0;
--medal-hard: #ffd700;
--medal-hard: var(--color-cyber-yellow);
--medal-impossible: #d32f2f;
--medal-custom: #2196f3;
}
+1 -1
View File
@@ -30,6 +30,6 @@
}
.l-header__highlightText {
color: #0073b7;
color: var(--color-malibu-blue);
font-weight: 700;
}