Merge remote-tracking branch 'upstream/main' into local-attack

This commit is contained in:
Aotumuri
2026-01-12 12:22:52 +09:00
63 changed files with 2826 additions and 2277 deletions
+71 -521
View File
@@ -127,552 +127,102 @@
<div
id="mobile-menu-backdrop"
class="lg:!hidden [.in-game_&]:hidden hidden pointer-events-none [&.open]:block [&.open]:pointer-events-auto [&.open]:fixed [&.open]:inset-0 [&.open]:bg-black/50 [&.open]:z-[40000] transition-opacity"
class="lg:!hidden [.in-game_&]:hidden hidden pointer-events-none [&.open]:block [&.open]:pointer-events-auto [&.open]:fixed [&.open]:inset-0 [&.open]:bg-black/60 [&.open]:z-[40000] transition-opacity"
role="presentation"
aria-hidden="true"
></div>
<div
<mobile-nav-bar
id="sidebar-menu"
class="peer [.in-game_&]:hidden z-[40001] fixed left-0 top-0 h-full flex flex-col justify-start overflow-visible bg-black/60 backdrop-blur-md transition-transform duration-500 ease-out transform -translate-x-full w-[80%] [&.open]:translate-x-0 lg:hidden"
role="dialog"
data-i18n-aria-label="main.menu"
aria-hidden="true"
>
<!-- Border Segments (Custom right border with gap for button) -->
<div
class="absolute right-0 top-0 w-px bg-transparent"
style="height: calc(50% - 64px)"
></div>
<div
class="absolute right-0 bottom-0 w-px bg-transparent"
style="height: calc(50% - 64px)"
></div>
<div
class="flex-1 w-full flex flex-col justify-start overflow-y-auto md:pt-[clamp(1rem,3vh,4rem)] md:pb-[clamp(0.5rem,2vh,2rem)] md:px-[clamp(1rem,1.5vw,2rem)] p-5 gap-[clamp(1rem,3vh,3rem)]"
>
<!-- Logo + Menu -->
<div
class="flex flex-col text-[#2563eb] mb-[clamp(1rem,2vh,2rem)] ml-[clamp(0.2rem,0.4vw,0.4vh)]"
>
<div class="flex flex-col items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 1364 259"
width="100%"
height="100%"
fill="currentColor"
class="w-[clamp(120px,15vw,192px)] h-[clamp(40px,6vh,64px)] drop-shadow-[0_0_10px_rgba(37,99,235,0.3)]"
>
<!-- (Logo paths preserved) -->
<path
d="M0,174V51h15.24v-17.14h16.81v-16.98h16.96V0h1266v17.23h17.13v16.81h16.98v16.96h14.88v123h-15.13v17.08h-17.08v17.08h-16.9v17.04H324.9v16.86h-16.9v16.95h-102v-17.12h-17.07v-17.05H48.73v-17.05h-16.89v-16.89H14.94v-16.89H0ZM1297.95,17.35H65.9v16.7h-17.08v17.08h-14.5v123.08h14.85v16.9h17.08v17.08h139.9v17.08h17.08v16.36h67.9v-16.72h17.08v-17.07h989.88v-17.07h17.08v-16.9h14.44V50.8h-14.75v-17.08h-16.9v-16.37Z"
/>
<path
d="M189.1,154.78v17.07h-16.9v16.75h-51.07v-16.42h-16.9v-17.07h-16.97v-84.88h16.63v-17.07h16.9v-16.84h51.07v16.5h17.07v17.07h16.7v84.89h-16.54ZM137.87,53.1v17.15h-16.6v84.86h16.97v16.61h16.89v-16.97h16.6v-84.86h-16.97v-16.79h-16.89Z"
/>
<path
d="M273.91,104.06v-16.73h50.92v16.45h16.85v68.05h-16.44v17.06h-50.96v16.88h16.4v16.96h-67.28v-16.61h16.33v-101.86h-16.38v-16.98h33.4v16.63c6.12,0,11.72,0,17.31,0,0,22.56,0,45.13,0,67.75h33.59v-67.61h-33.73Z"
/>
<path
d="M631.12,188.64v-16.36h16.53V53.2h-16.25v-16.86h118.33v33.29h-16.65v-16.36h-50.72v50.44h33.36v-16.35h16.99v50.25h-16.6v-16.33h-33.73v50.65h16.37v16.72h-67.63Z"
/>
<path
d="M596.78,103.8v84.94h-33.54v-84.39h-34.03v84.25h-33.85v-101.29h84.5v16.49h16.93Z"
/>
<path
d="M1107.12,188.71v-84.34h-34.03v84.37h-33.7v-101.41h84.42v16.41h16.86v84.96h-33.54Z"
/>
<path
d="M988.1,171.78v16.87h-67.88v-16.38h-16.87v-68.06h16.38v-16.87h68.06v16.38h16.87v68.06h-16.55ZM970.78,104.35h-33.39v67.38h33.39v-67.38Z"
/>
<path
d="M460.77,155.38v16.49h-16.58v16.83h-68.05v-16.5h-16.83v-68.05h16.49v-16.83h68.05v16.49h16.83v34.06h-67.31v33.82h33.47v-16.31h33.92ZM393.39,104.18v16.56h33.3v-16.56h-33.3Z"
/>
<path
d="M1209.13,172h-16.9v-67.9h-16.96v-16.9h16.68v-17.08h16.9v-16.82h16.9v33.58h50.98v16.91h-50.4v67.96h16.48v-16.43h50.95v16.54h-16.55v16.76h-68.08v-16.6Z"
/>
<path
d="M834.91,120.94v16.96h-16.65v33.88h16.41v16.96h-67.29v-16.63h16.34v-67.87h-16.4v-16.97h50.42v33.81h17.3l-.14-.14Z"
/>
<path
d="M835.05,121.08v-33.75h33.76v16.43h16.85v33.96h-33.43v-16.79c-6.13,0-11.73,0-17.32,0,0,0,.14.14.14.14Z"
/>
</svg>
<div
id="game-version"
class="l-header__highlightText text-center"
></div>
</div>
</div>
<div class="flex flex-col">
<button
class="nav-menu-item block w-full text-left font-bold uppercase tracking-[0.05em] text-white/70 transition-all duration-200 cursor-pointer hover:text-blue-600 hover:translate-x-2.5 hover:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] [&.active]:text-blue-600 [&.active]:translate-x-2.5 [&.active]:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] text-[clamp(18px,2.8vh,32px)] py-[clamp(0.2rem,0.8vh,0.75rem)]"
data-page="page-play"
data-i18n="main.play"
></button>
<button
class="nav-menu-item block w-full text-left font-bold uppercase tracking-[0.05em] text-white/70 transition-all duration-200 cursor-pointer hover:text-blue-600 hover:translate-x-2.5 hover:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] [&.active]:text-blue-600 [&.active]:translate-x-2.5 [&.active]:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] text-[clamp(18px,2.8vh,32px)] py-[clamp(0.2rem,0.8vh,0.75rem)]"
data-page="page-news"
data-i18n="main.news"
></button>
<button
class="nav-menu-item block w-full text-left font-bold uppercase tracking-[0.05em] text-white/70 transition-all duration-200 cursor-pointer hover:text-blue-600 hover:translate-x-2.5 hover:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] [&.active]:text-blue-600 [&.active]:translate-x-2.5 [&.active]:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] text-[clamp(18px,2.8vh,32px)] py-[clamp(0.2rem,0.8vh,0.75rem)]"
data-page="page-item-store"
data-i18n="main.store"
></button>
<button
class="nav-menu-item block w-full text-left font-bold uppercase tracking-[0.05em] text-white/70 transition-all duration-200 cursor-pointer hover:text-blue-600 hover:translate-x-2.5 hover:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] [&.active]:text-blue-600 [&.active]:translate-x-2.5 [&.active]:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] text-[clamp(18px,2.8vh,32px)] py-[clamp(0.2rem,0.8vh,0.75rem)]"
data-page="page-options"
data-i18n="main.options"
></button>
<!-- Keybinds menu item removed for mobile users -->
<button
class="nav-menu-item block w-full text-left font-bold uppercase tracking-[0.05em] text-white/70 transition-all duration-200 cursor-pointer hover:text-blue-600 hover:translate-x-2.5 hover:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] [&.active]:text-blue-600 [&.active]:translate-x-2.5 [&.active]:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] text-[clamp(18px,2.8vh,32px)] py-[clamp(0.2rem,0.8vh,0.75rem)]"
data-page="page-stats"
data-i18n="main.stats"
></button>
<button
class="nav-menu-item block w-full text-left font-bold uppercase tracking-[0.05em] text-white/70 transition-all duration-200 cursor-pointer hover:text-blue-600 hover:translate-x-2.5 hover:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] [&.active]:text-blue-600 [&.active]:translate-x-2.5 [&.active]:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] text-[clamp(18px,2.8vh,32px)] py-[clamp(0.2rem,0.8vh,0.75rem)]"
data-page="page-account"
data-i18n="main.account"
></button>
<button
class="nav-menu-item block w-full text-left font-bold uppercase tracking-[0.05em] text-white/70 transition-all duration-200 cursor-pointer hover:text-blue-600 hover:translate-x-2.5 hover:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] [&.active]:text-blue-600 [&.active]:translate-x-2.5 [&.active]:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] text-[clamp(18px,2.8vh,32px)] py-[clamp(0.2rem,0.8vh,0.75rem)]"
data-page="page-help"
data-i18n="main.help"
></button>
</div>
<div class="flex flex-col gap-4 w-full mt-auto">
<div
class="[.in-game_&]:hidden flex w-full items-center justify-end gap-4 pt-4 border-t border-white/10"
>
<div class="flex-shrink-0">
<lang-selector></lang-selector>
</div>
</div>
</div>
</div>
</div>
></mobile-nav-bar>
<!-- MAIN CONTENT AREA -->
<div
class="[.in-game_&]:hidden flex-1 relative overflow-hidden h-full transition-[margin] duration-500 ease-out will-change-[margin-left] flex flex-col"
>
<!-- Desktop Top Bar -->
<nav
class="hidden lg:flex w-full bg-slate-950/70 backdrop-blur-md border-b border-white/10 items-center justify-center gap-8 py-4 shrink-0 transition-opacity z-50 relative"
>
<div class="flex flex-col items-center justify-center">
<div class="h-8 text-[#2563eb]">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 1364 259"
fill="currentColor"
class="h-full w-auto drop-shadow-[0_0_10px_rgba(37,99,235,0.4)]"
>
<path
d="M0,174V51h15.24v-17.14h16.81v-16.98h16.96V0h1266v17.23h17.13v16.81h16.98v16.96h14.88v123h-15.13v17.08h-17.08v17.08h-16.9v17.04H324.9v16.86h-16.9v16.95h-102v-17.12h-17.07v-17.05H48.73v-17.05h-16.89v-16.89H14.94v-16.89H0ZM1297.95,17.35H65.9v16.7h-17.08v17.08h-14.5v123.08h14.85v16.9h17.08v17.08h139.9v17.08h17.08v16.36h67.9v-16.72h17.08v-17.07h989.88v-17.07h17.08v-16.9h14.44V50.8h-14.75v-17.08h-16.9v-16.37Z"
/>
<path
d="M189.1,154.78v17.07h-16.9v16.75h-51.07v-16.42h-16.9v-17.07h-16.97v-84.88h16.63v-17.07h16.9v-16.84h51.07v16.5h17.07v17.07h16.7v84.89h-16.54ZM137.87,53.1v17.15h-16.6v84.86h16.97v16.61h16.89v-16.97h16.6v-84.86h-16.97v-16.79h-16.89Z"
/>
<path
d="M273.91,104.06v-16.73h50.92v16.45h16.85v68.05h-16.44v17.06h-50.96v16.88h16.4v16.96h-67.28v-16.61h16.33v-101.86h-16.38v-16.98h33.4v16.63c6.12,0,11.72,0,17.31,0,0,22.56,0,45.13,0,67.75h33.59v-67.61h-33.73Z"
/>
<path
d="M631.12,188.64v-16.36h16.53V53.2h-16.25v-16.86h118.33v33.29h-16.65v-16.36h-50.72v50.44h33.36v-16.35h16.99v50.25h-16.6v-16.33h-33.73v50.65h16.37v16.72h-67.63Z"
/>
<path
d="M596.78,103.8v84.94h-33.54v-84.39h-34.03v84.25h-33.85v-101.29h84.5v16.49h16.93Z"
/>
<path
d="M1107.12,188.71v-84.34h-34.03v84.37h-33.7v-101.41h84.42v16.41h16.86v84.96h-33.54Z"
/>
<path
d="M988.1,171.78v16.87h-67.88v-16.38h-16.87v-68.06h16.38v-16.87h68.06v16.38h16.87v68.06h-16.55ZM970.78,104.35h-33.39v67.38h33.39v-67.38Z"
/>
<path
d="M460.77,155.38v16.49h-16.58v16.83h-68.05v-16.5h-16.83v-68.05h16.49v-16.83h68.05v16.49h16.83v34.06h-67.31v33.82h33.47v-16.31h33.92ZM393.39,104.18v16.56h33.3v-16.56h-33.3Z"
/>
<path
d="M1209.13,172h-16.9v-67.9h-16.96v-16.9h16.68v-17.08h16.9v-16.82h16.9v33.58h50.98v16.91h-50.4v67.96h16.48v-16.43h50.95v16.54h-16.55v16.76h-68.08v-16.6Z"
/>
<path
d="M834.91,120.94v16.96h-16.65v33.88h16.41v16.96h-67.29v-16.63h16.34v-67.87h-16.4v-16.97h50.42v33.81h17.3l-.14-.14Z"
/>
<path
d="M835.05,121.08v-33.75h33.76v16.43h16.85v33.96h-33.43v-16.79c-6.13,0-11.73,0-17.32,0,0,0,.14.14.14.14Z"
/>
</svg>
</div>
<div
class="game-version-display text-[#2563eb] text-xs font-bold tracking-widest uppercase cursor-default select-text leading-none mt-1"
></div>
</div>
<button
class="nav-menu-item text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
data-page="page-play"
data-i18n="main.play"
></button>
<button
class="nav-menu-item text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
data-page="page-news"
data-i18n="main.news"
></button>
<button
class="nav-menu-item text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500 relative"
data-page="page-item-store"
data-i18n="main.store"
></button>
<button
class="nav-menu-item text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
data-page="page-options"
data-i18n="main.options"
></button>
<button
class="nav-menu-item text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
data-page="page-keybinds"
data-i18n="main.keys"
></button>
<button
class="nav-menu-item text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
data-page="page-stats"
data-i18n="main.stats"
></button>
<button
class="nav-menu-item text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
data-page="page-account"
data-i18n="main.account"
></button>
<button
class="nav-menu-item text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
data-page="page-help"
data-i18n="main.help"
></button>
<lang-selector></lang-selector>
</nav>
<desktop-nav-bar></desktop-nav-bar>
<div
id="turnstile-container"
class="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-[99999]"
></div>
<gutter-ads></gutter-ads>
<!-- Main container with responsive padding -->
<main
class="relative [.in-game_&]:hidden flex flex-col flex-1 overflow-hidden w-full px-[clamp(1.5rem,3vw,3rem)] pt-[clamp(0.75rem,1.5vw,1.5rem)] pb-[clamp(0.75rem,1.5vw,1.5rem)]"
>
<div
class="w-full max-w-[20cm] mx-auto flex flex-col flex-1 gap-[clamp(1.5rem,3vw,3rem)] overflow-y-auto overflow-x-hidden [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden lg:[scrollbar-width:auto] lg:[-ms-overflow-style:auto] lg:[&::-webkit-scrollbar]:block"
>
<div
id="page-play"
class="flex flex-col gap-2 w-full max-w-6xl mx-auto px-0 sm:px-4 transition-all duration-300 my-auto min-h-0"
>
<token-login class="w-full hidden"></token-login>
<main-layout class="contents">
<play-page class="contents"></play-page>
<!-- Header / Identity Section -->
<div class="grid grid-cols-1 lg:grid-cols-12 gap-2 lg:gap-6 w-full">
<div
class="lg:col-span-9 flex flex-row flex-nowrap gap-x-2 h-[60px] items-center bg-slate-900/80 backdrop-blur-md p-3 rounded-xl border border-blue-500/20 relative z-20 text-sm sm:text-base shrink-0"
>
<!-- Flag -->
<div
class="h-[40px] sm:h-[50px] shrink-0 aspect-[4/3] flex items-center justify-center"
>
<!-- Hamburger (Mobile) -->
<button
id="hamburger-btn"
class="lg:hidden flex w-full h-full bg-slate-800/40 text-white/90 border border-blue-400/20 hover:bg-slate-700/40 p-0 rounded-md items-center justify-center cursor-pointer transition-all duration-200"
data-i18n-aria-label="main.menu"
aria-expanded="false"
aria-controls="sidebar-menu"
aria-haspopup="dialog"
data-i18n-title="main.menu"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-8 h-8"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
/>
</svg>
</button>
<news-modal
id="page-news"
inline
class="hidden w-full h-full page-content"
></news-modal>
<single-player-modal
id="page-single-player"
inline
class="hidden w-full h-full page-content"
></single-player-modal>
<host-lobby-modal
id="page-host-lobby"
inline
class="hidden w-full h-full page-content"
></host-lobby-modal>
<join-private-lobby-modal
id="page-join-private-lobby"
inline
class="hidden w-full h-full page-content"
></join-private-lobby-modal>
<territory-patterns-modal
id="page-item-store"
inline
class="hidden w-full h-full page-content"
></territory-patterns-modal>
<matchmaking-modal
id="page-matchmaking"
inline
class="hidden w-full h-full page-content"
></matchmaking-modal>
<user-setting
id="page-settings"
inline
class="hidden w-full h-full page-content"
></user-setting>
<stats-modal
id="page-stats"
inline
class="hidden w-full h-full page-content"
></stats-modal>
<account-modal
id="page-account"
inline
class="hidden w-full h-full page-content"
></account-modal>
<help-modal
id="page-help"
inline
class="hidden w-full h-full page-content"
></help-modal>
<language-modal
id="page-language"
inline
class="hidden w-full h-full page-content"
></language-modal>
<flag-input-modal
id="flag-input-modal"
inline
class="hidden w-full h-full page-content"
></flag-input-modal>
</main-layout>
<!-- Flag (Desktop) -->
<flag-input
id="flag-input-component"
class="hidden lg:flex w-full h-full items-center justify-center rounded-md overflow-hidden"
></flag-input>
</div>
<!-- Desktop Footer -->
<page-footer></page-footer>
<!-- Username -->
<div
class="flex-1 min-w-0 h-[40px] sm:h-[50px] flex items-center"
>
<username-input
class="relative w-full h-full block text-ellipsis overflow-hidden whitespace-nowrap"
></username-input>
</div>
<!-- Pattern button (Mobile - inside bar, Desktop - hidden here) -->
<button
id="territory-patterns-input-preview-button"
class="aspect-square h-[40px] sm:h-[50px] lg:hidden border border-white/20 bg-white/5 hover:bg-white/10 active:bg-white/20 rounded-lg cursor-pointer focus:outline-none transition-all duration-200 hover:scale-105 overflow-hidden shrink-0"
style="padding: 4px !important"
data-i18n-title="main.pick_pattern"
></button>
</div>
<!-- Pattern button (Desktop only - separate column) -->
<div class="hidden lg:flex lg:col-span-3">
<div
id="territory-patterns-preview-desktop-wrapper"
class="w-full h-[60px]"
></div>
</div>
</div>
<!-- Primary Game Actions Area -->
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6 w-full">
<!-- Left Column: Featured Lobbies / Quick Play -->
<div class="lg:col-span-9 flex flex-col gap-6 min-w-0">
<!-- Public Lobby Card -->
<public-lobby
class="block w-full transition-all duration-300"
></public-lobby>
<!-- Matchmaking Buttons (Full Width within column) -->
<!-- Not Logged In Button -->
<button
id="matchmaking-button-logged-out"
class="w-full h-20 bg-purple-600/50 text-white font-black uppercase tracking-widest rounded-xl transition-all duration-200 flex flex-col items-center justify-center border border-purple-500/30 overflow-hidden relative opacity-60 cursor-not-allowed"
disabled
aria-disabled="true"
>
<span
class="relative z-10 text-2xl"
data-i18n="matchmaking_button.login_required"
></span>
</button>
<!-- Logged In Button -->
<button
id="matchmaking-button"
class="hidden w-full h-20 bg-purple-600 hover:bg-purple-500 text-white font-black uppercase tracking-widest rounded-xl transition-all duration-200 flex flex-col items-center justify-center border border-purple-500/30 group overflow-hidden relative"
data-i18n-title="matchmaking_modal.title"
>
<span
class="relative z-10 text-2xl"
data-i18n="matchmaking_button.play_ranked"
></span>
<span
class="relative z-10 text-xs font-medium text-purple-100 opacity-90 group-hover:opacity-100 transition-opacity"
data-i18n="matchmaking_button.description"
></span>
</button>
</div>
<!-- Right Column: Custom Games & Modes -->
<div class="lg:col-span-3">
<div class="h-40 lg:h-[30.5rem] relative z-10">
<div
class="h-full flex flex-col bg-slate-900/40 backdrop-blur-sm rounded-2xl border border-blue-400/10 overflow-hidden"
>
<div
class="py-2 bg-blue-900/20 border-b border-blue-400/10 text-center text-sm font-bold text-gray-300 uppercase tracking-widest"
data-i18n="host_modal.label"
></div>
<div class="flex-1 p-2 flex flex-row lg:flex-col gap-2">
<o-button
id="single-player"
data-i18n-title="main.solo"
translationKey="main.solo"
fill
class="flex-1 transition-transform hover:-translate-y-0.5"
></o-button>
<o-button
id="host-lobby-button"
data-i18n-title="main.create"
translationKey="main.create"
fill
secondary
class="flex-1 opacity-90 hover:opacity-100"
></o-button>
<o-button
id="join-private-lobby-button"
data-i18n-title="main.join"
translationKey="main.join"
fill
secondary
class="flex-1 opacity-90 hover:opacity-100"
></o-button>
</div>
</div>
</div>
</div>
</div>
</div>
<news-modal
id="page-news"
inline
class="hidden w-full h-full page-content"
></news-modal>
<single-player-modal
id="page-single-player"
inline
class="hidden w-full h-full page-content"
></single-player-modal>
<host-lobby-modal
id="page-host-lobby"
inline
class="hidden w-full h-full page-content"
></host-lobby-modal>
<join-private-lobby-modal
id="page-join-private-lobby"
inline
class="hidden w-full h-full page-content"
></join-private-lobby-modal>
<territory-patterns-modal
id="page-item-store"
inline
class="hidden w-full h-full page-content"
></territory-patterns-modal>
<matchmaking-modal
id="page-matchmaking"
inline
class="hidden w-full h-full page-content"
></matchmaking-modal>
<user-setting
id="page-options"
inline
class="hidden w-full h-full page-content"
></user-setting>
<keybinds-modal
id="page-keybinds"
inline
class="hidden w-full h-full page-content"
></keybinds-modal>
<stats-modal
id="page-stats"
inline
class="hidden w-full h-full page-content"
></stats-modal>
<account-modal
id="page-account"
inline
class="hidden w-full h-full page-content"
></account-modal>
<help-modal
id="page-help"
inline
class="hidden w-full h-full page-content"
></help-modal>
<language-modal
id="page-language"
inline
class="hidden w-full h-full page-content"
></language-modal>
<flag-input-modal
id="flag-input-modal"
inline
class="hidden w-full h-full page-content"
></flag-input-modal>
<!-- Desktop Footer -->
</div>
</main>
<footer
class="[.in-game_&]:hidden bg-black/60 backdrop-blur-md flex flex-col items-center justify-center gap-2 pt-[2px] pb-2 text-white/50 w-full border-t border-white/10 shrink-0 mt-auto"
>
<div class="flex items-center justify-center gap-6 pt-2">
<a
href="https://github.com/openfrontio/OpenFrontIO"
target="_blank"
rel="noopener noreferrer"
class="opacity-60 hover:opacity-100 hover:scale-110 transition-all"
>
<img
src="/icons/github-mark-white.svg"
data-i18n-alt="news.github_link"
class="h-7 w-7 object-contain"
/>
</a>
<a
href="https://www.reddit.com/r/OpenFrontIO/"
target="_blank"
rel="noopener noreferrer"
class="opacity-60 hover:opacity-100 hover:scale-110 transition-all"
>
<svg
class="h-7 w-7 object-contain"
viewBox="0 0 24 24"
fill="white"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 0 1-2.498.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 0 1 .042.52c0 2.694-3.13 4.87-7.004 4.87-3.874 0-7.004-2.176-7.004-4.87 0-.183.015-.366.043-.534A1.748 1.748 0 0 1 4.028 12c0-.968.786-1.754 1.754-1.754.463 0 .898.196 1.207.49 1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 0 1 .14-.197.35.35 0 0 1 .238-.042l2.906.617a1.214 1.214 0 0 1 1.108-.701zM9.25 12C8.561 12 8 12.562 8 13.25c0 .687.561 1.248 1.25 1.248.687 0 1.248-.561 1.249-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 0 0-.231.094.33.33 0 0 0 0 .463c.842.842 2.484.913 2.961.913.477 0 2.105-.056 2.961-.913a.361.361 0 0 0 .029-.463.33.33 0 0 0-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.979-.196-2.512-.73a.326.326 0 0 0-.232-.095z"
/>
</svg>
</a>
<a
href="https://discord.gg/jRpxXvG42t"
target="_blank"
rel="noopener noreferrer"
class="opacity-60 hover:opacity-100 hover:scale-110 transition-all"
>
<svg
class="h-7 w-7 object-contain"
viewBox="0 0 24 24"
fill="white"
>
<path
d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-14.36a.074.074 0 0 0-.032-.027zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.418 2.157-2.418 1.21 0 2.176 1.085 2.157 2.418 0 1.334-.956 2.419-2.157 2.419zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.418 2.157-2.418 1.21 0 2.176 1.085 2.157 2.418 0 1.334-.946 2.419-2.157 2.419z"
/>
</svg>
</a>
<a
href="https://openfront.wiki/Main_Page"
target="_blank"
rel="noopener noreferrer"
class="opacity-60 hover:opacity-100 hover:scale-110 transition-all"
>
<img
src="/icons/wiki-logo.svg"
data-i18n-alt="main.wiki"
class="h-7 w-7 object-contain"
/>
</a>
</div>
<div class="text-xs mt-2 flex items-center justify-center gap-4">
<a
href="/terms-of-service.html"
data-i18n="main.terms_of_service"
target="_blank"
class="hover:text-white transition-colors"
></a>
<span data-i18n="main.copyright"></span>
<a
href="/privacy-policy.html"
data-i18n="main.privacy_policy"
target="_blank"
class="hover:text-white transition-colors"
></a>
</div>
</footer>
<!-- Global Modals -->
<territory-patterns-modal
id="territory-patterns-modal"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 MiB

After

Width:  |  Height:  |  Size: 844 KiB

+83 -98
View File
@@ -2,119 +2,104 @@
"name": "Britannia",
"nations": [
{
"coordinates": [960, 1258],
"name": "Dumnonia",
"flag": "1_Dumnonia"
"coordinates": [1539, 1915],
"name": "Pas-de-Calais",
"flag": "fr"
},
{
"coordinates": [918, 1088],
"name": "Dyfed",
"flag": "1_Dyfed"
"coordinates": [214, 1242],
"name": "Mayo",
"flag": "ie"
},
{
"coordinates": [1114, 1108],
"name": "Gwent",
"flag": "1_Gwent"
"coordinates": [189, 1605],
"name": "Kerry",
"flag": "ie"
},
{
"coordinates": [1048, 936],
"name": "Gwynedd",
"flag": "1_Gwynedd"
"coordinates": [257, 1458],
"name": "Clare",
"flag": "ie"
},
{
"coordinates": [1108, 986],
"coordinates": [527, 1295],
"name": "Meath",
"flag": "ie"
},
{
"coordinates": [703, 431],
"name": "Highland",
"flag": "gb-sct"
},
{
"coordinates": [611, 690],
"name": "Argyll and Bute",
"flag": "gb-sct"
},
{
"coordinates": [1118, 1202],
"name": "North Yorkshire",
"flag": "gb-eng"
},
{
"coordinates": [971, 517],
"name": "Aberdeenshire",
"flag": "gb-sct"
},
{
"coordinates": [951, 1088],
"name": "Cumbria",
"flag": "gb-eng"
},
{
"coordinates": [846, 1000],
"name": "Dumfries and Galloway",
"flag": "gb-sct"
},
{
"coordinates": [856, 672],
"name": "Perthshire and Kinross",
"flag": "gb-sct"
},
{
"coordinates": [1271, 1403],
"name": "Lincolnshire",
"flag": "gb-eng"
},
{
"coordinates": [867, 1883],
"name": "Devon",
"flag": "gb-eng"
},
{
"coordinates": [1051, 946],
"name": "Northumberland",
"flag": "gb-eng"
},
{
"coordinates": [1400, 1495],
"name": "Norfolk",
"flag": "gb-eng"
},
{
"coordinates": [902, 1569],
"name": "Powys",
"flag": "1_Powys"
"flag": "gb-wls"
},
{
"coordinates": [952, 536],
"name": "Strathclyde",
"flag": "1_Strathclyde"
"coordinates": [1407, 1584],
"name": "Suffolk",
"flag": "gb-eng"
},
{
"coordinates": [748, 556],
"name": "Dalriata",
"flag": "1_Dalriata"
"coordinates": [1148, 1820],
"name": "Hampshire",
"flag": "gb-eng"
},
{
"coordinates": [1228, 1176],
"name": "Wessex",
"flag": "1_Wessex"
},
{
"coordinates": [1442, 1226],
"name": "Sussex",
"flag": "1_Sussex"
},
{
"coordinates": [1600, 1188],
"name": "Kent",
"flag": "1_Kent"
},
{
"coordinates": [1621, 1085],
"name": "Essex",
"flag": "1_Essex"
},
{
"coordinates": [1707, 1018],
"name": "East Anglia",
"flag": "1_East Anglia"
},
{
"coordinates": [1370, 1002],
"name": "Mercia",
"flag": "1_Mercia"
},
{
"coordinates": [1298, 724],
"name": "Northumbria",
"flag": "1_Northumbria"
},
{
"coordinates": [962, 366],
"name": "Fortriu",
"flag": "1_Fortriu"
},
{
"coordinates": [272, 1041],
"name": "Munster",
"flag": "1_Munster"
},
{
"coordinates": [264, 830],
"name": "Connacht",
"flag": "1_Connacht"
},
{
"coordinates": [608, 975],
"name": "Laigin",
"flag": "1_Laigin"
},
{
"coordinates": [564, 845],
"name": "Southern Uí Néill",
"flag": "1_Southern Ui Neill"
},
{
"coordinates": [639, 680],
"name": "Ulaid",
"flag": "1_Ulaid"
},
{
"coordinates": [509, 759],
"name": "Airgialla",
"flag": "1_Airgialla"
},
{
"coordinates": [416, 678],
"name": "Northern Uí Néill",
"flag": "1_Northern Ui Neill"
},
{
"coordinates": [1869, 1308],
"name": "Franks",
"flag": "1_Franks"
"coordinates": [404, 1146],
"name": "Fermanagh",
"flag": "gb-nir"
}
]
}
+347 -81
View File
@@ -6,11 +6,22 @@
"lang_code": "de"
},
"common": {
"close": "Schließen"
"close": "Schließen",
"available": "Verfügbar",
"preset_max": "Maximal",
"summary_send": "Senden",
"summary_keep": "Behalten",
"cancel": "Abbrechen",
"send": "Senden",
"cap_label": "Kapazität",
"cap_tooltip": "Verbleibende Kapazität des Empfängers",
"target_dead": "Ziel eliminiert",
"target_dead_note": "Du kannst keine Ressourcen an einen eliminierten Spieler senden.",
"none": "Nichts"
},
"main": {
"title": "OpenFront (ALPHA)",
"join_discord": "Tritt dem Discord bei!",
"join_discord": "Discord",
"login_discord": "Anmelden mit Discord",
"checking_login": "Überprüfe Login...",
"logged_in": "Angemeldet!",
@@ -19,12 +30,13 @@
"join_lobby": "Lobby beitreten",
"single_player": "Einzelspieler",
"instructions": "Anleitung",
"how_to_play": "Wie man Spielt",
"advertise": "Werbung",
"wiki": "Wiki"
"wiki": "Wiki",
"privacy_policy": "Datenschutzerklärung",
"terms_of_service": "Nutzungsbedingungen",
"reddit": "Reddit"
},
"news": {
"full_changelog": "Gesamtes Änderungsprotokoll ansehen",
"see_all_releases": "Alle Versionen ansehen",
"github_link": "auf GitHub",
"title": "Versionshinweise"
},
@@ -41,6 +53,7 @@
"action_move_camera": "Kamera bewegen",
"action_ratio_change": "Angriffsrate verringern/erhöhen",
"action_reset_gfx": "Grafik zurücksetzen",
"action_auto_upgrade": "Nächstes Gebäude automatisch verbessern",
"ui_section": "Spieloberfläche",
"ui_leaderboard": "Bestenliste",
"ui_your_team": "Dein Team:",
@@ -49,7 +62,6 @@
"ui_control_desc": "Der Kontrollbereich beinhaltet folgende Elemente:",
"ui_pop": "Bevölkerung - Die Anzahl der aktuellen Einheiten, die maximale Bevölkerungszahl und die Geschwindigkeit mit der man neue Einheiten bekommst.",
"ui_gold": "Gold - Die aktuelle Menge an Gold, und die Geschwindigkeit mit der man Gold bekommt.",
"ui_troops_workers": "Truppen und Arbeiter - Die Menge der zugewiesenen Truppen und Arbeiter. Truppen werden zum Angriff oder zur Verteidigung gegen Angriffe eingesetzt. Arbeiter erzeugen Gold. Die Anzahl der Truppen und Arbeiter kann mit dem Schieberegler eingestellt werden.",
"ui_attack_ratio": "Angriffsverhältnis - Die Anzahl der Truppen, die beim Angriff verwendet werden, kann mit dem Schieberegler angepasst werden. Je mehr Truppen beim Angriff verwendet werden desto geringer sind die eigenen Verluste. Während weniger Truppen zu größeren Verlusten führen.\nDieser Effekt geht nicht über das Verhältnis von 2:1 hinaus.",
"ui_events": "Ereignis-Panel",
"ui_events_desc": "Das Ereignis-Panel zeigt die neuesten Ereignisse, Anfragen und Schnell-Nachrichten. Einige Beispiele sind:",
@@ -60,6 +72,7 @@
"ui_options_desc": "Die folgenden Schaltflächen sind in den Optionen verfügbar:",
"ui_playeroverlay": "Spieler-Info-Overlay",
"ui_playeroverlay_desc": "Wenn du über ein Land fährst, wird die Info-Übersicht des Spielers unter den Optionen angezeigt. Es zeigt den Typ des Spielers an: Menschlicher Spieler, Nation (schlauer Bot), oder Bot. Die Haltung einer Nation zu dir, von feindselig bis Freund. Und verteidigende Truppen, Gold, sowie die Anzahl der Kriegsschiffe und verschiedene Gebäude, die der Spieler hat.",
"ui_wilderness": "Wildnis",
"option_pause": "Spiel pausieren/fortsetzen - Nur im Einzelspieler möglich.",
"option_timer": "Spieluhr - Vergangene Zeit seit Spielbeginn.",
"option_exit": "Spiel verlassen.",
@@ -67,6 +80,7 @@
"radial_title": "Radialmenü",
"radial_desc": "Rechtsklick (oder Touch auf dem Smartphone) öffnet das Menü. Rechtsklick außerhalb des Menüs schließt dieses. Aus dem Menü heraus kann man:",
"radial_build": "Baumenü öffnen.",
"radial_attack": "Angriffsmenü öffnen.",
"radial_info": "Den Infobereich öffnen.",
"radial_boat": "Ein Transportschiff zum Angriff zum gewählten Gebiet schicken. Nur verfügbar mit Zugang zu Wasser.",
"radial_close": "Das Menü schließen.",
@@ -89,10 +103,12 @@
"build_desc": "Beschreibung",
"build_city": "Stadt",
"build_city_desc": "Erhöht die maximale Bevölkerungsanzahl. Nützlich, wenn das eigene Territorium nicht erweitert werden kann oder das Bevölkerungslimit erreicht wird.",
"build_factory": "Fabrik",
"build_factory_desc": "Baut automatisch Eisenbahnverbindungen zu nahegelegenen Städten, Häfen und anderen Fabriken und kann sich auch mit befreundeten Nachbarn verbinden. Züge erscheinen regelmäßig und bringen dir für jedes Gebäude, das sie auf ihrer Route besuchen, einen festen Goldbetrag, mit zusätzlichem Gold für den Besuch der Gebäude deiner Nachbarn.",
"build_defense": "Verteidigungsposten",
"build_defense_desc": "Erhöht die Verteidigung von anliegenden Grenzen, markiert mit einem karierten Muster. Angriffe von Feinden sind langsamer und sorgen für mehr Verluste beim Feind.",
"build_port": "Hafen",
"build_port_desc": "Kann nur in der Nähe von Wasser gebaut werden. Erlaubt den Bau von Kriegsschiffen. Schickt automatisch Handelsschiffe zwischen deinen Häfen und denen anderer Länder (außer wenn der Handel gestoppt wird) und gibt Gold an beide Seiten. Der Handel stoppt automatisch, wenn du einen Spieler angreifst oder angegriffen wirst. Der Handel wird nach 5 Minuten wieder aufgenommen, oder wenn du dich mit dem Spieler verbündest. Sie können den Handel manuell mit \"Handel stoppen\" oder \"Handel starten\" an-/ausschalten.",
"build_port_desc": "Kann nur in der Nähe von Wasser gebaut werden. Erlaubt den Bau von Kriegsschiffen. Sendet automatisch Handelsschiffe zwischen den Häfen deines Landes und anderer Länder (außer wenn der Handel gestoppt ist), wodurch beide Seiten Gold erhalten. Der Handel mit einem Spieler wird automatisch gestoppt, wenn du einen Spieler angreifst oder angegriffen wirst. Er wird nach 5 Minuten oder bei Allianz wieder aufgenommen. Du kannst den Handel manuell mit Handel stoppen oder Handel starten ein- und ausschalten.",
"build_warship": "Kriegsschiff",
"build_warship_desc": "Patrouilliert in einem Gebiet und kapert Handelsschiffe, zerstört feindliche Kriegsschiffe und Boote (Transportschiffe). Erscheint beim nächstgelegenen Hafen und patrouilliert im Gebiet wo es gebaut wurde. Mit einem Klick auf das Kriegsschiff kann es gesteuert und mit einem weiteren Klick in ein anderes Gebiet geschickt werden.",
"build_silo": "Raketensilo",
@@ -112,22 +128,51 @@
"icon_ally": "Handschlag - Verbündeter. Dieser Spieler ist ein Verbündeter.",
"icon_embargo": "Dollar Stoppschild - Embargo. Dieser Spieler hat den Handel mit Dir automatisch oder manuell eingestellt.",
"icon_request": "Umschlag - Allianzanfrage. Dieser Spieler hat dir eine Allianzanfrage geschickt.",
"info_enemy_panel": "Gegner-Infobereich"
"info_enemy_panel": "Gegner-Infobereich",
"exit_confirmation": "Bist du sicher, dass du das Spiel verlassen möchtest?"
},
"single_modal": {
"title": "Einzelspieler",
"random_spawn": "Zufälliger Spawn",
"allow_alliances": "Bündnisse erlauben",
"options_title": "Optionen",
"bots": "Bots: ",
"bots_disabled": "Deaktiviert",
"nations": "Nationen: ",
"disable_nations": "Nationen deaktivieren",
"instant_build": "Sofortiges Bauen",
"infinite_gold": "Unendlich Gold",
"infinite_troops": "Unendlich Truppen",
"compact_map": "Kompakte Karte",
"max_timer": "Spiellänge (Minuten)",
"disable_nukes": "Atomwaffen deaktivieren",
"enables_title": "Aktiviere Einstellungen",
"start": "Spiel starten"
},
"token_login_modal": {
"title": "Anmeldung läuft...",
"logging_in": "Anmeldung läuft...",
"success": "Erfolgreich angemeldet als {email}!"
},
"account_modal": {
"title": "Konto",
"logged_in_as": "Angemeldet als {email}",
"fetching_account": "Kontoinformationen werden geladen...",
"logged_in_with_discord": "Angemeldet mit Discord",
"recovery_email_sent": "Wiederherstellungs-E-Mail an {email} gesendet"
},
"stats_modal": {
"title": "Statistiken",
"clan_stats": "Clan-Statistiken",
"loading": "Lädt...",
"error": "Fehler beim Laden der Clan-Statistiken",
"no_stats": "Keine Clan-Statistiken verfügbar",
"clan": "Clan",
"games": "Spiele",
"win_score": "Siegpunkte",
"loss_score": "Niederlagenpunkte",
"win_loss_ratio": "Sieg/Niederlage"
},
"map": {
"map": "Karte",
"world": "Welt",
@@ -156,7 +201,16 @@
"baikal": "Baikalsee",
"halkidiki": "Halkidiki",
"straitofgibraltar": "Straße von Gibraltar",
"italia": "Italien"
"italia": "Italien",
"japan": "Japan",
"yenisei": "Yenisei",
"pluto": "Pluto",
"montreal": "Montreal",
"achiran": "Achiran",
"baikalnukewars": "Baikal (Nuklearkrieg)",
"fourislands": "Vier Inseln",
"gulfofstlawrence": "Sankt-Lorenz-Golf",
"lisbon": "Lissabon"
},
"map_categories": {
"continental": "Kontinental",
@@ -174,16 +228,25 @@
"join_lobby": "Lobby beitreten",
"checking": "Überprüfe Lobby...",
"not_found": "Lobby nicht gefunden. Bitte Lobby ID überprüfen und erneut versuchen.",
"error": "Ein Fehler ist aufgetreten. Bitte erneut versuchen.",
"joined_waiting": "Erfolgreich beigetreten! Warte auf den Start des Spiels..."
"error": "Es ist ein Fehler aufgetreten. Bitte versuche es nochmal oder wende dich an den Support.",
"joined_waiting": "Erfolgreich beigetreten! Warte auf den Start des Spiels...",
"version_mismatch": "Dieses Spiel nutzt eine andere Version. Du kannst nicht beitreten."
},
"public_lobby": {
"join": "Nächstem Spiel beitreten",
"waiting": "wartende Spieler",
"teams_Duos": "Duos (Teams von 2)",
"teams_Trios": "Trios (Teams von 3)",
"teams_Quads": "Quads (Teams von 4)",
"teams": "{num} Teams"
"teams_Duos": "von 2 (Duos)",
"teams_Trios": "von 3 (Trios)",
"teams_Quads": "von 4 (Quads)",
"teams_hvn": "Menschen vs. Nationen",
"teams": "{num} Teams",
"players_per_team": "von {num}"
},
"matchmaking_modal": {
"title": "Spielersuche",
"connecting": "Verbinde mit Server...",
"searching": "Spiel wird gesucht...",
"waiting_for_game": "Das Spiel startet in Kürze..."
},
"username": {
"enter_username": "Benutzernamen eingeben",
@@ -199,15 +262,28 @@
"options_title": "Optionen",
"bots": "Bots:",
"bots_disabled": "Deaktiviert",
"nations": "Nationen: ",
"disable_nations": "Nationen deaktivieren",
"max_timer": "Spiellänge (Minuten)",
"instant_build": "Sofortiges Bauen",
"infinite_gold": "Unendlich Gold",
"donate_gold": "Gold spenden",
"infinite_troops": "Unendlich Truppen",
"donate_troops": "Truppen spenden",
"compact_map": "Kompakte Karte",
"enables_title": "Aktiviere Einstellungen",
"player": "Spieler",
"players": "Spieler",
"nation_players": "Nationen",
"nation_player": "Nation",
"waiting": "Warte auf Spieler...",
"start": "Spiel starten"
"random_spawn": "Zufälliger Spawn",
"start": "Spiel starten",
"host_badge": "Host",
"assigned_teams": "Zugewiesene Teams",
"empty_teams": "Leere Teams",
"empty_team": "Leer",
"remove_player": "{username} entfernen"
},
"team_colors": {
"red": "Rot",
@@ -221,13 +297,14 @@
},
"game_starting_modal": {
"title": "Das Spiel startet...",
"desc": "Der Start der Lobby wird vorbereitet. Bitte warten."
"credits": "Credits",
"code_license": "Code lizenziert unter AGPL-3.0 (ohne Gewährleistung)"
},
"difficulty": {
"difficulty": "Schwierigkeitsgrad",
"Relaxed": "Entspannt",
"Balanced": "Ausgeglichen",
"Intense": "Anspruchsvoll",
"Easy": "Entspannt",
"Medium": "Ausgeglichen",
"Hard": "Anspruchsvoll",
"Impossible": "Unmöglich"
},
"game_mode": {
@@ -258,19 +335,25 @@
"emojis_label": "Emojis",
"emojis_desc": "Emojis im Spiel ein-/ausblenden",
"alert_frame_label": "Warnrahmen",
"alert_frame_desc": "Den Warnrahmen umschalten. Wenn aktiviert, wird der Rahmen angezeigt, wenn du verraten wirst.",
"alert_frame_desc": "Aktiviere oder deaktiviere den Alarmrahmen. Wenn aktiviert, wird der Rahmen angezeigt, wenn du verraten wirst oder einen Landangriff erleidest.",
"special_effects_label": "Spezialeffekte",
"special_effects_desc": "Spezialeffekte (de-)aktivieren. Deaktivieren um die Leistung zu verbessern",
"structure_sprites_label": "Struktur-Grafiken",
"structure_sprites_desc": "Struktur-Grafiken ein-/ausschalten",
"anonymous_names_label": "Verborgene Namen",
"anonymous_names_desc": "Echte Spielernamen mit zufälligen Namen auf deinem Bildschirm ausblenden.",
"lobby_id_visibility_label": "Versteckte Lobby-IDs",
"lobby_id_visibility_desc": "Lobby-ID bei Erstellung privater Lobbys verbergen",
"left_click_label": "Linksklick zum Öffnen des Menüs",
"left_click_desc": "Wenn AN, öffnet Linksklick das Menü und die Schwertattacken-Schaltfläche. Wenn AUS, greift Linksklick direkt an.",
"left_click_menu": "Linksklick-Menü",
"attack_ratio_label": "⚔️ Angriffsverhältnis",
"attack_ratio_desc": "Prozentsatz deiner Truppen die in den Angriff geschickt werden (1100 %)",
"troop_ratio_label": "🪖🛠️ Truppen-/Arbeiter-Verhältnis",
"troop_ratio_desc": "Stelle das Verhältnis zwischen Truppen (für den Kampf) und Arbeitern (für die Goldproduktion) ein (1100%)",
"territory_patterns_label": "🏳️ Gebietsmuster",
"territory_patterns_desc": "Wähle, ob Gebietsmuster im Spiel angezeigt werden sollen",
"territory_patterns_desc": "Leg fest, ob Gebietsmuster im Spiel angezeigt werden",
"performance_overlay_label": "Leistungsanzeige",
"performance_overlay_desc": "Leistungsanzeige ein-/ausschalten. Wenn aktiviert, wird das Overlay angezeigt. Drücke während des Spiels Shift+D, um es umzuschalten.",
"easter_writing_speed_label": "Schreibgeschwindigkeits-Multiplikator",
"easter_writing_speed_desc": "Anpassen, wie schnell Du vorgibst zu programmieren (x1x100)",
"easter_bug_count_label": "Anzahl der Bugs",
@@ -278,6 +361,27 @@
"view_options": "Anzeigeeinstellungen",
"toggle_view": "Ansicht umschalten",
"toggle_view_desc": "Alternative Ansicht anzeigen (Gebiete/Länder)",
"build_controls": "Bau-Steuerung",
"build_city": "Stadt bauen",
"build_city_desc": "Baue eine Stadt unter deinem Cursor.",
"build_factory": "Fabrik bauen",
"build_factory_desc": "Baue eine Fabrik unter deinem Cursor.",
"build_defense_post": "Verteidigungsposten bauen",
"build_defense_post_desc": "Baue einen Verteidigungsposten unter deinem Cursor.",
"build_port": "Hafen bauen",
"build_port_desc": "Baue einen Hafen unter deinem Cursor.",
"build_warship": "Kriegsschiff bauen",
"build_warship_desc": "Baue ein Kriegsschiff unter deinem Cursor.",
"build_missile_silo": "Raketensilo bauen",
"build_missile_silo_desc": "Baue ein Raketensilo unter deinem Cursor.",
"build_sam_launcher": "Flugabwehrsystem bauen",
"build_sam_launcher_desc": "Baue ein Flugabwehrsystem unter deinem Cursor.",
"build_atom_bomb": "Atombombe bauen",
"build_atom_bomb_desc": "Baue eine Atombombe unter deinem Cursor.",
"build_hydrogen_bomb": "Wasserstoffbombe bauen",
"build_hydrogen_bomb_desc": "Baue eine Wasserstoffbombe unter deinem Cursor.",
"build_mirv": "MIRV bauen",
"build_mirv_desc": "Baue eine MIRV unter deinem Cursor.",
"attack_ratio_controls": "Angriffsverhältnis Steuerung",
"attack_ratio_up": "Angriffsverhältnis erhöhen",
"attack_ratio_up_desc": "Angriffsverhältnis erhöhen um 10%",
@@ -305,7 +409,14 @@
"move_right": "Kamera nach rechts bewegen",
"move_right_desc": "Bewegt die Kamera nach rechts",
"reset": "Zurücksetzen",
"unbind": "Aufheben"
"unbind": "Aufheben",
"on": "An",
"off": "Aus",
"toggle_terrain": "Gelände anzeigen/verbergen",
"exit_game_label": "Spiel verlassen",
"exit_game_info": "Zurück zum Hauptmenü",
"background_music_volume": "Musiklautstärke",
"sound_effects_volume": "Soundeffektlautstärke"
},
"chat": {
"title": "Chat",
@@ -327,26 +438,31 @@
},
"help": {
"troops": "Bitte gib mir Truppen!",
"troops_frontlines": "Schicke Truppen an die Front!",
"gold": "Bitte gib mir Gold!",
"no_attack": "Bitte greife mich nicht an!",
"sorry_attack": "Entschuldigung, ich wollte nicht angreifen.",
"alliance": "Allianz?",
"help_defend": "Hilf mir, gegen [P1] zu verteidigen!",
"team_up": "Lass uns gemeinsam gegen [P1] kämpfen!"
"trade_partners": "Lass uns Handelspartner werden!"
},
"attack": {
"attack": "Greife [P1] an!",
"mirv": "Starte einen MIRV auf [P1]!",
"focus": "Fokussiere Feuer auf [P1]!",
"finish": "Lass uns mit [P1] fertig werden!"
"finish": "Lass uns mit [P1] fertig werden!",
"build_warships": "Baue Kriegsschiffe!"
},
"defend": {
"defend": "Verteidige [P1]!",
"defend_from": "Verteidige gegen [P1]!",
"dont_attack": "Greife [P1] nicht an!",
"ally": "[P1] ist mein Verbündeter!"
"ally": "[P1] ist mein Verbündeter!",
"build_posts": "Baue Verteidigungsposten!"
},
"greet": {
"hello": "Hallo!",
"good_job": "Gut gemacht!",
"good_luck": "Viel Glück!",
"have_fun": "Viel Spaß!",
"gg": "GG!",
@@ -357,13 +473,19 @@
"thanks": "Danke!",
"oops": "Hoppla, falscher Knopf!",
"trust_me": "Du kannst mir vertrauen. Versprochen!",
"trust_broken": "Ich hatte dir vertraut..."
"trust_broken": "Ich hatte dir vertraut...",
"ruining_games": "Du versaust gerade beide unsere Spiele.",
"dont_do_that": "Mach das nicht!",
"same_team": "Ich bin auf deiner Seite!"
},
"misc": {
"go": "Los geht's!",
"strategy": "Schöne Strategie!",
"fun": "Dieses Spiel macht Spaß!",
"pr": "Wann wird mein PR endlich übernommen...?"
"team_up": "Lass uns gemeinsam gegen [P1] kämpfen!",
"pr": "Wann wird mein PR endlich übernommen...?",
"build_closer": "Baue dichter, damit Züge erscheinen!",
"coastline": "Bitte lass mich eine Küste bekommen."
},
"warnings": {
"strong": "[P1] ist stark.",
@@ -374,10 +496,14 @@
"has_allies": "[P1] hat viele Verbündete.",
"no_allies": "[P1] hat keine Verbündeten.",
"betrayed": "[P1] hat seinen Verbündeten verraten!",
"betrayed_me": "[P1] hat mich verraten!",
"getting_big": "[P1] wächst zu schnell!",
"danger_base": "[P1] ist ungeschützt!",
"saving_for_mirv": "[P1] spart, um eine MIRV-Rakete zu starten.",
"mirv_ready": "[P1] hat genug Gold, um eine MIRV-Rakete zu starten!"
"mirv_ready": "[P1] hat genug Gold, um eine MIRV-Rakete zu starten!",
"snowballing": "[P1] wächst zu schnell!",
"cheating": "[P1] betrügt!",
"stop_trading": "Stoppe den Handel mit [P1]!"
}
},
"build_menu": {
@@ -389,12 +515,15 @@
"sam_launcher": "Verteidigt gegen eingehende Atomraketen",
"warship": "Erobert Handelsschiffe, zerstört Schiffe und Boote",
"port": "Sendet Handelsschiffe, um Gold zu generieren",
"defense_post": "Erhöht Verteidigung der Grenzen in der Nähe",
"city": "Erhöht maximale Bevölkerung"
"defense_post": "Erhöht die Verteidigung der umliegenden Grenzen",
"city": "Erhöht die Bevölkerungsgrenze",
"factory": "Baut Eisenbahnen und schickt Züge auf die Strecke"
},
"not_enough_money": "Nicht genug Geld"
},
"win_modal": {
"support_openfront": "Unterstütze OpenFront!",
"territory_pattern": "Kaufe ein Gebietsmuster, um werbefrei zu spielen!",
"died": "Du bist gestorben",
"your_team": "Dein Team hat gewonnen!",
"other_team": "Team {team} hat gewonnen!",
@@ -402,7 +531,15 @@
"other_won": "{player} hat gewonnen!",
"exit": "Spiel verlassen",
"keep": "Weiterspielen",
"wishlist": "Zur Steam-Wunschliste hinzufügen!"
"spectate": "Zuschauen",
"wishlist": "Zur Steam-Wunschliste hinzufügen!",
"ofm_winter": "OpenFront Masters Winterturnier!",
"ofm_winter_description": "Nimm am Wettbewerbsturnier teil und tritt gegen die besten Spieler an",
"join_tournament": "Turnier beitreten",
"join_discord": "Tritt unserer Discord-Community bei!",
"discord_description": "Vernetze dich mit anderen Spielern, erhalte Updates und tausche Strategien aus",
"join_server": "Server beitreten",
"youtube_tutorial": "Brauchst du Hilfe?"
},
"leaderboard": {
"title": "Rangliste",
@@ -413,8 +550,12 @@
"owned": "Besitz",
"gold": "Gold",
"troops": "Truppen",
"show_top_5": "Top 5 anzeigen",
"show_all": "Alle anzeigen"
"launchers": "Raketensilos",
"sams": "Flugabwehrsysteme",
"warships": "Kriegsschiffe",
"cities": "Städte",
"show_control": "Steuerung anzeigen",
"show_units": "Einheiten anzeigen"
},
"player_info_overlay": {
"type": "Typ",
@@ -422,11 +563,13 @@
"nation": "Nation",
"player": "Spieler",
"team": "Team",
"d_troops": "Verteidigende Truppen",
"alliance_timeout": "Bündnis endet in",
"troops": "Truppen",
"a_troops": "Angreifende Truppen",
"gold": "Gold",
"ports": "Häfen",
"cities": "Städte",
"factories": "Fabriken",
"missile_launchers": "Raketenwerfer",
"sams": "SAMs",
"warships": "Schlachtschiffe",
@@ -436,12 +579,29 @@
},
"events_display": {
"retreating": "Zieht sich zurück",
"retaliate": "Vergelten",
"boat": "Boot",
"alliance_request_status": "{name} hat deine Allianz-Anfrage {status}",
"alliance_accepted": "akzeptiert",
"alliance_rejected": "abgelehnt",
"duration_second": "1 Sekunde",
"betrayal_description": "Du hast deine Allianz mit {name} gebrochen und bist zum VERRÄTER geworden ({malusPercent}% Verteidigungs-Malus für {durationText})",
"duration_seconds_plural": "{seconds} Sekunden",
"betrayed_you": "{name} hat die Allianz mit dir gebrochen",
"about_to_expire": "Deine Allianz mit {name} läuft bald ab!",
"alliance_expired": "Deine Allianz mit {name} ist abgelaufen",
"attack_request": "{name} bittet dich, {target} anzugreifen",
"sent_emoji": "Gesendet an {name}: {emoji}",
"renew_alliance": "Verlängerung beantragen",
"request_alliance": "{name} bittet um eine Allianz!",
"focus": "Fokus",
"accept_alliance": "Annehmen",
"reject_alliance": "Ablehnen",
"alliance_renewed": "Deine Allianz mit {name} wurde erneuert",
"ignore": "Ignorieren"
"wants_to_renew_alliance": "{name} möchte eure Allianz erneuern",
"ignore": "Ignorieren",
"unit_voluntarily_deleted": "Einheit freiwillig gelöscht",
"betrayal_debuff_ends": "Noch {time} Sekunden, bis der Verrats-Malus endet"
},
"unit_info_modal": {
"structure_info": "Gebäudeinformation",
@@ -452,6 +612,11 @@
"upgrade": "Verbessern",
"level": "Level"
},
"player_type": {
"player": "Spieler",
"nation": "Nation",
"bot": "Bot"
},
"relation": {
"hostile": "Feindlich",
"distrustful": "Misstrauisch",
@@ -460,30 +625,54 @@
"default": "Standard"
},
"control_panel": {
"pop": "Bevölkerung",
"gold": "Gold",
"troops": "Truppen",
"workers": "Arbeiter",
"attack_ratio": "Angriffsverhältnis"
},
"player_panel": {
"gold": "Gold",
"troops": "Truppen",
"betrayals": "Anzahl der Verrate",
"betrayals": "Verräter",
"traitor": "Verräter",
"trading": "Handel",
"active": "Aktiv",
"stopped": "Gestoppt",
"alliance_time_remaining": "Allianz endet in",
"embargo": "Stoppte den Handel mit dir",
"nuke": "Auf dich abgefeuerte Atomwaffen",
"start_trade": "Handel starten",
"stop_trade": "Handel stoppen",
"yes": "Ja",
"no": "Nein",
"none": "Nichts",
"alliances": "Allianzen"
"stop_trade_all": "Handel mit allen stoppen",
"start_trade_all": "Mit allen handeln",
"alliances": "Allianzen",
"flag": "Flagge",
"chat": "Chat",
"target": "Ziel",
"break_alliance": "Allianz auflösen",
"alliance": "Allianz",
"send_alliance": "Allianz senden",
"send_troops": "Truppen senden",
"send_gold": "Gold senden",
"emotes": "Emojis"
},
"send_troops_modal": {
"title_with_name": "Truppen an {name} senden",
"available_tooltip": "Verfügbare Truppen",
"min_keep": "Mindestbestand",
"slider_tooltip": "{{percent}} % • {{amount}}",
"aria_slider": "Truppenregler",
"capacity_note": "Der Empfänger kann derzeit nur {{amount}} annehmen."
},
"send_gold_modal": {
"title_with_name": "Gold an {name} senden",
"available_tooltip": "Verfügbares Gold",
"aria_slider": "Mengenregler",
"slider_tooltip": "{{percent}} % • {{amount}}"
},
"replay_panel": {
"replay_speed": "Wiedergabegeschwindigkeit",
"game_speed": "Spielgeschwindigkeit"
"game_speed": "Spielgeschwindigkeit",
"fastest_game_speed": "Max"
},
"error_modal": {
"crashed": "Spiel ist abgestürzt!",
@@ -492,51 +681,128 @@
"copy_clipboard": "In die Zwischenablage kopieren",
"copied": "Kopiert!",
"failed_copy": "Kopieren fehlgeschlagen",
"spawn_failed": {
"title": "Spawn fehlgeschlagen",
"description": "Automatische Spawn-Auswahl fehlgeschlagen. Du kannst dieses Spiel nicht spielen."
},
"desync_notice": "Du wurdest von anderen Spielern desynchronisiert. Was du siehst, könnte sich von anderen Spielern unterscheiden."
},
"performance_overlay": {
"reset": "Zurücksetzen",
"copy_json_title": "Aktuelle Leistungswerte als JSON kopieren",
"copy_clipboard": "JSON kopieren",
"copied": "Kopiert!",
"failed_copy": "Kopieren fehlgeschlagen",
"fps": "FPS:",
"avg_60s": "Durchschn. (60s):",
"frame": "Frame:",
"tick_exec": "Tick-Ausführung:",
"tick_delay": "Tick-Verzögerung:",
"layers_header": "Ebenen (Durchschn. / Max., nach Gesamtzeit sortiert):"
},
"heads_up_message": {
"choose_spawn": "Wähle eine Startposition"
"choose_spawn": "Wähle eine Startposition",
"random_spawn": "Zufälliger Spawn ist aktiviert. Startposition wird ausgewählt..."
},
"territory_patterns": {
"title": "Gebietsmuster auswählen",
"title": "Gebietsmuster",
"colors": "Farben",
"purchase": "Kaufen",
"show_only_owned": "Meine Designs",
"blocked": {
"login": "Du musst angemeldet sein, um auf dieses Muster zugreifen zu können.",
"login": "Du musst angemeldet sein, um auf dieses Muster zuzugreifen.",
"purchase": "Kaufe dieses Muster, um es freizuschalten."
},
"pattern": {
"default": "Standard",
"custom": "Benutzerdefiniert",
"stripes_v": "Vertikal",
"stripes_h": "Horizontal",
"horizontal_stripes": "Horizontal (Alt)",
"vertical_bars": "Vertikal (Alt)",
"checkerboard": "Schachbrett",
"choco": "Schoko",
"diagonal": "Diagonal",
"cross": "Kreuz",
"mini_cross": "Mini-Kreuz",
"sword": "Schwert",
"sparse_dots": "Spärliche Punkte",
"evan": "Evan",
"diagonal_stripe": "Diagonaler Streifen",
"mountain_ridge": "Gebirgszug",
"scattered_dots": "Verstreute Punkte",
"circuit_board": "Platine",
"shells": "Muscheln",
"-w-": ".w.",
"white_rabbit": "Weißer Hase",
"goat": "Ziege",
"cats": "Katzen",
"cursor": "Zeiger",
"hand": "Hand",
"radiation": "Strahlung",
"openfront_qr": "OpenFront.io QR code",
"openfront": "OpenFront",
"t_rex": "T-Rex",
"embelem": "Emblem",
"grogu_head": "Grogu Kopf",
"grogu": "Grogu"
"default": "Standard"
}
},
"flag_input": {
"title": "Flagge auswählen",
"button_title": "Wähle eine Flagge!",
"search_flag": "Suche..."
},
"spawn_ad": {
"loading": "Anzeige wird geladen..."
},
"auth": {
"login_required": "Login ist erforderlich, um auf diese Webseite zuzugreifen.",
"redirecting": "Du wirst weitergeleitet...",
"not_authorized": "Du bist nicht berechtigt, auf diese Website zuzugreifen.",
"contact_admin": "Wenn du glaubst, dass du diese Nachricht fälschlicherweise siehst, wende dich sich bitte an den Website-Administrator."
},
"radial_menu": {
"delete_unit_title": "Einheit löschen",
"delete_unit_description": "Klicken, um die nächstgelegene Einheit zu löschen"
},
"discord_user_header": {
"avatar_alt": "Profilbild"
},
"player_stats_table": {
"building_stats": "Gebäudestatistiken",
"ship_arrivals": "Schiffseinläufe",
"nuke_stats": "Atomwaffenstatistik",
"player_metrics": "Spieler-Metriken",
"building": "Gebäude",
"ship_type": "Schiffstyp",
"weapon": "Waffe",
"built": "Erbaut",
"destroyed": "Vernichtet",
"captured": "Eingenommen",
"lost": "Verloren",
"hits": "Treffer",
"launched": "Gestartet",
"landed": "Gelandet",
"sent": "Gesendet",
"arrived": "Angekommen",
"attack": "Angriff",
"received": "Empfangen",
"cancelled": "Abgebrochen",
"count": "Anzahl",
"gold": "Gold",
"workers": "Arbeiter",
"war": "Krieg",
"trade": "Handel",
"steal": "Stehlen",
"unit": {
"city": "Stadt",
"port": "Hafen",
"defp": "Verteidigungsposten",
"saml": "Flugabwehrsystem",
"silo": "Raketensilo",
"wshp": "Kriegsschiff",
"fact": "Fabrik",
"trade": "Handelsschiff",
"trans": "Transportschiff",
"abomb": "Atombombe",
"hbomb": "Wasserstoffbombe",
"mirv": "MIRV-Rakete",
"mirvw": "MIRV-Sprengkopf"
}
},
"game_list": {
"recent_games": "Letzte Spiele",
"game_id": "Spiel-ID",
"mode": "Modus",
"mode_ffa": "Jeder gegen jeden",
"mode_team": "Team",
"view": "Ansicht",
"details": "Details",
"started": "Gestartet",
"map": "Karte",
"difficulty": "Schwierigkeitsgrad",
"type": "Typ"
},
"player_stats_tree": {
"public": "Öffentlich",
"private": "Privat",
"singleplayer": "Einzelspieler",
"mode": "Modus",
"stats_wins": "Siege",
"stats_losses": "Niederlagen",
"stats_wlr": "Sieg/Niederlage-Verhältnis",
"stats_games_played": "Gespielte Spiele",
"mode_ffa": "Jeder gegen jeden",
"mode_team": "Team"
}
}
-175
View File
@@ -1,175 +0,0 @@
{
"lang": {
"en": "debug",
"native": "debug",
"svg": "xx",
"lang_code": "debug"
},
"main": {
"join_discord": "main.join_discord",
"create_lobby": "main.create_lobby",
"join_lobby": "main.join_lobby",
"single_player": "main.single_player",
"instructions": "main.instructions",
"how_to_play": "main.how_to_play",
"wiki": "main.wiki"
},
"help_modal": {
"hotkeys": "help_modal.hotkeys",
"table_key": "help_modal.table_key",
"table_action": "help_modal.table_action",
"action_alt_view": "help_modal.action_alt_view",
"action_attack_altclick": "help_modal.action_attack_altclick",
"action_build": "help_modal.action_build",
"action_center": "help_modal.action_center",
"action_zoom": "help_modal.action_zoom",
"action_move_camera": "help_modal.action_move_camera",
"action_ratio_change": "help_modal.action_ratio_change",
"action_reset_gfx": "help_modal.action_reset_gfx",
"ui_section": "help_modal.ui_section",
"ui_leaderboard": "help_modal.ui_leaderboard",
"ui_leaderboard_desc": "help_modal.ui_leaderboard_desc",
"ui_control": "help_modal.ui_control",
"ui_control_desc": "help_modal.ui_control_desc",
"ui_pop": "help_modal.ui_pop",
"ui_gold": "help_modal.ui_gold",
"ui_troops_workers": "help_modal.ui_troops_workers",
"ui_attack_ratio": "help_modal.ui_attack_ratio",
"ui_options": "help_modal.ui_options",
"ui_options_desc": "help_modal.ui_options_desc",
"option_pause": "help_modal.option_pause",
"option_timer": "help_modal.option_timer",
"option_exit": "help_modal.option_exit",
"option_settings": "help_modal.option_settings",
"radial_title": "help_modal.radial_title",
"radial_desc": "help_modal.radial_desc",
"radial_build": "help_modal.radial_build",
"radial_info": "help_modal.radial_info",
"radial_boat": "help_modal.radial_boat",
"radial_close": "help_modal.radial_close",
"info_title": "help_modal.info_title",
"info_enemy_desc": "help_modal.info_enemy_desc",
"info_target": "help_modal.info_target",
"info_alliance": "help_modal.info_alliance",
"info_emoji": "help_modal.info_emoji",
"info_ally_panel": "help_modal.info_ally_panel",
"info_ally_desc": "help_modal.info_ally_desc",
"ally_betray": "help_modal.ally_betray",
"ally_donate": "help_modal.ally_donate",
"build_menu_title": "help_modal.build_menu_title",
"build_name": "help_modal.build_name",
"build_icon": "help_modal.build_icon",
"build_desc": "help_modal.build_desc",
"build_city": "help_modal.build_city",
"build_city_desc": "help_modal.build_city_desc",
"build_defense": "help_modal.build_defense",
"build_defense_desc": "help_modal.build_defense_desc",
"build_port": "help_modal.build_port",
"build_port_desc": "help_modal.build_port_desc",
"build_warship": "help_modal.build_warship",
"build_warship_desc": "help_modal.build_warship_desc",
"build_silo": "help_modal.build_silo",
"build_silo_desc": "help_modal.build_silo_desc",
"build_sam": "help_modal.build_sam",
"build_sam_desc": "help_modal.build_sam_desc",
"build_atom": "help_modal.build_atom",
"build_atom_desc": "help_modal.build_atom_desc",
"build_hydrogen": "help_modal.build_hydrogen",
"build_hydrogen_desc": "help_modal.build_hydrogen_desc",
"build_mirv": "help_modal.build_mirv",
"build_mirv_desc": "help_modal.build_mirv_desc",
"player_icons": "help_modal.player_icons",
"icon_desc": "help_modal.icon_desc",
"icon_crown": "help_modal.icon_crown",
"icon_traitor": "help_modal.icon_traitor",
"icon_ally": "help_modal.icon_ally",
"info_enemy_panel": "help_modal.info_enemy_panel"
},
"single_modal": {
"title": "single_modal.title",
"allow_alliances": "single_modal.allow_alliances",
"options_title": "single_modal.options_title",
"bots": "single_modal.bots",
"bots_disabled": "single_modal.bots_disabled",
"disable_nations": "single_modal.disable_nations",
"instant_build": "single_modal.instant_build",
"infinite_gold": "single_modal.infinite_gold",
"random_spawn": "single_modal.random_spawn",
"infinite_troops": "single_modal.infinite_troops",
"disable_nukes": "single_modal.disable_nukes",
"start": "single_modal.start"
},
"map": {
"map": "map.map",
"world": "map.world",
"europe": "map.europe",
"mena": "map.mena",
"northamerica": "map.northamerica",
"oceania": "map.oceania",
"blacksea": "map.blacksea",
"africa": "map.africa",
"asia": "map.asia",
"mars": "map.mars",
"southamerica": "map.southamerica",
"britannia": "map.britannia",
"gatewaytotheatlantic": "map.gatewaytotheatlantic",
"australia": "map.australia",
"random": "map.random",
"iceland": "map.iceland",
"pangaea": "map.pangaea"
},
"private_lobby": {
"title": "private_lobby.title",
"enter_id": "private_lobby.enter_id",
"player": "private_lobby.player",
"players": "private_lobby.players",
"join_lobby": "private_lobby.join_lobby",
"checking": "private_lobby.checking",
"not_found": "private_lobby.not_found",
"error": "private_lobby.error",
"joined_waiting": "private_lobby.joined_waiting"
},
"public_lobby": {
"join": "public_lobby.join",
"waiting": "public_lobby.waiting"
},
"username": {
"enter_username": "username.enter_username",
"not_string": "username.not_string",
"too_short": "username.too_short",
"too_long": "username.too_long",
"invalid_chars": "username.invalid_chars"
},
"host_modal": {
"title": "host_modal.title",
"options_title": "host_modal.options_title",
"bots": "host_modal.bots",
"bots_disabled": "host_modal.bots_disabled",
"player_immunity_duration": "host_modal.player_immunity_duration",
"disable_nations": "host_modal.disable_nations",
"instant_build": "host_modal.instant_build",
"random_spawn": "host_modal.random_spawn",
"infinite_gold": "host_modal.infinite_gold",
"infinite_troops": "host_modal.infinite_troops",
"disable_nukes": "host_modal.disable_nukes",
"player": "host_modal.player",
"players": "host_modal.players",
"waiting": "host_modal.waiting",
"start": "host_modal.start"
},
"game_starting_modal": {
"title": "game_starting_modal.title",
"desc": "game_starting_modal.desc"
},
"difficulty": {
"difficulty": "difficulty.difficulty",
"Relaxed": "difficulty.Relaxed",
"Balanced": "difficulty.Balanced",
"Intense": "difficulty.Intense",
"Impossible": "difficulty.Impossible"
},
"heads_up_message": {
"choose_spawn": "heads_up_message.choose_spawn",
"random_spawn": "heads_up_message.random_spawn"
}
}
+20 -9
View File
@@ -26,12 +26,15 @@
"title": "OpenFront (ALPHA)",
"join_discord": "Discord",
"login_discord": "Login with Discord",
"sign_in": "Sign in",
"discord_avatar_alt": "Discord profile avatar",
"user_avatar_alt": "{username}'s avatar",
"checking_login": "Checking login...",
"logged_in": "Logged in!",
"log_out": "Log out",
"create": "Create Lobby",
"join": "Join Lobby",
"solo": "Solo Lobby",
"solo": "Solo",
"instructions": "Instructions",
"game_info": "Game info",
"wiki": "Wiki",
@@ -42,7 +45,7 @@
"play": "Play",
"news": "News",
"store": "Store",
"options": "Options",
"settings": "Settings",
"keys": "Keys",
"stats": "Stats",
"account": "Account",
@@ -179,13 +182,8 @@
"title": "Account",
"connected_as": "Connected as",
"stats_overview": "Stats Overview",
"save_progress_title": "Save Your Progress",
"save_progress_desc": "Link your account to keep your stats, rank, and cosmetics safe.",
"link_discord": "Link Discord Account",
"link_via_email_placeholder": "Link via Email",
"link_button": "Link",
"log_out": "Log Out",
"welcome_back": "Welcome Back",
"sign_in_desc": "Sign in to save your stats and progress",
"or": "OR",
"email_placeholder": "Enter your email address",
@@ -429,7 +427,7 @@
"factory": "Factory"
},
"user_setting": {
"title": "User Settings",
"title": "Settings",
"tab_basic": "Basic Settings",
"tab_keybinds": "Keybinds",
"dark_mode_label": "Dark Mode",
@@ -713,7 +711,20 @@
"wants_to_renew_alliance": "{name} wants to renew your alliance",
"ignore": "Ignore",
"unit_voluntarily_deleted": "Unit voluntarily deleted",
"betrayal_debuff_ends": "{time} seconds left until betrayal debuff ends"
"betrayal_debuff_ends": "{time} seconds left until betrayal debuff ends",
"attack_cancelled_retreat": "Attack cancelled, {troops} soldiers killed during retreat",
"received_gold_from_captured_ship": "Received {gold} gold from ship captured from {name}",
"received_gold_from_trade": "Received {gold} gold from trade with {name}",
"missile_intercepted": "Missile intercepted {unit}",
"mirv_warheads_intercepted": "{count, plural, one {{count} MIRV warhead intercepted} other {{count} MIRV warheads intercepted}}",
"sent_troops_to_player": "Sent {troops} troops to {name}",
"received_troops_from_player": "Received {troops} troops from {name}",
"sent_gold_to_player": "Sent {gold} gold to {name}",
"received_gold_from_player": "Received {gold} gold from {name}",
"unit_captured_by_enemy": "Your {unit} was captured by {name}",
"captured_enemy_unit": "Captured {unit} from {name}",
"unit_destroyed": "Your {unit} was destroyed",
"no_boats_available": "No boats available, max {max}"
},
"unit_info_modal": {
"structure_info": "Structure Info",
+315 -90
View File
@@ -6,11 +6,22 @@
"lang_code": "ko"
},
"common": {
"close": "닫기"
"close": "닫기",
"available": "사용 가능",
"preset_max": "최댓값",
"summary_send": "보내기",
"summary_keep": "유지",
"cancel": "취소",
"send": "보내기",
"cap_label": "제한",
"cap_tooltip": "수령자 잔여 수용량",
"target_dead": "목표 제거 완료",
"target_dead_note": "제거된 플레이어에게 자원을 보낼수 없습니다.",
"none": "없음"
},
"main": {
"title": "오픈 프론트 (시험판)",
"join_discord": "디스코드에 참가하세요!",
"join_discord": "디스코드",
"login_discord": "디스코드로 로그인하기",
"checking_login": "로그인 확인 중...",
"logged_in": "로그인되었습니다!",
@@ -19,12 +30,13 @@
"join_lobby": "로비에 참여하기",
"single_player": "혼자 하기",
"instructions": "소개",
"how_to_play": "게임 방법",
"advertise": "광고",
"wiki": "위키"
"wiki": "위키",
"privacy_policy": "개인정보보호정책",
"terms_of_service": "서비스 이용 약관",
"reddit": "레딧"
},
"news": {
"full_changelog": "변경 사항 전부 보기",
"see_all_releases": "모든 버전 보기",
"github_link": " (깃허브)",
"title": "배포 요약"
},
@@ -41,6 +53,7 @@
"action_move_camera": "카메라 움직이기",
"action_ratio_change": "공격 비율 낮추기 / 높이기",
"action_reset_gfx": "그래픽 초기화",
"action_auto_upgrade": "가장 가까운 건물 자동 업그레이드",
"ui_section": "게임 UI",
"ui_leaderboard": "순위표",
"ui_your_team": "나의 팀:",
@@ -49,7 +62,6 @@
"ui_control_desc": "컨트롤 패널에는 다음과 같은 요소들이 포함되어 있습니다:",
"ui_pop": "인구 - 현재 보유한 유닛 수, 최대 인구 수, 그리고 인구 증가 속도를 나타냅니다.",
"ui_gold": "금 - 현재 보유한 금 양과 금 획득 속도를 나타냅니다.",
"ui_troops_workers": "병력와 일꾼 - 배치된 병력와 일꾼의 수를 나타냅니다. 병력은 공격하거나 방어할 때 사용되고, 일꾼은 골드를 생산하는 데 사용됩니다. 슬라이더를 이용해 병력와 일꾼의 수를 조절할 수 있습니다.",
"ui_attack_ratio": "공격 비율 - 공격 시 사용할 병력의 비율을 나타냅니다. 슬라이더로 공격 비율을 조절할 수 있습니다. 공격군이 방어군보다 많으면 공격 중 손실되는 병력이 줄어들고, 적으면 공격군이 더 큰 피해를 입게 됩니다. 이 효과는 최대 2:1 비율까지만 적용됩니다.",
"ui_events": "이벤트판",
"ui_events_desc": "이벤트판은 최신 이벤트, 요청사항, 그리고 빠른 채팅 메시지를 보여줍니다. 예시로는 다음과 같은 것들이 있습니다:",
@@ -68,6 +80,7 @@
"radial_title": "방사 메뉴",
"radial_desc": "마우스 오른쪽 버튼을 클릭하면 방사형 메뉴가 열립니다. (모바일에서는 터치) 방사형 메뉴 바깥쪽을 마우스 오른쪽 버튼으로 클릭하면 메뉴가 닫힙니다. 메뉴에서 다음 작업을 수행할 수 있습니다.",
"radial_build": "건설 메뉴를 엽니다.",
"radial_attack": "정보 메뉴를 엽니다.",
"radial_info": "정보 메뉴를 엽니다.",
"radial_boat": "선택한 위치를 공격하기 위해 함선(수송선)을 보냅니다. 물에 접근할 수 있는 경우에만 사용 가능합니다.",
"radial_close": "메뉴를 닫습니다.",
@@ -91,11 +104,11 @@
"build_city": "도시",
"build_city_desc": "최대 인구를 증가시킵니다. 영토를 확장할 수 없거나 인구 제한에 도달할 뻔할 때 유용합니다.",
"build_factory": "공장",
"build_factory_desc": "인근 구조물로 자동으로 철도를 만들고, 가끔 기차를 생성합니다.",
"build_factory_desc": "자동으로 인근 도시, 항구, 다른 공장까지 철도를 건설하며, 우호적인 이웃과도 연결할 수 있습니다. 기차는 일정 간격으로 등장해 경로상의 각 건물을 방문할 때마다 일정량의 금을 제공하며, 이웃의 건물을 방문할 경우 추가 금을 얻을 수 있습니다.",
"build_defense": "방어 진지",
"build_defense_desc": "체크무늬 패턴을 보이는 근처 국경 주변의 방어력을 강화합니다. 적의 공격 속도가 느려지고 사상자가 증가합니다.",
"build_port": "항구",
"build_port_desc": "물가 근처에만 건설할 수 있습니다. 군함을 건조할 수 있니다. 자국과 다른 국가의 항구 간에 무역선을 자동으로 보내 양측에 재화를 제공합니다. (무역이 중단된 경우 제외) 플레이어를 공격하거나 플레이어에게 공격을 받으면 무역이 자동으로 중단됩니다. 5분 후 또는 동맹국이 되면 무역이 재개됩니다. \"무역 중단\" 또는 \"무역 시작\"을 통해 무역을 수동으로 전환할 수 있습니다.",
"build_port_desc": "물가 근처에만 건설할 수 있습니다. 군함을 건조할 수 있게 됩니다. (무역 중단 시를 제외하고) 자국 항구와 다른 국가의 항구 간에 무역선을 자동으로 보내 양측 모두에게 금을 지급합니다. 플레이어를 공격하거나 공격받으면 해당 플레이어와의 무역이 자동으로 중단됩니다. 5분 후 또는 동맹 관계가 되면 무역이 재개됩니다. '무역 중단' 또는 '무역 시작'을 통해 수동으로 무역 상태를 변경할 수 있습니다.",
"build_warship": "군함",
"build_warship_desc": "지정한 지역을 순찰하며 적의 무역선과 보트(수송선), 군함을 공격해 파괴합니다. 가장 가까운 항구에서 생성되며, 처음 건설할 때 클릭한 지역을 순찰합니다. 군함은 공격 명령(단축키의 공격 기능 참고)을 통해 조종할 수 있으며, 이동시키고 싶은 지역을 다시 공격 클릭하면 해당 위치로 이동합니다.",
"build_silo": "미사일 발사대",
@@ -120,18 +133,46 @@
},
"single_modal": {
"title": "혼자 하기",
"random_spawn": "무작위 생성",
"allow_alliances": "동맹 허용",
"options_title": "설정",
"bots": "봇: ",
"bots_disabled": "사용 안 함",
"nations": "국가:",
"disable_nations": "국가 비활성화",
"instant_build": "빠른 건설",
"infinite_gold": "무한 금",
"infinite_troops": "무한 병력",
"compact_map": "초소형 맵",
"max_timer": "게임 길이 (분)",
"disable_nukes": "핵 금지",
"enables_title": "설정 활성화",
"start": "게임 시작하기"
},
"token_login_modal": {
"title": "로그인 중...",
"logging_in": "로그인 중...",
"success": "{email}(으)로 성공적으로 로그인했습니다!"
},
"account_modal": {
"title": "계정",
"logged_in_as": "{email}(으)로 로그인됨",
"fetching_account": "계정 정보 불러오는중...",
"logged_in_with_discord": "디스코드 로그인 완료",
"recovery_email_sent": "{email}(으)로 복구 이메일을 보냈습니다."
},
"stats_modal": {
"title": "정보",
"clan_stats": "클랜 정보",
"loading": "로딩 중...",
"error": "클랜 정보 불러오기 실패",
"no_stats": "클랜 정보 없음",
"clan": "클랜",
"games": "게임",
"win_score": "승점",
"loss_score": "패배 점수",
"win_loss_ratio": "승/패"
},
"map": {
"map": "지도",
"world": "세계",
@@ -160,7 +201,16 @@
"baikal": "바이칼",
"halkidiki": "할키디키 반도",
"straitofgibraltar": "지브롤터 해협",
"italia": "이탈리아"
"italia": "이탈리아",
"japan": "일본",
"yenisei": "예니셰이 강",
"pluto": "명왕성",
"montreal": "몬트리올",
"achiran": "아치란",
"baikalnukewars": "바이칼 (핵전쟁용)",
"fourislands": "네 개의 섬",
"gulfofstlawrence": "생로렌스만",
"lisbon": "리스본"
},
"map_categories": {
"continental": "대륙",
@@ -178,16 +228,25 @@
"join_lobby": "로비에 참여하기",
"checking": "로비 확인중...",
"not_found": "로비를 찾을 수 없습니다. 로비 ID를 확인하고 다시 시도해 주세요.",
"error": "오류가 발생했습니다. 다시 시도해 주세요.",
"joined_waiting": "성공적으로 참여했습니다! 게임이 시작될 때까지 기다리는 중입니다..."
"error": "오류가 발생했습니다. 다시 시도해주세요.",
"joined_waiting": "성공적으로 참여했습니다! 게임이 시작될 때까지 기다리는 중입니다...",
"version_mismatch": "."
},
"public_lobby": {
"join": "다음 게임 참가",
"waiting": "기다리는 플레이어들",
"teams_Duos": "듀오 (2인 1조)",
"teams_Trios": "트리오 (3인 1조)",
"teams_Quads": "쿼드 (4인 1조)",
"teams": "{num} 팀"
"teams_Duos": "2인 1조",
"teams_Trios": "3인 1조",
"teams_Quads": "4인 1조",
"teams_hvn": "인간 VS 국가",
"teams": "{num} 팀",
"players_per_team": "{num}인 1조"
},
"matchmaking_modal": {
"title": "매치메이킹",
"connecting": "매치메이킹 서버에 연결 중...",
"searching": "게임 찾는 중...",
"waiting_for_game": "게임 시작 대기 중..."
},
"username": {
"enter_username": "사용자 이름을 입력하세요",
@@ -203,15 +262,28 @@
"options_title": "설정",
"bots": "봇: ",
"bots_disabled": "사용 안 함",
"nations": "국가:",
"disable_nations": "국가 비활성화",
"max_timer": "게임 길이 (분)",
"instant_build": "빠른 건설",
"infinite_gold": "무한 금",
"donate_gold": "금 지원",
"infinite_troops": "무한 병력",
"donate_troops": "병력 지원",
"compact_map": "초소형 맵",
"enables_title": "설정 활성화",
"player": "플레이어",
"players": "플레이어",
"nation_players": "국가",
"nation_player": "국가",
"waiting": "플레이어들을 기다리고 있습니다...",
"start": "게임 시작하기"
"random_spawn": "무작위 생성",
"start": "게임 시작하기",
"host_badge": "방장",
"assigned_teams": "할당된 팀",
"empty_teams": "빈 팀",
"empty_team": "비어 있음",
"remove_player": "{username} 추방하기"
},
"team_colors": {
"red": "빨강",
@@ -225,13 +297,14 @@
},
"game_starting_modal": {
"title": "게임이 시작됩니다...",
"desc": "로비가 시작하기를 기다리고 있습니다. 기다려주세요."
"credits": "크레딧",
"code_license": "코드는 AGPL-3.0 라이선스에 따르며, 보증은 제공되지 않습니다."
},
"difficulty": {
"difficulty": "난이도",
"Relaxed": "쉬움 ",
"Balanced": "보통",
"Intense": "어려움",
"Easy": "느긋함",
"Medium": "보통",
"Hard": "치열함",
"Impossible": "불가능"
},
"game_mode": {
@@ -259,33 +332,28 @@
"tab_keybinds": "키 설정",
"dark_mode_label": "다크 모드",
"dark_mode_desc": "밝은 테마/어두운 테마로 전환합니다.",
"dark_mode_enabled": "다크 모드 적용됨",
"light_mode_enabled": "라이트 모드 적용됨",
"emojis_label": "이모지",
"emojis_visible": "이모티콘 활성화됨",
"emojis_hidden": "이모티콘 숨김 처리 됨",
"emojis_desc": "게임 내에서 감정 표현을 표시할지 여부를 선택합니다.",
"alert_frame_label": "경고 프레임",
"alert_frame_desc": "경고 프레임을 전환합니다. 활성화하면 배신당했을 때 프레임이 표시됩니다.",
"alert_frame_desc": "경고창을 켜고 끕니다. 활성화하면 배신을 당하거나 지상으로 공격받을 때 경고창이 표시됩니다.",
"special_effects_label": "특수 효과",
"special_effects_desc": "특수 효과를 켜고 끕니다. 성능을 향상시키려면 비활성화하세요.",
"special_effects_enabled": "특수 효과 적용됨",
"special_effects_disabled": "특수 효과 해제됨",
"structure_sprites_label": "건물 스프라이트",
"structure_sprites_desc": "건물 스프라이트 켜기/끄기",
"anonymous_names_label": "이름 감추기",
"anonymous_names_desc": "화면에 무작위 이름을 표시하여 실제 플레이어 이름을 숨깁니다.",
"anonymous_names_enabled": "익명 활성화됨",
"real_names_shown": "이름 보임",
"lobby_id_visibility_label": "로비 ID 숨기기",
"lobby_id_visibility_desc": "비공개 로비 생성 시 로비 ID 숨기기",
"left_click_label": "좌클릭으로 메뉴 열기",
"left_click_desc": "켜짐 상태에서는 마우스 왼쪽 클릭 시 메뉴가 열리고 검 버튼 공격이 활성화됩니다. 꺼짐 상태에서는 마우스 왼쪽 클릭 시 바로 공격이 활성화됩니다.",
"left_click_menu": "좌클릭 메뉴",
"left_click_opens_menu": "좌클릭으로 메뉴 열기",
"right_click_opens_menu": "우클릭으로 메뉴 열기",
"attack_ratio_label": "⚔️ 공격 비율",
"attack_ratio_desc": "공격에 보낼 병력 비율 (1~100%)",
"troop_ratio_label": "🪖🛠️ 병력과 일꾼 비율",
"troop_ratio_desc": "전투를 위한 병력과 재화 생산을 위한 일꾼의 균형을 조정합니다. (1~100%)",
"territory_patterns_label": "🏳️ 영토 패턴",
"territory_patterns_label": "🏳️ 영토 스킨",
"territory_patterns_desc": "게임 내에서 영토 패턴 패턴을 표시할지 여부를 선택하세요",
"performance_overlay_label": "성능 오버레이",
"performance_overlay_desc": "성능 오버레이를 켜고 끕니다. 활성화하면 성능 오버레이가 표시됩니다. 게임 중 Shift+D를 눌러 켜거나 끌 수 있습니다.",
"easter_writing_speed_label": "글쓰기 속도 배율",
"easter_writing_speed_desc": "코딩하는 척하는 속도 조정 (x1~x100)",
"easter_bug_count_label": "버그 수",
@@ -293,6 +361,27 @@
"view_options": "옵션 보기",
"toggle_view": "보기 전환",
"toggle_view_desc": "지도 보기 전환 (지형/국가)",
"build_controls": "건설 컨트롤",
"build_city": "도시 건설",
"build_city_desc": "커서 위치에 도시를 건설합니다.",
"build_factory": "공장 건설",
"build_factory_desc": "커서 위치에 공장을 건설합니다.",
"build_defense_post": "방어 초소 건설",
"build_defense_post_desc": "커서 위치에 방어 초소를 건설합니다.",
"build_port": "항구 건설",
"build_port_desc": "커서 위치에 항구를 건설합니다.",
"build_warship": "군함 건조",
"build_warship_desc": "커서 위치에 군함을 건조합니다.",
"build_missile_silo": "미사일 격납고 건설",
"build_missile_silo_desc": "커서 위치에 미사일 격납고를 건설합니다.",
"build_sam_launcher": "지대공미사일 발사대 건설",
"build_sam_launcher_desc": "커서 위치에 지대공미사일 발사대를 건설합니다.",
"build_atom_bomb": "원자 폭탄 제조",
"build_atom_bomb_desc": "커서 위치에 원자 폭탄을 제조합니다.",
"build_hydrogen_bomb": "수소 폭탄 제조",
"build_hydrogen_bomb_desc": "커서 위치에 수소 폭탄을 제조합니다.",
"build_mirv": "탄도미사일 제조",
"build_mirv_desc": "커서 위치에 탄도미사일을 제조합니다.",
"attack_ratio_controls": "공격 비율 제어",
"attack_ratio_up": "공격 비율 높이기",
"attack_ratio_up_desc": "공격 비율을 10% 증가",
@@ -324,10 +413,10 @@
"on": "켬",
"off": "끔",
"toggle_terrain": "지형 보기",
"terrain_enabled": "지형 보기 활성화됨",
"terrain_disabled": "지형 보기 비활성화됨",
"exit_game_label": "게임 나가기",
"exit_game_info": "주 메뉴로 돌아가기"
"exit_game_info": "주 메뉴로 돌아가기",
"background_music_volume": "배경 음악 음량",
"sound_effects_volume": "효과음 음량"
},
"chat": {
"title": "빠른 채팅",
@@ -349,26 +438,31 @@
},
"help": {
"troops": "병력 좀 줘!",
"troops_frontlines": "전선으로 병력을 보내!",
"gold": "금 좀 줘!",
"no_attack": "나 치지 마!",
"sorry_attack": "미안, 치려던게 아니였어;",
"alliance": "동맹할래?",
"help_defend": "[P1](으)로부터 방어하는 걸 도와줘!",
"team_up": "[P1]을(를) 상대로 함께 편을 먹자!"
"trade_partners": "무역 파트너가 되자!"
},
"attack": {
"attack": "[P1]을(를) 공격해!",
"mirv": "[P1]에게 다탄두 미사일을 발사 해야해!",
"focus": "[P1]에게 집중 공격하자!",
"finish": "[P1]을(를) 끝장내버리자!"
"finish": "[P1]을(를) 끝장내버리자!",
"build_warships": "군함을 건조해!"
},
"defend": {
"defend": "[P1]을(를) 막아!",
"defend_from": "[P1]로부터 방어해!",
"dont_attack": "[P1]을(를) 치지 마!",
"ally": "[P1]은(는) 내 동맹이야!"
"ally": "[P1]은(는) 내 동맹이야!",
"build_posts": "방어 초소를 건설해!"
},
"greet": {
"hello": "안녕!",
"good_job": "잘했어!",
"good_luck": "행운을 빌어!",
"have_fun": "재미있게 놀아!",
"gg": "GG!",
@@ -379,13 +473,19 @@
"thanks": "고마워!",
"oops": "앗, 잘못 눌렀다",
"trust_me": "날 믿어, 약속할게!",
"trust_broken": "믿었는데..."
"trust_broken": "믿었는데...",
"ruining_games": "너 때문에 우리 둘 다 망하고 있잖아.",
"dont_do_that": "그러지 마!",
"same_team": "난 네 편이야!"
},
"misc": {
"go": "가자!",
"strategy": "좋은 전략이야!",
"fun": "이 판 재밌네 ㅋㅋㅋ",
"pr": "내 PR은 도대체 언제 머지되는 걸까...?"
"team_up": "힘을 합쳐 [P1]을 공격하자!",
"pr": "내 PR은 도대체 언제 머지되는 걸까...?",
"build_closer": "철도를 공유하려면 더 가까이 건설해!",
"coastline": "해안선을 좀 차지하게 해줘."
},
"warnings": {
"strong": "[P1]은(는) 강해.",
@@ -396,10 +496,14 @@
"has_allies": "[P1]은(는) 동맹이 많아.",
"no_allies": "[P1]은(는) 동맹이 없어.",
"betrayed": "[P1]이(가) 동맹을 배신했어!",
"betrayed_me": "[P1]이(가) 날 배신했어!",
"getting_big": "[P1]이(가) 너무 빨리 크고 있어!",
"danger_base": "[P1]이(가) 방어되지 않고 있어!",
"saving_for_mirv": "[P1]이(가) 다탄두 미사일 발사를 위해 자금을 모으고 있어.",
"mirv_ready": "[P1]은(는) 다탄두 미사일 발사를 위한 금이 충분히 있어!"
"mirv_ready": "[P1]은(는) 다탄두 미사일 발사를 위한 금이 충분히 있어!",
"snowballing": "[P1]이(가) 너무 빨리 커지고 있어!",
"cheating": "[P1] 핵쓰는거 아니냐?",
"stop_trading": "[P1]과의 무역을 중단해!"
}
},
"build_menu": {
@@ -418,6 +522,8 @@
"not_enough_money": "돈이 부족합니다"
},
"win_modal": {
"support_openfront": "OpenFront를 후원해주세요!",
"territory_pattern": "영토 스킨을 구매하고 광고 없이 플레이하세요!",
"died": "죽었습니다",
"your_team": "우리팀이 승리했습니다!",
"other_team": "{team} 팀이 이겼습니다!",
@@ -425,7 +531,15 @@
"other_won": "{player} 이(가) 이겼습니다!",
"exit": "게임 나가기",
"keep": "계속 플레이하기",
"wishlist": "Steam에서 찜하기!"
"spectate": "관전하기",
"wishlist": "Steam에서 찜하기!",
"ofm_winter": "오픈프론트 마스터즈 겨울 토너먼트!",
"ofm_winter_description": "경쟁 토너먼트에 참여하여 최고의 플레이어들과 실력을 겨루세요",
"join_tournament": "토너먼트 참가",
"join_discord": "저희 디스코드 커뮤니티에 참여하세요!",
"discord_description": "다른 플레이어들과 소통하고, 업데이트 소식을 받고, 전략을 공유하세요",
"join_server": "서버 참가",
"youtube_tutorial": "도움이 필요하신가요?"
},
"leaderboard": {
"title": "순위표",
@@ -435,7 +549,13 @@
"team": "팀",
"owned": "소유함",
"gold": "금",
"troops": "병력"
"troops": "병력",
"launchers": "발사대",
"sams": "지대공 미사일",
"warships": "군함",
"cities": "도시",
"show_control": "조작 표시",
"show_units": "유닛 표시"
},
"player_info_overlay": {
"type": "유형",
@@ -443,11 +563,13 @@
"nation": "국가",
"player": "플레이어",
"team": "팀",
"d_troops": "방어 병력",
"alliance_timeout": "동맹 종료까지",
"troops": "병력",
"a_troops": "공격 병력",
"gold": "금",
"ports": "항구",
"cities": "도시",
"factories": "공장",
"missile_launchers": "미사일 발사대",
"sams": "지대공 미사일들",
"warships": "군함",
@@ -457,6 +579,7 @@
},
"events_display": {
"retreating": "후퇴 중",
"retaliate": "반격",
"boat": "배",
"alliance_request_status": "{name}이(가) 당신의 동맹 요청을 {status}했습니다",
"alliance_accepted": "수락",
@@ -475,7 +598,10 @@
"accept_alliance": "수락",
"reject_alliance": "거절",
"alliance_renewed": "{name}와(과)의 동맹이 갱신되었습니다",
"ignore": "무시하기"
"wants_to_renew_alliance": "{name}님이 동맹을 갱신을 원합니다",
"ignore": "무시하기",
"unit_voluntarily_deleted": "유닛이 해산되었습니다",
"betrayal_debuff_ends": "배신 디버프가 끝나기까지 {time}초 남았습니다"
},
"unit_info_modal": {
"structure_info": "건축물 정보",
@@ -486,6 +612,11 @@
"upgrade": "업그레이드",
"level": "레벨"
},
"player_type": {
"player": "플레이어",
"nation": "국가",
"bot": "봇"
},
"relation": {
"hostile": "적대적",
"distrustful": "신뢰하지 않음",
@@ -494,26 +625,49 @@
"default": "기본"
},
"control_panel": {
"pop": "인구",
"gold": "금",
"troops": "병력",
"workers": "일꾼",
"attack_ratio": "공격 비율"
},
"player_panel": {
"gold": "금",
"troops": "병력",
"betrayals": "배신 횟수",
"betrayals": "배신",
"traitor": "배신자",
"trading": "무역",
"active": "활성화 됨",
"stopped": "중지 됨",
"alliance_time_remaining": "동맹 만료: ",
"embargo": "당신과의 무역을 중단함",
"nuke": "당신에게 발사한 핵무기",
"start_trade": "무역 시작",
"stop_trade": "무역 중단",
"yes": "예",
"no": "아니오",
"none": "없음",
"alliances": "동맹"
"stop_trade_all": "모두와 무역 중단",
"start_trade_all": "모두와 무역 시작",
"alliances": "동맹",
"flag": "깃발",
"chat": "대화",
"target": "목표",
"break_alliance": "동맹 파기",
"alliance": "동맹",
"send_alliance": "동맹 제안",
"send_troops": "병력 보내기",
"send_gold": "금 보내기",
"emotes": "이모지"
},
"send_troops_modal": {
"title_with_name": "{name}에게 병력 보내기",
"available_tooltip": "현재 지원 가능한 병력",
"min_keep": "최소 보유량",
"slider_tooltip": "{{percent}}% • {{amount}}",
"aria_slider": "병력 슬라이더",
"capacity_note": "상대방은 지금 {{amount}}까지만 받을 수 있습니다."
},
"send_gold_modal": {
"title_with_name": "{name}에게 금 보내기",
"available_tooltip": "현재 보유 중인 금",
"aria_slider": "수량 슬라이더",
"slider_tooltip": "{{percent}}% • {{amount}}"
},
"replay_panel": {
"replay_speed": "리플레이 속도",
@@ -527,57 +681,128 @@
"copy_clipboard": "클립보드로 복사",
"copied": "복사 완료!",
"failed_copy": "복사 실패",
"spawn_failed": {
"title": "시작 지점 생성 실패",
"description": "자동 시작 지점 선택에 실패했습니다. 이 게임을 플레이할 수 없습니다."
},
"desync_notice": "다른 플레이어와 동기화되지 않았습니다. 보이는 것이 다른 플레이어와 다를 수 있습니다."
},
"performance_overlay": {
"reset": "초기화",
"copy_json_title": "현재 성능 지표를 JSON으로 복사",
"copy_clipboard": "JSON 복사",
"copied": "복사됨!",
"failed_copy": "복사 실패",
"fps": "초당 프레임 수(FPS):",
"avg_60s": "평균 (60초):",
"frame": "프레임:",
"tick_exec": "틱 실행 시간:",
"tick_delay": "틱 지연 시간:",
"layers_header": "레이어 (평균 / 최대, 총 시간 순 정렬):"
},
"heads_up_message": {
"choose_spawn": "시작 위치를 선택하세요"
"choose_spawn": "시작 위치를 선택하세요",
"random_spawn": "무작위 시작이 활성화되었습니다. 시작 위치를 선택하는 중..."
},
"territory_patterns": {
"title": "영토 패턴 선택",
"title": "스킨",
"colors": "색상",
"purchase": "구매",
"show_only_owned": "나의 스킨",
"blocked": {
"login": "이 패턴에 액세스하려면 로그인해야 합니다.",
"purchase": "이 패턴을 구매하여 잠금 해제하세요."
"login": "이 스킨에 액세스하려면 로그인해야 합니다.",
"purchase": "이 스킨을 구매하여 잠금 해제하세요."
},
"pattern": {
"default": "기본값",
"custom": "사용자 정의",
"stripes_v": "세로",
"stripes_h": "가로",
"horizontal_stripes": "가로 (보조)",
"vertical_bars": "세로 (보조)",
"checkerboard": "체커보드",
"choco": "초코",
"diagonal": "대각선",
"cross": "교차",
"mini_cross": "작은 교차",
"sword": "검",
"sparse_dots": "듬성듬성한 점들",
"evan": "이반",
"diagonal_stripe": "대각선 줄무늬",
"mountain_ridge": "산마루",
"scattered_dots": "흩어진 점들",
"circuit_board": "회로 기판",
"shells": "껍데기",
"-w-": ".w.",
"white_rabbit": "하얀 토끼",
"goat": "염소",
"cats": "고양이",
"cursor": "커서",
"hand": "손",
"radiation": "방사선",
"openfront_qr": "OpenFront.io QR 코드",
"openfront": "오픈프론트",
"t_rex": "티라노사우루스",
"embelem": "휘장",
"grogu_head": "그로구 머리",
"grogu": "그로구"
"default": "기본값"
}
},
"flag_input": {
"title": "깃발 선택",
"button_title": "깃발을 선택하세요!",
"search_flag": "검색..."
},
"spawn_ad": {
"loading": "광고 로딩 중..."
},
"auth": {
"login_required": "이 웹사이트에 접속하려면 로그인해야 합니다.",
"redirecting": "다른 페이지로 이동 중입니다...",
"not_authorized": "이 웹사이트에 접근할 권한이 없습니다.",
"contact_admin": "이 메시지가 잘못 표시되었다고 생각되면 웹사이트 관리자에게 문의하십시오."
},
"radial_menu": {
"delete_unit_title": "유닛 삭제",
"delete_unit_description": "클릭하여 가장 가까운 유닛을 삭제합니다"
},
"discord_user_header": {
"avatar_alt": "아바타"
},
"player_stats_table": {
"building_stats": "건물 통계",
"ship_arrivals": "함선 도착",
"nuke_stats": "핵 통계",
"player_metrics": "플레이어 지표",
"building": "건물",
"ship_type": "함선 종류",
"weapon": "무기",
"built": "건설함",
"destroyed": "파괴됨",
"captured": "점령함",
"lost": "잃음",
"hits": "명중",
"launched": "발사함",
"landed": "명중함",
"sent": "보냄",
"arrived": "도착함",
"attack": "공격",
"received": "받음",
"cancelled": "취소됨",
"count": "횟수",
"gold": "금",
"workers": "일꾼",
"war": "전쟁",
"trade": "무역",
"steal": "약탈",
"unit": {
"city": "도시",
"port": "항구",
"defp": "방어 진지",
"saml": "지대공 미사일 발사대",
"silo": "미사일 격납고",
"wshp": "군함",
"fact": "공장",
"trade": "무역선",
"trans": "운송선",
"abomb": "원자폭탄",
"hbomb": "수소폭탄",
"mirv": "다탄두 미사일",
"mirvw": "다탄두 탄두"
}
},
"game_list": {
"recent_games": "최근 게임",
"game_id": "게임 ID",
"mode": "모드",
"mode_ffa": "개인전",
"mode_team": "팀전",
"view": "보기",
"details": "세부 정보",
"started": "시작됨",
"map": "지도",
"difficulty": "난이도",
"type": "유형"
},
"player_stats_tree": {
"public": "공개",
"private": "비공개",
"singleplayer": "혼자 하기",
"mode": "모드",
"stats_wins": "승",
"stats_losses": "패",
"stats_wlr": "승패 비율",
"stats_games_played": "플레이 횟수",
"mode_ffa": "개인전",
"mode_team": "팀전"
}
}
+31 -31
View File
@@ -1,119 +1,119 @@
{
"map": {
"height": 2514,
"num_land_tiles": 1669657,
"width": 2048
"height": 2088,
"num_land_tiles": 1188359,
"width": 1600
},
"map16x": {
"height": 628,
"num_land_tiles": 99515,
"width": 512
"height": 522,
"num_land_tiles": 70697,
"width": 400
},
"map4x": {
"height": 1257,
"num_land_tiles": 410570,
"width": 1024
"height": 1044,
"num_land_tiles": 292283,
"width": 800
},
"name": "britannia",
"name": "Britannia",
"nations": [
{
"coordinates": [1969, 2305],
"coordinates": [1539, 1915],
"flag": "fr",
"name": "Pas-de-Calais"
},
{
"coordinates": [274, 1494],
"coordinates": [214, 1242],
"flag": "ie",
"name": "Mayo"
},
{
"coordinates": [242, 1931],
"coordinates": [189, 1605],
"flag": "ie",
"name": "Kerry"
},
{
"coordinates": [328, 1754],
"coordinates": [257, 1458],
"flag": "ie",
"name": "Clare"
},
{
"coordinates": [675, 1559],
"coordinates": [527, 1295],
"flag": "ie",
"name": "Meath"
},
{
"coordinates": [900, 518],
"coordinates": [703, 431],
"flag": "gb-sct",
"name": "Highland"
},
{
"coordinates": [782, 830],
"coordinates": [611, 690],
"flag": "gb-sct",
"name": "Argyll and Bute"
},
{
"coordinates": [1431, 1447],
"coordinates": [1118, 1202],
"flag": "gb-eng",
"name": "North Yorkshire"
},
{
"coordinates": [1243, 622],
"coordinates": [971, 517],
"flag": "gb-sct",
"name": "Aberdeenshire"
},
{
"coordinates": [1218, 1309],
"coordinates": [951, 1088],
"flag": "gb-eng",
"name": "Cumbria"
},
{
"coordinates": [1082, 1202],
"coordinates": [846, 1000],
"flag": "gb-sct",
"name": "Dumfries and Galloway"
},
{
"coordinates": [1096, 808],
"coordinates": [856, 672],
"flag": "gb-sct",
"name": "Perthshire and Kinross"
},
{
"coordinates": [1626, 1688],
"coordinates": [1271, 1403],
"flag": "gb-eng",
"name": "Lincolnshire"
},
{
"coordinates": [1110, 2266],
"coordinates": [867, 1883],
"flag": "gb-eng",
"name": "Devon"
},
{
"coordinates": [1345, 1138],
"coordinates": [1051, 946],
"flag": "gb-eng",
"name": "Northumberland"
},
{
"coordinates": [1792, 1799],
"coordinates": [1400, 1495],
"flag": "gb-eng",
"name": "Norfolk"
},
{
"coordinates": [1154, 1888],
"coordinates": [902, 1569],
"flag": "gb-wls",
"name": "Powys"
},
{
"coordinates": [1801, 1905],
"coordinates": [1407, 1584],
"flag": "gb-eng",
"name": "Suffolk"
},
{
"coordinates": [1469, 2190],
"coordinates": [1148, 1820],
"flag": "gb-eng",
"name": "Hampshire"
},
{
"coordinates": [516, 1379],
"flag": "gb",
"coordinates": [404, 1146],
"flag": "gb-nir",
"name": "Fermanagh"
}
]
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 12 KiB

+4 -128
View File
@@ -72,7 +72,7 @@ export class AccountModal extends BaseModal {
const content = this.isLoadingUser
? html`
<div
class="flex flex-col items-center justify-center p-12 text-white bg-black/40 backdrop-blur-md rounded-2xl border border-white/10 h-full min-h-[400px]"
class="flex flex-col items-center justify-center p-12 text-white bg-black/60 backdrop-blur-md rounded-2xl border border-white/10 h-full min-h-[400px]"
>
<div
class="w-12 h-12 border-4 border-blue-500/30 border-t-blue-500 rounded-full animate-spin mb-4"
@@ -107,7 +107,7 @@ export class AccountModal extends BaseModal {
return html`
<div
class="h-full flex flex-col bg-black/40 backdrop-blur-md rounded-2xl border border-white/10 overflow-hidden"
class="h-full flex flex-col bg-black/60 backdrop-blur-md rounded-2xl border border-white/10 overflow-hidden"
>
<div
class="flex items-center mb-6 pb-2 border-b border-white/10 gap-2 shrink-0 p-6"
@@ -173,11 +173,7 @@ export class AccountModal extends BaseModal {
const isLinked = me?.discord ?? me?.email;
if (!isLinked) {
return html`
<div class="p-6 flex justify-center items-start min-h-full">
<div class="w-full max-w-2xl">${this.renderLinkAccountSection()}</div>
</div>
`;
return this.renderLoginOptions();
}
return html`
@@ -235,60 +231,6 @@ export class AccountModal extends BaseModal {
`;
}
private renderLinkAccountSection(): TemplateResult {
return html`
<div class="bg-blue-500/10 rounded-xl border border-blue-500/20 p-6">
<div class="flex items-start gap-4 mb-6">
<div>
<h3 class="text-lg font-bold text-white uppercase tracking-wider">
${translateText("account_modal.save_progress_title")}
</h3>
<p class="text-sm text-white/60 mt-1">
${translateText("account_modal.save_progress_desc")}
</p>
</div>
</div>
<div class="flex flex-col gap-3">
<button
@click="${this.handleDiscordLogin}"
class="w-full px-4 py-3 text-white bg-[#5865F2] hover:bg-[#4752C4] border border-transparent rounded-xl focus:outline-none transition-all duration-200 flex items-center justify-center gap-2 group relative overflow-hidden shadow-lg hover:shadow-xl"
>
<img
src="/images/DiscordLogo.svg"
alt="Discord"
class="w-5 h-5 relative z-10"
/>
<span class="font-bold text-sm relative z-10 tracking-wide"
>${translateText("main.login_discord") ||
translateText("account_modal.link_discord")}</span
>
</button>
<div class="relative group w-full">
<div class="flex gap-2">
<input
type="email"
.value="${this.email}"
@input="${this.handleEmailInput}"
class="flex-1 min-w-0 px-4 py-2 bg-black/40 border border-white/10 rounded-lg text-white text-sm placeholder-white/30 focus:outline-none focus:border-blue-500 transition-all font-medium"
placeholder="${translateText(
"account_modal.link_via_email_placeholder",
)}"
/>
<button
@click="${this.handleSubmit}"
class="px-5 py-2 text-sm font-bold text-white uppercase bg-blue-600 hover:bg-blue-500 rounded-lg transition-all border border-blue-500/50 hover:shadow-[0_0_15px_rgba(37,99,235,0.3)] shrink-0"
>
${translateText("account_modal.link_button")}
</button>
</div>
</div>
</div>
</div>
`;
}
private renderLoggedInAs(): TemplateResult {
const me = this.userMeResponse?.user;
if (me?.discord) {
@@ -309,46 +251,7 @@ export class AccountModal extends BaseModal {
</div>
`;
}
// "Mini" Login Options for linking account
return html`
<div class="w-full space-y-3">
<button
@click="${this.handleDiscordLogin}"
class="w-full px-4 py-3 text-white bg-[#5865F2] hover:bg-[#4752C4] border border-transparent rounded-xl focus:outline-none transition-colors duration-200 flex items-center justify-center gap-2 group relative overflow-hidden"
>
<img
src="/images/DiscordLogo.svg"
alt="Discord"
class="w-5 h-5 relative z-10"
/>
<span class="font-bold text-sm relative z-10 tracking-wide"
>${translateText("main.login_discord") ||
translateText("account_modal.link_discord")}</span
>
</button>
<div class="relative group">
<div class="flex gap-2">
<input
type="email"
.value="${this.email}"
@input="${this.handleEmailInput}"
class="w-full px-4 py-2 bg-black/40 border border-white/10 rounded-lg text-white text-sm placeholder-white/30 focus:outline-none focus:border-blue-500 transition-all font-medium"
placeholder="${translateText(
"account_modal.link_via_email_placeholder",
)}"
/>
<button
@click="${this.handleSubmit}"
class="px-4 py-2 text-sm font-bold text-white uppercase bg-blue-600 hover:bg-blue-500 rounded-lg transition-all"
>
${translateText("account_modal.link_button")}
</button>
</div>
</div>
</div>
`;
return html``;
}
private viewGame(gameId: string): void {
@@ -398,11 +301,6 @@ export class AccountModal extends BaseModal {
<line x1="15" y1="12" x2="3" y2="12"></line>
</svg>
</div>
<h3
class="text-xl font-bold text-white uppercase tracking-widest mb-2"
>
${translateText("account_modal.welcome_back")}
</h3>
<p class="text-white/50 text-sm font-medium">
${translateText("account_modal.sign_in_desc")}
</p>
@@ -414,9 +312,6 @@ export class AccountModal extends BaseModal {
@click="${this.handleDiscordLogin}"
class="w-full px-6 py-4 text-white bg-[#5865F2] hover:bg-[#4752C4] border border-transparent rounded-xl focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#5865F2] transition-colors duration-200 flex items-center justify-center gap-3 group relative overflow-hidden shadow-lg hover:shadow-[#5865F2]/20"
>
<div
class="absolute inset-0 bg-white/10 translate-y-full group-hover:translate-y-0 transition-transform duration-300"
></div>
<img
src="/images/DiscordLogo.svg"
alt="Discord"
@@ -454,25 +349,6 @@ export class AccountModal extends BaseModal {
)}"
required
/>
<div
class="absolute right-4 top-1/2 -translate-y-1/2 text-white/20"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"
></path>
<polyline points="22,6 12,13 2,6"></polyline>
</svg>
</div>
</div>
<button
@click="${this.handleSubmit}"
+42 -8
View File
@@ -1,5 +1,5 @@
import { LitElement, css, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { renderPlayerFlag } from "../core/CustomFlag";
import { FlagSchema } from "../core/Schemas";
import { translateText } from "./Utils";
@@ -10,7 +10,12 @@ const flagKey: string = "flag";
export class FlagInput extends LitElement {
@state() public flag: string = "";
static styles = css``;
@property({ type: Boolean, attribute: "show-select-label" })
public showSelectLabel: boolean = false;
private isDefaultFlagValue(flag: string): boolean {
return !flag || flag === "xx";
}
public getCurrentFlag(): string {
return this.flag;
@@ -42,6 +47,17 @@ export class FlagInput extends LitElement {
}
};
private onInputClick(e: Event) {
e.preventDefault();
e.stopPropagation();
this.dispatchEvent(
new CustomEvent("flag-input-click", {
bubbles: true,
composed: true,
}),
);
}
connectedCallback() {
super.connectedCallback();
this.flag = this.getStoredFlag();
@@ -59,18 +75,31 @@ export class FlagInput extends LitElement {
}
render() {
const isDefaultFlag = this.isDefaultFlagValue(this.flag);
const showSelect = this.showSelectLabel && isDefaultFlag;
const buttonTitle = showSelect
? translateText("flag_input.title")
: translateText("flag_input.button_title");
return html`
<button
id="flag-input_"
class="flag-btn m-0 border-0 bg-transparent hover:bg-white/10 w-full h-full flex cursor-pointer justify-center items-center focus:outline-none focus:ring-0 transition-colors duration-200"
id="flag-input"
class="flag-btn 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-slate-900/80 hover:bg-slate-800/80 active:bg-slate-800/90 rounded-lg overflow-hidden"
style="padding: 0 !important;"
title=${translateText("flag_input.button_title")}
title=${buttonTitle}
@click=${this.onInputClick}
>
<span
id="flag-preview"
class="w-full h-full overflow-hidden"
style="display:block;"
class=${showSelect ? "hidden" : "w-full h-full overflow-hidden"}
></span>
${showSelect
? html`<span
class="text-[10px] font-black text-white/40 uppercase leading-none break-words w-full text-center px-1"
>
${translateText("flag_input.title")}
</span>`
: null}
</button>
`;
}
@@ -81,6 +110,11 @@ export class FlagInput extends LitElement {
) as HTMLElement;
if (!preview) return;
if (this.showSelectLabel && this.isDefaultFlagValue(this.flag)) {
preview.innerHTML = "";
return;
}
preview.innerHTML = "";
if (this.flag?.startsWith("!")) {
+2 -2
View File
@@ -18,7 +18,7 @@ export class FlagInputModal extends BaseModal {
render() {
const content = html`
<div
class="h-full flex flex-col bg-black/40 backdrop-blur-md rounded-2xl border border-white/10 overflow-hidden"
class="h-full flex flex-col bg-black/60 backdrop-blur-md rounded-2xl border border-white/10 overflow-hidden"
>
<div
class="flex items-center mb-4 pb-2 border-b border-white/10 gap-2 shrink-0 p-6"
@@ -54,7 +54,7 @@ export class FlagInputModal extends BaseModal {
<div class="flex justify-center w-full px-6 pb-4 shrink-0">
<input
class="h-12 w-full max-w-md border border-white/10 bg-black/40
class="h-12 w-full max-w-md border border-white/10 bg-black/60
rounded-xl shadow-inner text-xl text-center focus:outline-none
focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 text-white placeholder-white/30 transition-all"
type="text"
+3 -3
View File
@@ -99,7 +99,7 @@ export class HelpModal extends BaseModal {
const content = html`
<div
class="h-full flex flex-col ${this.inline
? "bg-black/40 backdrop-blur-md rounded-2xl border border-white/10 p-6"
? "bg-black/60 backdrop-blur-md rounded-2xl border border-white/10 p-6"
: ""}"
>
<div class="flex items-center mb-6 pb-2 border-b border-white/10 gap-2">
@@ -598,7 +598,7 @@ export class HelpModal extends BaseModal {
</li>
<li class="flex items-center gap-3">
<img
src="/images/BoatIcon.svg"
src="/images/BoatIconWhite.svg"
class="w-8 h-8 scale-75 origin-left"
/>
<span>${translateText("help_modal.radial_boat")}</span>
@@ -701,7 +701,7 @@ export class HelpModal extends BaseModal {
</li>
<li class="flex items-center gap-3">
<img
src="/images/TargetIcon.svg"
src="/images/TargetIconWhite.svg"
class="w-8 h-8 scale-75 origin-left"
/>
<span>${translateText("help_modal.info_target")}</span>
+22 -3
View File
@@ -102,7 +102,7 @@ export class HostLobbyModal extends BaseModal {
render() {
const content = html`
<div
class="h-full flex flex-col bg-black/40 backdrop-blur-md rounded-2xl border border-white/10 overflow-hidden select-none"
class="h-full flex flex-col bg-black/60 backdrop-blur-md rounded-2xl border border-white/10 overflow-hidden select-none"
>
<!-- Header -->
<div
@@ -202,6 +202,25 @@ export class HostLobbyModal extends BaseModal {
? this.lobbyId
: "••••••••"}
</button>
<button
@click=${this.copyToClipboard}
class="p-1.5 rounded-md hover:bg-white/10 text-white/60 hover:text-white transition-colors"
title="${translateText("common.click_to_copy")}"
aria-label="${translateText("common.click_to_copy")}"
type="button"
>
<svg
viewBox="0 0 24 24"
height="16px"
width="16px"
fill="currentColor"
aria-hidden="true"
>
<path
d="M16 1H4c-1.1 0-2 .9-2 2v12h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"
/>
</svg>
</button>
</div>
</div>
@@ -614,7 +633,7 @@ export class HostLobbyModal extends BaseModal {
min="0"
max="120"
.value=${String(this.maxTimerValue ?? 0)}
class="w-full text-center rounded bg-black/40 text-white text-sm font-bold border border-white/20 focus:outline-none focus:border-blue-500 p-1 my-1"
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"
@click=${(e: Event) => e.stopPropagation()}
@input=${this.handleMaxTimerValueChanges}
@keydown=${this.handleMaxTimerValueKeyDown}
@@ -694,7 +713,7 @@ export class HostLobbyModal extends BaseModal {
.value=${String(
this.spawnImmunityDurationMinutes ?? 0,
)}
class="w-full text-center rounded bg-black/40 text-white text-sm font-bold border border-white/20 focus:outline-none focus:border-blue-500 p-1 my-1"
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"
@click=${(e: Event) => e.stopPropagation()}
@input=${this.handleSpawnImmunityDurationInput}
@keydown=${this.handleSpawnImmunityDurationKeyDown}
+1 -1
View File
@@ -185,7 +185,7 @@ export class InputHandler {
}
// Map invalid values to undefined (filtered later)
if (typeof val !== "string" || val === "Null") {
if (typeof val !== "string") {
return [k, undefined];
}
return [k, val];
+20 -1
View File
@@ -38,7 +38,7 @@ export class JoinPrivateLobbyModal extends BaseModal {
render() {
const content = html`
<div
class="h-full flex flex-col bg-black/40 backdrop-blur-md rounded-2xl border border-white/10 overflow-hidden select-none"
class="h-full flex flex-col bg-black/60 backdrop-blur-md rounded-2xl border border-white/10 overflow-hidden select-none"
>
<div
class="flex items-center mb-6 pb-2 border-b border-white/10 gap-2 shrink-0 p-6"
@@ -137,6 +137,25 @@ export class JoinPrivateLobbyModal extends BaseModal {
? this.currentLobbyId
: "••••••••"}
</div>
<button
@click=${this.copyToClipboard}
class="p-1.5 rounded-md hover:bg-white/10 text-white/60 hover:text-white transition-colors"
title="${translateText("common.click_to_copy")}"
aria-label="${translateText("common.click_to_copy")}"
type="button"
>
<svg
viewBox="0 0 24 24"
height="16px"
width="16px"
fill="currentColor"
aria-hidden="true"
>
<path
d="M16 1H4c-1.1 0-2 .9-2 2v12h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"
/>
</svg>
</button>
</div>`
: ""}
</div>
-533
View File
@@ -1,533 +0,0 @@
import { html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { formatKeyForDisplay, translateText } from "../client/Utils";
import "./components/baseComponents/setting/SettingKeybind";
import { SettingKeybind } from "./components/baseComponents/setting/SettingKeybind";
import { BaseModal } from "./components/BaseModal";
const DefaultKeybinds: Record<string, string> = {
toggleView: "Space",
buildCity: "Digit1",
buildFactory: "Digit2",
buildPort: "Digit3",
buildDefensePost: "Digit4",
buildMissileSilo: "Digit5",
buildSamLauncher: "Digit6",
buildWarship: "Digit7",
buildAtomBomb: "Digit8",
buildHydrogenBomb: "Digit9",
buildMIRV: "Digit0",
attackRatioDown: "KeyT",
attackRatioUp: "KeyY",
boatAttack: "KeyB",
groundAttack: "KeyG",
localAttack: "KeyL",
zoomOut: "KeyQ",
zoomIn: "KeyE",
centerCamera: "KeyC",
moveUp: "KeyW",
moveLeft: "KeyA",
moveDown: "KeyS",
moveRight: "KeyD",
};
@customElement("keybinds-modal")
export class KeybindsModal extends BaseModal {
@state() private keybinds: Record<
string,
{ value: string | string[]; key: string }
> = {};
connectedCallback() {
super.connectedCallback();
const savedKeybinds = localStorage.getItem("settings.keybinds");
if (savedKeybinds) {
try {
const parsed = JSON.parse(savedKeybinds);
// Validate shape: ensure all values have 'value' and 'key' properties with correct types
if (
typeof parsed === "object" &&
parsed !== null &&
!Array.isArray(parsed)
) {
const isValid = Object.values(parsed).every((entry) => {
// Ensure entry is an object (not null, not array, not primitive)
if (
typeof entry !== "object" ||
entry === null ||
Array.isArray(entry)
) {
return false;
}
// Ensure 'key' property exists and is a string
if (!("key" in entry) || typeof entry.key !== "string") {
return false;
}
// Ensure 'value' property exists and is either a string or an array of strings
if (!("value" in entry)) {
return false;
}
if (typeof entry.value === "string") {
return true;
}
if (Array.isArray(entry.value)) {
return entry.value.every((v) => typeof v === "string");
}
return false;
});
if (isValid) {
this.keybinds = parsed;
} else {
console.warn(
"Invalid keybinds structure: entries must be objects with 'key' (string) and 'value' (string or string[]) properties. Ignoring saved data.",
);
}
} else {
console.warn(
"Invalid keybinds data: expected non-array object. Ignoring saved data.",
);
}
} catch (e) {
console.warn("Invalid keybinds JSON:", e);
}
}
}
private handleKeybindChange(
e: CustomEvent<{
action: string;
value: string;
key: string;
prevValue?: string;
}>,
) {
const { action, value, key, prevValue } = e.detail;
const activeKeybinds: Record<string, string> = { ...DefaultKeybinds };
for (const [k, v] of Object.entries(this.keybinds)) {
// Normalize value to string
const normalizedValue = Array.isArray(v.value)
? v.value[0] || ""
: v.value;
if (normalizedValue === "Null") {
delete activeKeybinds[k];
} else {
activeKeybinds[k] = normalizedValue;
}
}
const values = Object.entries(activeKeybinds)
.filter(([k]) => k !== action)
.map(([, v]) => v);
if (values.includes(value) && value !== "Null") {
// Format key for user-friendly display
const displayKey = formatKeyForDisplay(key || value);
// Use heads-up-message modal for error popup
window.dispatchEvent(
new CustomEvent("show-message", {
detail: {
message: html`
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 text-red-500 inline-block align-middle mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<span class="font-medium">
${(() => {
const message = translateText(
"user_setting.keybind_conflict_error",
{ key: displayKey },
);
const parts = message.split(displayKey);
return html`${parts[0]}<span
class="font-mono font-bold bg-white/10 px-1.5 py-0.5 rounded text-red-200 mx-1 border border-white/10"
>${displayKey}</span
>${parts[1] || ""}`;
})()}
</span>
`,
color: "red",
duration: 3000,
},
}),
);
const element = this.renderRoot.querySelector(
`setting-keybind[action="${action}"]`,
) as SettingKeybind;
if (element) {
// Restore the previous value, or use default keybind if no previous override
element.value = prevValue ?? DefaultKeybinds[action] ?? "";
element.requestUpdate();
}
return;
}
this.keybinds = { ...this.keybinds, [action]: { value: value, key: key } };
localStorage.setItem("settings.keybinds", JSON.stringify(this.keybinds));
}
private getKeyValue(action: string): string | undefined {
const entry = this.keybinds[action];
if (!entry) return undefined;
// Normalize value to string
const normalizedValue = Array.isArray(entry.value)
? entry.value[0] || ""
: entry.value;
if (normalizedValue === "Null") return "";
return normalizedValue || undefined;
}
private getKeyChar(action: string): string {
const entry = this.keybinds[action];
if (!entry) return "";
return entry.key || "";
}
render() {
const content = html`
<div
class="h-full flex flex-col ${this.inline
? "bg-black/40 backdrop-blur-md rounded-2xl border border-white/10"
: ""}"
>
<div
class="flex items-center mb-6 pb-2 border-b border-white/10 gap-2 shrink-0 p-6"
>
<div class="flex items-center gap-4 flex-1">
<button
@click=${this.close}
class="group flex items-center justify-center w-10 h-10 rounded-full bg-white/5 hover:bg-white/10 transition-all border border-white/10"
aria-label="${translateText("common.back")}"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-5 h-5 text-gray-400 group-hover:text-white transition-colors"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 19l-7-7m0 0l7-7m-7 7h18"
/>
</svg>
</button>
<span
class="text-white text-xl sm:text-2xl md:text-3xl font-bold uppercase tracking-widest break-words hyphens-auto"
>
${translateText("user_setting.tab_keybinds")}
</span>
</div>
</div>
<div
class="flex-1 overflow-y-auto scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent px-6 pb-6 mr-1"
>
<div class="flex flex-col gap-2">${this.renderKeybindSettings()}</div>
</div>
</div>
`;
if (this.inline) {
return content;
}
return html`
<o-modal
title="${translateText("user_setting.tab_keybinds")}"
?inline=${this.inline}
hideCloseButton
hideHeader
>
${content}
</o-modal>
`;
}
private renderKeybindSettings() {
return html`
<h2
class="text-blue-200 text-xl font-bold mt-4 mb-3 border-b border-white/10 pb-2"
>
${translateText("user_setting.view_options")}
</h2>
<setting-keybind
action="toggleView"
label=${translateText("user_setting.toggle_view")}
description=${translateText("user_setting.toggle_view_desc")}
defaultKey="Space"
.value=${this.getKeyValue("toggleView")}
.display=${this.getKeyChar("toggleView")}
@change=${this.handleKeybindChange}
></setting-keybind>
<h2
class="text-blue-200 text-xl font-bold mt-8 mb-3 border-b border-white/10 pb-2"
>
${translateText("user_setting.build_controls")}
</h2>
<setting-keybind
action="buildCity"
label=${translateText("user_setting.build_city")}
description=${translateText("user_setting.build_city_desc")}
defaultKey="Digit1"
.value=${this.getKeyValue("buildCity")}
.display=${this.getKeyChar("buildCity")}
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="buildFactory"
label=${translateText("user_setting.build_factory")}
description=${translateText("user_setting.build_factory_desc")}
defaultKey="Digit2"
.value=${this.getKeyValue("buildFactory")}
.display=${this.getKeyChar("buildFactory")}
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="buildPort"
label=${translateText("user_setting.build_port")}
description=${translateText("user_setting.build_port_desc")}
defaultKey="Digit3"
.value=${this.getKeyValue("buildPort")}
.display=${this.getKeyChar("buildPort")}
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="buildDefensePost"
label=${translateText("user_setting.build_defense_post")}
description=${translateText("user_setting.build_defense_post_desc")}
defaultKey="Digit4"
.value=${this.getKeyValue("buildDefensePost")}
.display=${this.getKeyChar("buildDefensePost")}
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="buildMissileSilo"
label=${translateText("user_setting.build_missile_silo")}
description=${translateText("user_setting.build_missile_silo_desc")}
defaultKey="Digit5"
.value=${this.getKeyValue("buildMissileSilo")}
.display=${this.getKeyChar("buildMissileSilo")}
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="buildSamLauncher"
label=${translateText("user_setting.build_sam_launcher")}
description=${translateText("user_setting.build_sam_launcher_desc")}
defaultKey="Digit6"
.value=${this.getKeyValue("buildSamLauncher")}
.display=${this.getKeyChar("buildSamLauncher")}
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="buildWarship"
label=${translateText("user_setting.build_warship")}
description=${translateText("user_setting.build_warship_desc")}
defaultKey="Digit7"
.value=${this.getKeyValue("buildWarship")}
.display=${this.getKeyChar("buildWarship")}
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="buildAtomBomb"
label=${translateText("user_setting.build_atom_bomb")}
description=${translateText("user_setting.build_atom_bomb_desc")}
defaultKey="Digit8"
.value=${this.getKeyValue("buildAtomBomb")}
.display=${this.getKeyChar("buildAtomBomb")}
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="buildHydrogenBomb"
label=${translateText("user_setting.build_hydrogen_bomb")}
description=${translateText("user_setting.build_hydrogen_bomb_desc")}
defaultKey="Digit9"
.value=${this.getKeyValue("buildHydrogenBomb")}
.display=${this.getKeyChar("buildHydrogenBomb")}
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="buildMIRV"
label=${translateText("user_setting.build_mirv")}
description=${translateText("user_setting.build_mirv_desc")}
defaultKey="Digit0"
.value=${this.getKeyValue("buildMIRV")}
.display=${this.getKeyChar("buildMIRV")}
@change=${this.handleKeybindChange}
></setting-keybind>
<h2
class="text-blue-200 text-xl font-bold mt-8 mb-3 border-b border-white/10 pb-2"
>
${translateText("user_setting.attack_ratio_controls")}
</h2>
<setting-keybind
action="attackRatioDown"
label=${translateText("user_setting.attack_ratio_down")}
description=${translateText("user_setting.attack_ratio_down_desc")}
defaultKey="KeyT"
.value=${this.getKeyValue("attackRatioDown")}
.display=${this.getKeyChar("attackRatioDown")}
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="attackRatioUp"
label=${translateText("user_setting.attack_ratio_up")}
description=${translateText("user_setting.attack_ratio_up_desc")}
defaultKey="KeyY"
.value=${this.getKeyValue("attackRatioUp")}
.display=${this.getKeyChar("attackRatioUp")}
@change=${this.handleKeybindChange}
></setting-keybind>
<h2
class="text-blue-200 text-xl font-bold mt-8 mb-3 border-b border-white/10 pb-2"
>
${translateText("user_setting.attack_keybinds")}
</h2>
<setting-keybind
action="boatAttack"
label=${translateText("user_setting.boat_attack")}
description=${translateText("user_setting.boat_attack_desc")}
defaultKey="KeyB"
.value=${this.getKeyValue("boatAttack")}
.display=${this.getKeyChar("boatAttack")}
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="groundAttack"
label=${translateText("user_setting.ground_attack")}
description=${translateText("user_setting.ground_attack_desc")}
defaultKey="KeyG"
.value=${this.getKeyValue("groundAttack")}
.display=${this.getKeyChar("groundAttack")}
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="localAttack"
label=${translateText("user_setting.local_attack")}
description=${translateText("user_setting.local_attack_desc")}
defaultKey="KeyL"
.value=${this.getKeyValue("localAttack")}
.display=${this.getKeyChar("localAttack")}
@change=${this.handleKeybindChange}
></setting-keybind>
<h2
class="text-blue-200 text-xl font-bold mt-8 mb-3 border-b border-white/10 pb-2"
>
${translateText("user_setting.zoom_controls")}
</h2>
<setting-keybind
action="zoomOut"
label=${translateText("user_setting.zoom_out")}
description=${translateText("user_setting.zoom_out_desc")}
defaultKey="KeyQ"
.value=${this.getKeyValue("zoomOut")}
.display=${this.getKeyChar("zoomOut")}
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="zoomIn"
label=${translateText("user_setting.zoom_in")}
description=${translateText("user_setting.zoom_in_desc")}
defaultKey="KeyE"
.value=${this.getKeyValue("zoomIn")}
.display=${this.getKeyChar("zoomIn")}
@change=${this.handleKeybindChange}
></setting-keybind>
<h2
class="text-blue-200 text-xl font-bold mt-8 mb-3 border-b border-white/10 pb-2"
>
${translateText("user_setting.camera_movement")}
</h2>
<setting-keybind
action="centerCamera"
label=${translateText("user_setting.center_camera")}
description=${translateText("user_setting.center_camera_desc")}
defaultKey="KeyC"
.value=${this.getKeyValue("centerCamera")}
.display=${this.getKeyChar("centerCamera")}
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="moveUp"
label=${translateText("user_setting.move_up")}
description=${translateText("user_setting.move_up_desc")}
defaultKey="KeyW"
.value=${this.getKeyValue("moveUp")}
.display=${this.getKeyChar("moveUp")}
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="moveLeft"
label=${translateText("user_setting.move_left")}
description=${translateText("user_setting.move_left_desc")}
defaultKey="KeyA"
.value=${this.getKeyValue("moveLeft")}
.display=${this.getKeyChar("moveLeft")}
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="moveDown"
label=${translateText("user_setting.move_down")}
description=${translateText("user_setting.move_down_desc")}
defaultKey="KeyS"
.value=${this.getKeyValue("moveDown")}
.display=${this.getKeyChar("moveDown")}
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="moveRight"
label=${translateText("user_setting.move_right")}
description=${translateText("user_setting.move_right_desc")}
defaultKey="KeyD"
.value=${this.getKeyValue("moveRight")}
.display=${this.getKeyChar("moveRight")}
@change=${this.handleKeybindChange}
></setting-keybind>
`;
}
protected onOpen(): void {
this.requestUpdate();
}
}
+33 -1
View File
@@ -2,6 +2,7 @@ import { LitElement, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import "./LanguageModal";
import { LanguageModal } from "./LanguageModal";
import { formatDebugTranslation } from "./Utils";
import en from "../../resources/lang/en.json";
import metadata from "../../resources/lang/metadata.json";
@@ -104,6 +105,12 @@ export class LangSelector extends LitElement {
const cached = this.languageCache.get(lang);
if (cached) return cached;
if (lang === "debug") {
const empty: Record<string, string> = {};
this.languageCache.set(lang, empty);
return empty;
}
if (lang === "en") {
const flat = flattenTranslations(en);
this.languageCache.set(lang, flat);
@@ -213,11 +220,11 @@ export class LangSelector extends LitElement {
"o-modal",
"o-button",
"territory-patterns-modal",
"pattern-input",
"fluent-slider",
"news-modal",
"news-button",
"account-modal",
"keybinds-modal",
"stats-modal",
"flag-input-modal",
"flag-input",
@@ -236,6 +243,27 @@ export class LangSelector extends LitElement {
element.textContent = text;
});
const applyAttributeTranslation = (
dataAttr: string,
targetAttr: string,
): void => {
document.querySelectorAll(`[${dataAttr}]`).forEach((element) => {
const key = element.getAttribute(dataAttr);
if (key === null) return;
const text = this.translateText(key);
if (text === null) {
console.warn(`Translation key not found: ${key}`);
return;
}
element.setAttribute(targetAttr, text);
});
};
applyAttributeTranslation("data-i18n-title", "title");
applyAttributeTranslation("data-i18n-alt", "alt");
applyAttributeTranslation("data-i18n-aria-label", "aria-label");
applyAttributeTranslation("data-i18n-placeholder", "placeholder");
components.forEach((tag) => {
document.querySelectorAll(tag).forEach((el) => {
if (typeof (el as any).requestUpdate === "function") {
@@ -249,6 +277,10 @@ export class LangSelector extends LitElement {
key: string,
params: Record<string, string | number> = {},
): string {
if (this.currentLang === "debug") {
return formatDebugTranslation(key, params);
}
let text: string | undefined;
if (this.translations && key in this.translations) {
text = this.translations[key];
+1 -1
View File
@@ -32,7 +32,7 @@ export class LanguageModal extends BaseModal {
<div
class="h-full flex flex-col ${
this.inline
? "bg-black/40 backdrop-blur-md rounded-2xl border border-white/10 p-6"
? "bg-black/60 backdrop-blur-md rounded-2xl border border-white/10 p-6"
: "bg-[#232323] text-white"
}"
>
+73 -70
View File
@@ -1,81 +1,84 @@
export function initLayout() {
const hb = document.getElementById("hamburger-btn");
const sidebar = document.getElementById("sidebar-menu");
const backdrop = document.getElementById("mobile-menu-backdrop");
// Wait for play-page component to render before setting up hamburger menu
customElements.whenDefined("play-page").then(() => {
const hb = document.getElementById("hamburger-btn");
const sidebar = document.getElementById("sidebar-menu");
const backdrop = document.getElementById("mobile-menu-backdrop");
// Force sidebar visibility style to ensure it's not hidden by other CSS
if (sidebar && window.innerWidth < 768) {
sidebar.style.display = "flex";
}
if (!hb) {
console.error("Hamburger button not found");
return;
}
// Disable fallback inline handler now that JS is loaded
hb.onclick = null;
if (!sidebar) {
console.error("Sidebar menu not found");
return;
}
if (!backdrop) {
console.error("Mobile menu backdrop not found");
return;
}
const setMenuState = (open: boolean) => {
sidebar.classList.toggle("open", open);
backdrop.classList.toggle("open", open);
document.documentElement.classList.toggle("overflow-hidden", open);
hb.setAttribute("aria-expanded", open ? "true" : "false");
};
const closeMenu = () => setMenuState(false);
const openMenu = () => setMenuState(true);
const toggle = (e: Event) => {
e.stopPropagation();
// Only prevent default if it's a touchstart to avoid ghost clicks
if ((e as any).type === "touchstart") {
(e as Event).preventDefault();
// Force sidebar visibility style to ensure it's not hidden by other CSS
if (sidebar && window.innerWidth < 768) {
sidebar.style.display = "flex";
}
const opening = !sidebar.classList.contains("open");
if (opening) {
openMenu();
} else {
closeMenu();
if (!hb) {
console.error("Hamburger button not found");
return;
}
};
hb.addEventListener("click", toggle);
// Disable fallback inline handler now that JS is loaded
hb.onclick = null;
backdrop.addEventListener("click", closeMenu);
// Close menu when clicking a menu link or button (Mobile only)
sidebar.addEventListener("click", (e) => {
// On desktop, we want the menu to stay open unless explicitly toggled
if (window.innerWidth >= 768) return;
// If the click happened on or inside an anchor/button/menu item, close the menu
const clickedElement = (e.target as Element).closest
? (e.target as Element).closest(
'a, button, [role="menuitem"], .nav-menu-item',
)
: null;
if (clickedElement) {
closeMenu();
if (!sidebar) {
console.error("Sidebar menu not found");
return;
}
});
// Close on Escape (Mobile only)
document.addEventListener("keydown", (e) => {
if (window.innerWidth >= 768) return;
if (e.key === "Escape" && sidebar.classList.contains("open")) {
closeMenu();
if (!backdrop) {
console.error("Mobile menu backdrop not found");
return;
}
const setMenuState = (open: boolean) => {
sidebar.classList.toggle("open", open);
backdrop.classList.toggle("open", open);
document.documentElement.classList.toggle("overflow-hidden", open);
hb.setAttribute("aria-expanded", open ? "true" : "false");
};
const closeMenu = () => setMenuState(false);
const openMenu = () => setMenuState(true);
const toggle = (e: Event) => {
e.stopPropagation();
// Only prevent default if it's a touchstart to avoid ghost clicks
if ((e as any).type === "touchstart") {
(e as Event).preventDefault();
}
const opening = !sidebar.classList.contains("open");
if (opening) {
openMenu();
} else {
closeMenu();
}
};
hb.addEventListener("click", toggle);
backdrop.addEventListener("click", closeMenu);
// Close menu when clicking a menu link or button (Mobile only)
sidebar.addEventListener("click", (e) => {
// On desktop, we want the menu to stay open unless explicitly toggled
if (window.innerWidth >= 768) return;
// If the click happened on or inside an anchor/button/menu item, close the menu
const clickedElement = (e.target as Element).closest
? (e.target as Element).closest(
'a, button, [role="menuitem"], .nav-menu-item',
)
: null;
if (clickedElement) {
closeMenu();
}
});
// Close on Escape (Mobile only)
document.addEventListener("keydown", (e) => {
if (window.innerWidth >= 768) return;
if (e.key === "Escape" && sidebar.classList.contains("open")) {
closeMenu();
}
});
});
}
+150 -78
View File
@@ -23,7 +23,6 @@ import { GutterAds } from "./GutterAds";
import { HelpModal } from "./HelpModal";
import { HostLobbyModal as HostPrivateLobbyModal } from "./HostLobbyModal";
import { JoinPrivateLobbyModal } from "./JoinPrivateLobbyModal";
import "./KeybindsModal";
import "./LangSelector";
import { LangSelector } from "./LangSelector";
import { initLayout } from "./Layout";
@@ -31,6 +30,7 @@ import "./Matchmaking";
import { MatchmakingModal } from "./Matchmaking";
import { initNavigation } from "./Navigation";
import "./NewsModal";
import "./PatternInput";
import "./PublicLobby";
import { PublicLobby } from "./PublicLobby";
import { SinglePlayerModal } from "./SinglePlayerModal";
@@ -44,7 +44,17 @@ import {
import { UserSettingModal } from "./UserSettingModal";
import "./UsernameInput";
import { UsernameInput } from "./UsernameInput";
import { incrementGamesPlayed, isInIframe } from "./Utils";
import {
getDiscordAvatarUrl,
incrementGamesPlayed,
isInIframe,
translateText,
} from "./Utils";
import "./components/DesktopNavBar";
import "./components/Footer";
import "./components/MainLayout";
import "./components/MobileNavBar";
import "./components/PlayPage";
import "./components/baseComponents/Button";
import "./components/baseComponents/Modal";
import "./styles.css";
@@ -54,6 +64,100 @@ import "./styles/layout/container.css";
import "./styles/layout/header.css";
import "./styles/modal/chat.css";
function updateAccountNavButton(userMeResponse: UserMeResponse | false) {
const button = document.getElementById("nav-account-button");
if (!button) return;
const avatarEl = document.getElementById("nav-account-avatar") as
| (HTMLImageElement & { _navToken?: symbol })
| null;
const personIconEl = document.getElementById(
"nav-account-person-icon",
) as SVGElement | null;
const emailBadgeEl = document.getElementById(
"nav-account-email-badge",
) as HTMLElement | null;
const signInTextEl = document.getElementById(
"nav-account-signin-text",
) as HTMLSpanElement | null;
// Unique token for this update call
const navToken = Symbol();
if (avatarEl) avatarEl._navToken = navToken;
const showAvatar = (src: string, alt?: string) => {
if (avatarEl) {
avatarEl.alt = alt ?? translateText("main.discord_avatar_alt");
// If the avatar fails to load (bad URL / CDN issue / offline), fall back
// to the default sign-in UI instead of leaving a broken image.
avatarEl.onerror = () => {
// Only handle if this is the latest update
if (avatarEl._navToken !== navToken) return;
avatarEl.src = "";
// If the user is still logged in via email, show the email badge state.
const email =
userMeResponse !== false ? userMeResponse.user.email : undefined;
if (email) {
showEmailLoggedIn();
} else {
showSignIn();
}
};
avatarEl.onload = () => {
// Only handle if this is the latest update
if (avatarEl._navToken !== navToken) return;
// Clear error handler after a successful load.
avatarEl.onerror = null;
};
avatarEl.src = src;
avatarEl.classList.remove("hidden");
}
personIconEl?.classList.add("hidden");
emailBadgeEl?.classList.add("hidden");
signInTextEl?.classList.add("hidden");
button?.classList.remove("border", "border-white/20");
};
const showSignIn = () => {
avatarEl?.classList.add("hidden");
personIconEl?.classList.remove("hidden");
emailBadgeEl?.classList.add("hidden");
signInTextEl?.classList.remove("hidden");
// Restore border when showing signin state
button?.classList.add("border", "border-white/20");
};
const showEmailLoggedIn = () => {
avatarEl?.classList.add("hidden");
personIconEl?.classList.remove("hidden");
emailBadgeEl?.classList.remove("hidden");
signInTextEl?.classList.add("hidden");
button?.classList.add("border", "border-white/20");
};
const discord =
userMeResponse !== false ? userMeResponse.user.discord : undefined;
if (discord && avatarEl) {
const avatarAlt = translateText("main.user_avatar_alt", {
username: discord.username,
});
const url = getDiscordAvatarUrl(discord);
if (url) {
showAvatar(url, avatarAlt);
return;
}
}
const email =
userMeResponse !== false ? userMeResponse.user.email : undefined;
if (email) {
showEmailLoggedIn();
return;
}
showSignIn();
}
declare global {
interface Window {
turnstile: any;
@@ -129,6 +233,10 @@ class Client {
// the user joins a lobby.
this.turnstileTokenPromise = getTurnstileToken();
// Wait for components to render before setting version
await customElements.whenDefined("mobile-nav-bar");
await customElements.whenDefined("desktop-nav-bar");
const versionElements = document.querySelectorAll(
"#game-version, .game-version-display",
);
@@ -233,25 +341,13 @@ class Client {
console.warn("Flag input modal element not found");
}
// Wait for the flag-input component to be fully ready
customElements.whenDefined("flag-input").then(() => {
// Use a small delay to ensure the component has rendered
setTimeout(() => {
const flagButton = document.querySelector(
"#flag-input-component #flag-input_",
);
if (!flagButton) {
console.warn("Flag button not found inside component");
return;
// Attach listener to any flag-input component (desktop or potentially others)
document.querySelectorAll("flag-input").forEach((flagInput) => {
flagInput.addEventListener("flag-input-click", () => {
if (flagInputModal && flagInputModal instanceof FlagInputModal) {
flagInputModal.open();
}
flagButton.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
if (flagInputModal && flagInputModal instanceof FlagInputModal) {
flagInputModal.open();
}
});
}, 100);
});
});
this.patternsModal = document.getElementById(
@@ -263,49 +359,27 @@ class Client {
) {
console.warn("Territory patterns modal element not found");
}
const patternButton = document.getElementById(
"territory-patterns-input-preview-button",
);
if (isInIframe() && patternButton) {
patternButton.style.display = "none";
}
// Move button to desktop wrapper on large screens
const desktopWrapper = document.getElementById(
"territory-patterns-preview-desktop-wrapper",
);
if (desktopWrapper && patternButton) {
const moveButtonBasedOnScreenSize = () => {
if (window.innerWidth >= 1024) {
// Desktop: move to wrapper
if (
patternButton.parentElement?.id !==
"territory-patterns-preview-desktop-wrapper"
) {
patternButton.className =
"w-full h-[60px] border border-white/20 bg-white/5 hover:bg-white/10 active:bg-white/20 rounded-lg cursor-pointer focus:outline-none transition-all duration-200 hover:scale-105 overflow-hidden";
patternButton.style.backgroundSize = "auto 100%";
patternButton.style.backgroundRepeat = "repeat-x";
desktopWrapper.appendChild(patternButton);
}
} else {
// Mobile: move back to bar
const mobileParent = document.querySelector(".lg\\:col-span-9.flex");
if (
mobileParent &&
patternButton.parentElement?.id ===
"territory-patterns-preview-desktop-wrapper"
) {
patternButton.className =
"aspect-square h-[40px] sm:h-[50px] lg:hidden border border-white/20 bg-white/5 hover:bg-white/10 active:bg-white/20 rounded-lg cursor-pointer focus:outline-none transition-all duration-200 hover:scale-105 overflow-hidden shrink-0";
patternButton.style.backgroundSize = "";
patternButton.style.backgroundRepeat = "";
mobileParent.appendChild(patternButton);
// Attach listener to any pattern-input component
document.querySelectorAll("pattern-input").forEach((patternInput) => {
patternInput.addEventListener("pattern-input-click", () => {
// Open the Store page which contains the patterns UI
window.showPage?.("page-item-store");
const skinStoreModal = document.getElementById(
"page-item-store",
) as HTMLElement & { open?: (opts: any) => void };
if (skinStoreModal) {
skinStoreModal.classList.remove("hidden");
if (typeof skinStoreModal.open === "function") {
skinStoreModal.open({ showOnlyOwned: true });
}
}
};
moveButtonBasedOnScreenSize();
window.addEventListener("resize", moveButtonBasedOnScreenSize);
});
});
if (isInIframe()) {
const mobilePat = document.getElementById("pattern-input-mobile");
if (mobilePat) mobilePat.style.display = "none";
}
if (
@@ -314,13 +388,17 @@ class Client {
) {
console.warn("Territory patterns modal element not found");
}
if (patternButton === null)
throw new Error("territory-patterns-input-preview-button");
this.patternsModal.previewButton = patternButton;
// We no longer need to manually manage the preview button as PatternInput handles it component-side.
// However, we still want to ensure the modal can be opened.
// The setupPatternInput above handles the click event for the new buttons.
this.patternsModal.refresh();
// Listen for pattern selection to update preview button
// Listen for pattern selection to update any other listeners if needed,
// though PatternInput handles its own updates via window event.
this.patternsModal.addEventListener("pattern-selected", () => {
this.patternsModal.refresh();
// PatternInput components will update themselves.
});
window.addEventListener("showPage", (e: any) => {
@@ -331,19 +409,6 @@ class Client {
}
});
patternButton.addEventListener("click", () => {
window.showPage?.("page-item-store");
const skinStoreModal = document.getElementById(
"page-item-store",
) as HTMLElement & { open?: (opts: any) => void };
if (skinStoreModal) {
skinStoreModal.classList.remove("hidden");
if (typeof skinStoreModal.open === "function") {
skinStoreModal.open({ showOnlyOwned: true });
}
}
});
this.tokenLoginModal = document.querySelector(
"token-login",
) as TokenLoginModal;
@@ -397,6 +462,12 @@ class Client {
});
}
if (matchmakingButtonLoggedOut) {
matchmakingButtonLoggedOut.addEventListener("click", () => {
window.showPage?.("page-account");
});
}
const onUserMe = async (userMeResponse: UserMeResponse | false) => {
// Check if user has actual authentication (discord or email), not just a publicId
const loggedIn =
@@ -407,6 +478,7 @@ class Client {
(userMeResponse.user.discord !== undefined ||
userMeResponse.user.email !== undefined);
updateMatchmakingButton(loggedIn);
updateAccountNavButton(userMeResponse);
document.dispatchEvent(
new CustomEvent("userMeResponse", {
detail: userMeResponse,
+1 -1
View File
@@ -48,7 +48,7 @@ export class MatchmakingModal extends BaseModal {
const content = html`
<div
class="h-full flex flex-col ${this.inline
? "bg-black/40 backdrop-blur-md rounded-2xl border border-white/10"
? "bg-black/60 backdrop-blur-md rounded-2xl border border-white/10"
: ""}"
>
<div
+83 -46
View File
@@ -1,26 +1,39 @@
export function initNavigation() {
const showPage = (pageId: string) => {
// Hide all pages
document.querySelectorAll(".page-content").forEach((el) => {
el.classList.add("hidden");
el.classList.remove("block");
});
document.getElementById("page-play")?.classList.add("hidden");
(window as any).currentPageId = pageId;
const target = document.getElementById(pageId);
if (target) {
target.classList.remove("hidden");
// Modals need block display explicitly
if (target.classList.contains("page-content")) {
target.classList.add("block");
}
// Hide only the currently visible modal
const visibleModal = document.querySelector(".page-content:not(.hidden)");
if (visibleModal) {
visibleModal.classList.add("hidden");
visibleModal.classList.remove("block");
}
// If the target itself is a modal component with inline attribute, open it
if (
target.hasAttribute("inline") &&
typeof (target as any).open === "function"
) {
(target as any).open();
// Handle page-play separately (it's not a page-content element)
const pagePlayEl = document.getElementById("page-play");
if (pageId === "page-play") {
pagePlayEl?.classList.remove("hidden");
} else {
pagePlayEl?.classList.add("hidden");
}
// Show the target page if it's a modal
if (pageId !== "page-play") {
const target = document.getElementById(pageId);
if (target) {
target.classList.remove("hidden");
// Modals need block display explicitly
if (target.classList.contains("page-content")) {
target.classList.add("block");
}
// If the target itself is a modal component with inline attribute, open it
if (
target.hasAttribute("inline") &&
typeof (target as any).open === "function"
) {
(target as any).open();
}
}
}
@@ -39,39 +52,63 @@ export function initNavigation() {
window.showPage = showPage;
document.querySelectorAll(".nav-menu-item[data-page]").forEach((el) => {
el.addEventListener("click", () => {
const pageId = (el as HTMLElement).dataset.page;
// Use event delegation for navigation items (they may be inside Lit components)
document.addEventListener("click", (e) => {
const target = (e.target as HTMLElement).closest(
".nav-menu-item[data-page]",
);
if (target) {
const pageId = (target as HTMLElement).dataset.page;
if (pageId) showPage(pageId);
});
}
});
// Handle clicks on main container to close open modals (navigate back)
const mainEl = document.querySelector("main");
if (mainEl) {
mainEl.addEventListener("click", (e: Event) => {
const target = e.target as HTMLElement;
const isPlayPageHidden = document
.getElementById("page-play")
?.classList.contains("hidden");
// Wait for main-layout component to render before setting up click handler
customElements.whenDefined("main-layout").then(() => {
// Handle clicks on main container to close open modals (navigate back)
const mainEl = document.querySelector("main");
// Only proceed if we are NOT on the play page (meaning a modal page is open)
if (isPlayPageHidden) {
// If clicking on the main container directly (e.g. padding/background)
// or the max-width wrapper div directly
const wrapper = mainEl.firstElementChild as HTMLElement;
if (target === mainEl || (wrapper && target === wrapper)) {
showPage("page-play");
if (mainEl) {
mainEl.addEventListener("click", (e: Event) => {
const target = e.target as HTMLElement;
const isPlayPageHidden = document
.getElementById("page-play")
?.classList.contains("hidden");
// Only proceed if we are NOT on the play page (meaning a modal page is open)
if (isPlayPageHidden) {
// Close modal if clicking on main element itself, or directly on a page-content element
const isOnMain = target === mainEl;
const isOnPageContent = target.classList.contains("page-content");
if (isOnMain || isOnPageContent) {
// Find the open modal and call its close() method instead of showPage directly
// This ensures proper cleanup (like websocket disconnection)
const openModal = document.querySelector(
".page-content:not(.hidden)",
) as any;
if (openModal && typeof openModal.close === "function") {
// Call leaveLobby or closeAndLeave first if it exists (for lobby modals)
if (typeof openModal.leaveLobby === "function") {
openModal.leaveLobby();
} else if (typeof openModal.closeAndLeave === "function") {
openModal.closeAndLeave();
return; // closeAndLeave already calls close()
}
openModal.close();
} else {
showPage("page-play");
}
}
}
}
});
}
});
}
});
// Set default active if not set
const initialPage = document.querySelector(
'.nav-menu-item[data-page="page-play"]',
);
if (initialPage && !initialPage.classList.contains("active")) {
// Set default page to play if no menu item is active
const anyActive = document.querySelector(".nav-menu-item.active");
if (!anyActive) {
showPage("page-play");
}
}
+1 -1
View File
@@ -18,7 +18,7 @@ export class NewsModal extends BaseModal {
const content = html`
<div
class="h-full flex flex-col ${this.inline
? "bg-black/40 backdrop-blur-md rounded-2xl border border-white/10"
? "bg-black/60 backdrop-blur-md rounded-2xl border border-white/10"
: ""}"
>
<div
+140
View File
@@ -0,0 +1,140 @@
import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { Cosmetics } from "../core/CosmeticSchemas";
import { UserSettings } from "../core/game/UserSettings";
import { PlayerPattern } from "../core/Schemas";
import { renderPatternPreview } from "./components/PatternButton";
import { fetchCosmetics } from "./Cosmetics";
import { translateText } from "./Utils";
// Module-level cosmetics cache to avoid refetching on every component mount
let cosmeticsCache: Promise<Cosmetics | null> | null = null;
function getCachedCosmetics(): Promise<Cosmetics | null> {
if (!cosmeticsCache) {
const fetchPromise = fetchCosmetics();
cosmeticsCache = fetchPromise.catch((err) => {
cosmeticsCache = null;
throw err;
});
}
return cosmeticsCache;
}
@customElement("pattern-input")
export class PatternInput extends LitElement {
@state() public pattern: PlayerPattern | null = null;
@state() public selectedColor: string | null = null;
@state() private isLoading: boolean = true;
@property({ type: Boolean, attribute: "show-select-label" })
public showSelectLabel: boolean = false;
private userSettings = new UserSettings();
private cosmetics: Cosmetics | null = null;
private _abortController: AbortController | null = null;
private _onPatternSelected = () => {
this.updateFromSettings();
};
private updateFromSettings() {
this.selectedColor = this.userSettings.getSelectedColor() ?? null;
if (this.cosmetics) {
this.pattern = this.userSettings.getSelectedPatternName(this.cosmetics);
} else {
this.pattern = null;
}
}
private onInputClick(e: Event) {
e.preventDefault();
e.stopPropagation();
this.dispatchEvent(
new CustomEvent("pattern-input-click", {
bubbles: true,
composed: true,
}),
);
}
async connectedCallback() {
super.connectedCallback();
this._abortController = new AbortController();
this.isLoading = true;
const cosmetics = await getCachedCosmetics();
if (!this.isConnected) return;
this.cosmetics = cosmetics;
this.updateFromSettings();
if (!this.isConnected) return;
this.isLoading = false;
window.addEventListener("pattern-selected", this._onPatternSelected, {
signal: this._abortController.signal,
});
}
disconnectedCallback() {
super.disconnectedCallback();
if (this._abortController) {
this._abortController.abort();
this._abortController = null;
}
}
createRenderRoot() {
return this;
}
render() {
const isDefault = this.pattern === null && this.selectedColor === null;
const showSelect = this.showSelectLabel && isDefault;
const buttonTitle = translateText("territory_patterns.title");
// Show loading state
if (this.isLoading) {
return html`
<button
id="pattern-input"
class="pattern-btn m-0 border-0 !p-0 w-full h-full flex cursor-pointer justify-center items-center focus:outline-none focus:ring-0 bg-slate-900/80 rounded-lg overflow-hidden"
disabled
>
<span
class="w-6 h-6 border-4 border-blue-500/30 border-t-blue-500 rounded-full animate-spin"
></span>
</button>
`;
}
let previewContent;
if (this.pattern) {
previewContent = renderPatternPreview(this.pattern, 128, 128);
} else {
previewContent = renderPatternPreview(null, 128, 128);
}
return html`
<button
id="pattern-input"
class="pattern-btn m-0 border-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-slate-900/80 hover:bg-slate-800/80 active:bg-slate-800/90 rounded-lg overflow-hidden"
title=${buttonTitle}
@click=${this.onInputClick}
>
<span
class=${showSelect
? "hidden"
: "w-full h-full overflow-hidden flex items-center justify-center [&>img]:object-cover [&>img]:w-full [&>img]:h-full"}
>
${!showSelect ? previewContent : null}
</span>
${showSelect
? html`<span
class="text-[10px] font-black text-white/40 uppercase leading-none break-words w-full text-center px-1"
>
${translateText("territory_patterns.select_skin")}
</span>`
: null}
</button>
`;
}
}
+104 -91
View File
@@ -26,7 +26,7 @@ export class PublicLobby extends LitElement {
private joiningInterval: number | null = null;
private currLobby: GameInfo | null = null;
private debounceDelay: number = 750;
private debounceDelay: number = 150;
private lobbyIDToStart = new Map<GameID, number>();
private lobbySocket = new PublicLobbySocket((lobbies) =>
this.handleLobbiesUpdate(lobbies),
@@ -124,111 +124,124 @@ export class PublicLobby extends LitElement {
<button
@click=${() => this.lobbyClicked(lobby)}
?disabled=${this.isButtonDebounced}
class="group relative isolate flex flex-col w-full h-80 lg:h-96 overflow-hidden rounded-2xl transition-all duration-300 ${this
class="group relative isolate flex flex-col w-full h-80 lg:h-96 overflow-hidden rounded-2xl transition-all duration-200 bg-[#3d7bab] ${this
.isLobbyHighlighted
? "ring-2 ring-blue-600 scale-[1.01] opacity-70"
: "hover:scale-[1.01] hover:border-white/30"} ${this.isButtonDebounced
? "opacity-70 cursor-not-allowed"
: "hover:scale-[1.01]"} active:scale-[0.98] ${this.isButtonDebounced
? "cursor-not-allowed"
: ""}"
>
<!-- Map Image Area -->
<div class="flex-1 w-full relative overflow-hidden bg-blue-500/85">
${mapImageSrc
? html`<img
src="${mapImageSrc}"
alt="${lobby.gameConfig.gameMap}"
class="w-full h-full object-cover filter drop-shadow-2xl"
/>`
: html`<div class="w-full h-full bg-gray-800 rounded-lg"></div>`}
</div>
<div class="font-sans w-full h-full flex flex-col">
<!-- Main card gradient - stops before text -->
<div class="absolute inset-0 pointer-events-none z-10"></div>
<!-- Content Banner -->
<div
class="relative w-full p-5 flex flex-col gap-1 text-left z-10 bg-slate-900/95 backdrop-blur-xl border-t border-white/10"
>
<div class="flex justify-between items-end w-full">
<div class="flex flex-col gap-1">
<!-- Header: Status or Join -->
<div
class="text-sm font-bold uppercase tracking-widest text-blue-400 mb-1"
<!-- Map Image Area with gradient overlay -->
<div class="flex-1 w-full relative overflow-hidden">
${mapImageSrc
? html`<img
src="${mapImageSrc}"
alt="${lobby.gameConfig.gameMap}"
class="absolute inset-0 w-full h-full object-cover object-center z-10"
/>`
: ""}
<!-- Vignette overlay for dark edges -->
<div class="pointer-events-none absolute inset-0 z-20"></div>
</div>
<!-- Mode Badge in top left -->
${fullModeLabel
? html`<span
class="absolute top-4 left-4 px-4 py-1 rounded font-bold text-sm lg:text-base uppercase tracking-widest z-30 bg-slate-800 text-white ring-1 ring-white/10 shadow-sm"
>
${this.currLobby
? isStarting
? html`<span class="text-green-400 animate-pulse"
>${translateText("public_lobby.starting_game")}</span
>`
: html`${translateText("public_lobby.waiting_for_players")}
${[0, 1, 2]
.map((i) => (i === this.joiningDotIndex ? "•" : "·"))
.join("")}`
: html`<span
class="group-hover:text-blue-300 transition-colors"
>${translateText("public_lobby.join")}</span
>`}
${fullModeLabel}
</span>`
: ""}
<!-- Timer in top right -->
${timeRemaining > 0
? html`
<span
class="absolute top-4 right-4 px-4 py-1 rounded font-bold text-sm lg:text-base tracking-widest z-30 bg-blue-600 text-white"
>
${timeDisplay}
</span>
`
: html`<span
class="absolute top-4 right-4 px-4 py-1 rounded font-bold text-sm lg:text-base uppercase tracking-widest z-30 bg-green-600 text-white"
>
${translateText("public_lobby.started")}
</span>`}
<!-- Content Banner -->
<div class="absolute bottom-0 left-0 right-0 z-20">
<!-- Modifier badges placed just above the gradient overlay -->
${modifierLabel.length > 0
? html`<div
class="absolute -top-8 left-4 z-30 flex gap-2 flex-wrap"
>
${modifierLabel.map(
(label) => html`
<span
class="px-2 py-0.5 rounded text-xs font-medium uppercase tracking-wide bg-purple-600 text-white"
>
${label}
</span>
`,
)}
</div>`
: html``}
<!-- Gradient overlay for text area - adds extra darkening -->
<div
class="absolute inset-0 bg-gradient-to-b from-black/60 to-black/90 pointer-events-none"
></div>
<div class="relative p-6 flex flex-col gap-2 text-left">
<!-- Header row: Status/Join on left, Player Count on right -->
<div class="flex items-center justify-between w-full">
<div class="text-base uppercase tracking-widest text-white">
${this.currLobby
? isStarting
? html`<span class="text-green-400 animate-pulse"
>${translateText("public_lobby.starting_game")}</span
>`
: html`<span class="text-orange-400"
>${translateText("public_lobby.waiting_for_players")}
${[0, 1, 2]
.map((i) =>
i === this.joiningDotIndex ? "•" : "·",
)
.join("")}</span
>`
: html`${translateText("public_lobby.join")}`}
</div>
<div class="flex items-center gap-2 text-white z-30">
<span class="text-base font-bold uppercase tracking-widest"
>${lobby.numClients}/${lobby.gameConfig.maxPlayers}</span
>
<svg
class="w-5 h-5 text-white"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3zM6 8a2 2 0 11-4 0 2 2 0 014 0zM16 18v-3a5.972 5.972 0 00-.75-2.906A3.005 3.005 0 0119 15v3h-3zM4.75 12.094A5.973 5.973 0 004 15v3H1v-3a3 3 0 013.75-2.906z"
></path>
</svg>
</div>
</div>
<!-- Map Name & Mode -->
<!-- Map Name - Full Width -->
<div
class="text-3xl font-black text-white leading-none tracking-tight"
class="text-2xl lg:text-3xl font-bold text-white leading-none uppercase tracking-widest w-full"
>
${translateText(
`map.${lobby.gameConfig.gameMap.toLowerCase().replace(/[\s.]+/g, "")}`,
)}
</div>
<div class="flex flex-wrap items-center gap-2 mt-2">
${fullModeLabel
? html`<span
class="px-2 py-1 rounded text-xs font-bold uppercase tracking-wider ${this
.isLobbyHighlighted
? "bg-green-500/20 text-green-300 border border-green-500/30"
: "bg-white/10 text-white border border-white/10"} backdrop-blur-sm"
>
${fullModeLabel}
</span>`
: ""}
${modifierLabel.map(
(label) =>
html`<span
class="px-2 py-1 rounded text-xs font-bold uppercase tracking-wider ${this
.isLobbyHighlighted
? "bg-green-500/20 text-green-300 border border-green-500/30"
: "bg-white/10 text-white border border-white/10"} backdrop-blur-sm"
>
${label}
</span>`,
)}
</div>
</div>
<!-- Player Count & Time -->
<div class="flex flex-col items-end gap-1">
<div class="flex items-center gap-2">
<span class="text-2xl font-bold text-white"
>${lobby.numClients}/${lobby.gameConfig.maxPlayers}</span
>
<svg
class="w-5 h-5 text-white/50"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3zM6 8a2 2 0 11-4 0 2 2 0 014 0zM16 18v-3a5.972 5.972 0 00-.75-2.906A3.005 3.005 0 0119 15v3h-3zM4.75 12.094A5.973 5.973 0 004 15v3H1v-3a3 3 0 013.75-2.906z"
></path>
</svg>
</div>
${timeRemaining > 0
? html`
<div
class="text-sm font-mono font-medium text-blue-200 bg-blue-500/20 px-2 py-0.5 rounded border border-blue-500/30"
>
${timeDisplay}
</div>
`
: html`<div
class="text-sm font-bold text-green-200 bg-green-500/20 border border-green-500/30 px-2 py-0.5 rounded uppercase tracking-wider"
>
${translateText("public_lobby.started")}
</div>`}
<!-- modifiers moved above gradient overlay -->
</div>
</div>
</div>
+2 -2
View File
@@ -129,7 +129,7 @@ export class SinglePlayerModal extends BaseModal {
render() {
const content = html`
<div
class="h-full flex flex-col bg-black/40 backdrop-blur-md rounded-2xl border border-white/10 overflow-hidden"
class="h-full flex flex-col bg-black/60 backdrop-blur-md rounded-2xl border border-white/10 overflow-hidden"
>
<!-- Header -->
<div
@@ -599,7 +599,7 @@ export class SinglePlayerModal extends BaseModal {
min="1"
max="120"
.value=${String(this.maxTimerValue ?? "")}
class="w-full text-center rounded bg-black/40 text-white text-sm font-bold border border-white/20 focus:outline-none focus:border-blue-500 p-1 my-1"
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"
aria-label=${translateText("single_modal.max_timer")}
@input=${this.handleMaxTimerValueChanges}
@keydown=${this.handleMaxTimerValueKeyDown}
+1 -1
View File
@@ -372,7 +372,7 @@ export class StatsModal extends BaseModal {
const content = html`
<div
class="h-full flex flex-col ${this.inline
? "bg-black/40 backdrop-blur-md rounded-2xl border border-white/10"
? "bg-black/60 backdrop-blur-md rounded-2xl border border-white/10"
: ""}"
>
<div
+3 -31
View File
@@ -1,5 +1,5 @@
import type { TemplateResult } from "lit";
import { html, render } from "lit";
import { html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { UserMeResponse } from "../core/ApiSchemas";
import { ColorPalette, Cosmetics, Pattern } from "../core/CosmeticSchemas";
@@ -9,7 +9,6 @@ import { hasLinkedAccount } from "./Api";
import { BaseModal } from "./components/BaseModal";
import "./components/Difficulties";
import "./components/PatternButton";
import { renderPatternPreview } from "./components/PatternButton";
import {
fetchCosmetics,
handlePurchase,
@@ -279,7 +278,7 @@ export class TerritoryPatternsModal extends BaseModal {
const content = html`
<div
class="h-full flex flex-col bg-black/40 backdrop-blur-md rounded-2xl border border-white/10 p-6"
class="h-full flex flex-col bg-black/60 backdrop-blur-md rounded-2xl border border-white/10 p-6"
>
${this.renderTabNavigation()}
<div class="overflow-y-auto pr-2 custom-scrollbar mr-1">
@@ -391,6 +390,7 @@ export class TerritoryPatternsModal extends BaseModal {
this.selectedColor = hexCode;
this.userSettings.setSelectedColor(hexCode);
this.refresh();
this.dispatchEvent(new CustomEvent("pattern-selected", { bubbles: true }));
this.close();
}
@@ -409,33 +409,5 @@ export class TerritoryPatternsModal extends BaseModal {
public async refresh() {
this.requestUpdate();
const preview = this.selectedColor
? this.renderColorPreview(this.selectedColor, 48, 48)
: renderPatternPreview(this.selectedPattern ?? null, 48, 48);
if (
this.previewButton === null ||
!document.body.contains(this.previewButton)
) {
this.previewButton = document.getElementById(
"territory-patterns-input-preview-button",
);
}
if (this.previewButton === null) return;
// Check if the element is still in the DOM to avoid lit-html errors
if (!document.body.contains(this.previewButton)) {
console.warn(
"TerritoryPatternsModal: previewButton is disconnected from DOM, skipping render",
);
return;
}
// Clear and re-render using Lit
render(preview, this.previewButton);
this.previewButton.style.padding = "4px";
this.requestUpdate();
}
}
+479 -5
View File
@@ -1,7 +1,9 @@
import { html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { translateText } from "../client/Utils";
import { formatKeyForDisplay, translateText } from "../client/Utils";
import { UserSettings } from "../core/game/UserSettings";
import "./components/baseComponents/setting/SettingKeybind";
import { SettingKeybind } from "./components/baseComponents/setting/SettingKeybind";
import "./components/baseComponents/setting/SettingNumber";
import "./components/baseComponents/setting/SettingSlider";
import "./components/baseComponents/setting/SettingToggle";
@@ -13,18 +15,203 @@ interface FlagInputModalElement extends HTMLElement {
returnTo?: string;
}
const DefaultKeybinds: Record<string, string> = {
toggleView: "Space",
buildCity: "Digit1",
buildFactory: "Digit2",
buildPort: "Digit3",
buildDefensePost: "Digit4",
buildMissileSilo: "Digit5",
buildSamLauncher: "Digit6",
buildWarship: "Digit7",
buildAtomBomb: "Digit8",
buildHydrogenBomb: "Digit9",
buildMIRV: "Digit0",
attackRatioDown: "KeyT",
attackRatioUp: "KeyY",
boatAttack: "KeyB",
groundAttack: "KeyG",
zoomOut: "KeyQ",
zoomIn: "KeyE",
centerCamera: "KeyC",
moveUp: "KeyW",
moveLeft: "KeyA",
moveDown: "KeyS",
moveRight: "KeyD",
};
@customElement("user-setting")
export class UserSettingModal extends BaseModal {
private userSettings: UserSettings = new UserSettings();
@state() private activeTab: "basic" | "keybinds" = "basic";
@state() private keySequence: string[] = [];
@state() private showEasterEggSettings = false;
@state() private keybinds: Record<
string,
{ value: string | string[]; key: string }
> = {};
connectedCallback() {
super.connectedCallback();
this.loadKeybindsFromStorage();
}
disconnectedCallback() {
window.removeEventListener("keydown", this.handleEasterEggKey);
super.disconnectedCallback();
}
private loadKeybindsFromStorage() {
const savedKeybinds = localStorage.getItem("settings.keybinds");
if (!savedKeybinds) return;
try {
const parsed = JSON.parse(savedKeybinds);
if (
typeof parsed === "object" &&
parsed !== null &&
!Array.isArray(parsed)
) {
const isValid = Object.values(parsed).every((entry) => {
if (
typeof entry !== "object" ||
entry === null ||
Array.isArray(entry)
) {
return false;
}
if (!("key" in entry) || typeof (entry as any).key !== "string") {
return false;
}
if (!("value" in entry)) {
return false;
}
const value = (entry as any).value;
if (typeof value === "string") {
return true;
}
if (Array.isArray(value)) {
return value.every((v) => typeof v === "string");
}
return false;
});
if (isValid) {
this.keybinds = parsed;
} else {
console.warn(
"Invalid keybinds structure: entries must be objects with 'key' (string) and 'value' (string or string[]) properties. Ignoring saved data.",
);
}
} else {
console.warn(
"Invalid keybinds data: expected non-array object. Ignoring saved data.",
);
}
} catch (e) {
console.warn("Invalid keybinds JSON:", e);
}
}
private handleKeybindChange(
e: CustomEvent<{
action: string;
value: string;
key: string;
prevValue?: string;
}>,
) {
const { action, value, key, prevValue } = e.detail;
const activeKeybinds: Record<string, string> = { ...DefaultKeybinds };
for (const [k, v] of Object.entries(this.keybinds)) {
const normalizedValue = Array.isArray(v.value)
? v.value[0] || ""
: v.value;
if (normalizedValue === "Null") {
delete activeKeybinds[k];
} else {
activeKeybinds[k] = normalizedValue;
}
}
const values = Object.entries(activeKeybinds)
.filter(([k]) => k !== action)
.map(([, v]) => v);
if (values.includes(value) && value !== "Null") {
const displayKey = formatKeyForDisplay(key || value);
window.dispatchEvent(
new CustomEvent("show-message", {
detail: {
message: html`
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 text-red-500 inline-block align-middle mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<span class="font-medium">
${(() => {
const message = translateText(
"user_setting.keybind_conflict_error",
{ key: displayKey },
);
const parts = message.split(displayKey);
return html`${parts[0]}<span
class="font-mono font-bold bg-white/10 px-1.5 py-0.5 rounded text-red-200 mx-1 border border-white/10"
>${displayKey}</span
>${parts[1] || ""}`;
})()}
</span>
`,
color: "red",
duration: 3000,
},
}),
);
const element = this.renderRoot.querySelector(
`setting-keybind[action="${action}"]`,
) as SettingKeybind;
if (element) {
element.value = prevValue ?? DefaultKeybinds[action] ?? "";
element.requestUpdate();
}
return;
}
this.keybinds = { ...this.keybinds, [action]: { value: value, key: key } };
localStorage.setItem("settings.keybinds", JSON.stringify(this.keybinds));
}
private getKeyValue(action: string): string | undefined {
const entry = this.keybinds[action];
if (!entry) return undefined;
const normalizedValue = Array.isArray(entry.value)
? entry.value[0] || ""
: entry.value;
if (normalizedValue === "Null") return "";
return normalizedValue || undefined;
}
private getKeyChar(action: string): string {
const entry = this.keybinds[action];
if (!entry) return "";
return entry.key || "";
}
private handleEasterEggKey = (e: KeyboardEvent) => {
if (!this.isModalOpen || this.showEasterEggSettings) return;
@@ -188,20 +375,25 @@ export class UserSettingModal extends BaseModal {
document.querySelector<FlagInputModalElement>("#flag-input-modal");
if (flagInputModal?.open) {
this.close();
flagInputModal.returnTo = "#" + (this.id || "page-options");
flagInputModal.returnTo = "#" + (this.id || "page-settings");
flagInputModal.open();
}
};
render() {
const activeContent =
this.activeTab === "basic"
? this.renderBasicSettings()
: this.renderKeybindSettings();
const content = html`
<div
class="h-full flex flex-col ${this.inline
? "bg-black/40 backdrop-blur-md rounded-2xl border border-white/10"
? "bg-black/60 backdrop-blur-md rounded-2xl border border-white/10"
: ""}"
>
<div
class="flex items-center mb-6 pb-2 border-b border-white/10 gap-2 shrink-0 p-6"
class="relative flex flex-col mb-6 border-b border-white/10 pb-4 shrink-0 p-6"
>
<div class="flex items-center gap-4 flex-1 flex-wrap">
<button
@@ -230,12 +422,33 @@ export class UserSettingModal extends BaseModal {
${translateText("user_setting.title")}
</span>
</div>
<div class="hidden md:flex items-center gap-2 justify-center mt-4">
<button
class="px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest ${this
.activeTab === "basic"
? "bg-blue-500/20 text-blue-400 border border-blue-500/30 shadow-[0_0_15px_rgba(59,130,246,0.2)]"
: "text-white/40 hover:text-white hover:bg-white/5 border border-transparent"}"
@click=${() => (this.activeTab = "basic")}
>
${translateText("user_setting.tab_basic")}
</button>
<button
class="px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest ${this
.activeTab === "keybinds"
? "bg-blue-500/20 text-blue-400 border border-blue-500/30 shadow-[0_0_15px_rgba(59,130,246,0.2)]"
: "text-white/40 hover:text-white hover:bg-white/5 border border-transparent"}"
@click=${() => (this.activeTab = "keybinds")}
>
${translateText("user_setting.tab_keybinds")}
</button>
</div>
</div>
<div
class="flex-1 overflow-y-auto scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent px-6 pb-6 mr-1"
>
<div class="flex flex-col gap-2">${this.renderBasicSettings()}</div>
<div class="flex flex-col gap-2">${activeContent}</div>
</div>
</div>
`;
@@ -260,6 +473,266 @@ export class UserSettingModal extends BaseModal {
window.removeEventListener("keydown", this.handleEasterEggKey);
}
private renderKeybindSettings() {
return html`
<h2
class="text-blue-200 text-xl font-bold mt-4 mb-3 border-b border-white/10 pb-2"
>
${translateText("user_setting.view_options")}
</h2>
<setting-keybind
action="toggleView"
label=${translateText("user_setting.toggle_view")}
description=${translateText("user_setting.toggle_view_desc")}
defaultKey="Space"
.value=${this.getKeyValue("toggleView")}
.display=${this.getKeyChar("toggleView")}
@change=${this.handleKeybindChange}
></setting-keybind>
<h2
class="text-blue-200 text-xl font-bold mt-8 mb-3 border-b border-white/10 pb-2"
>
${translateText("user_setting.build_controls")}
</h2>
<setting-keybind
action="buildCity"
label=${translateText("user_setting.build_city")}
description=${translateText("user_setting.build_city_desc")}
defaultKey="Digit1"
.value=${this.getKeyValue("buildCity")}
.display=${this.getKeyChar("buildCity")}
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="buildFactory"
label=${translateText("user_setting.build_factory")}
description=${translateText("user_setting.build_factory_desc")}
defaultKey="Digit2"
.value=${this.getKeyValue("buildFactory")}
.display=${this.getKeyChar("buildFactory")}
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="buildPort"
label=${translateText("user_setting.build_port")}
description=${translateText("user_setting.build_port_desc")}
defaultKey="Digit3"
.value=${this.getKeyValue("buildPort")}
.display=${this.getKeyChar("buildPort")}
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="buildDefensePost"
label=${translateText("user_setting.build_defense_post")}
description=${translateText("user_setting.build_defense_post_desc")}
defaultKey="Digit4"
.value=${this.getKeyValue("buildDefensePost")}
.display=${this.getKeyChar("buildDefensePost")}
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="buildMissileSilo"
label=${translateText("user_setting.build_missile_silo")}
description=${translateText("user_setting.build_missile_silo_desc")}
defaultKey="Digit5"
.value=${this.getKeyValue("buildMissileSilo")}
.display=${this.getKeyChar("buildMissileSilo")}
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="buildSamLauncher"
label=${translateText("user_setting.build_sam_launcher")}
description=${translateText("user_setting.build_sam_launcher_desc")}
defaultKey="Digit6"
.value=${this.getKeyValue("buildSamLauncher")}
.display=${this.getKeyChar("buildSamLauncher")}
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="buildWarship"
label=${translateText("user_setting.build_warship")}
description=${translateText("user_setting.build_warship_desc")}
defaultKey="Digit7"
.value=${this.getKeyValue("buildWarship")}
.display=${this.getKeyChar("buildWarship")}
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="buildAtomBomb"
label=${translateText("user_setting.build_atom_bomb")}
description=${translateText("user_setting.build_atom_bomb_desc")}
defaultKey="Digit8"
.value=${this.getKeyValue("buildAtomBomb")}
.display=${this.getKeyChar("buildAtomBomb")}
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="buildHydrogenBomb"
label=${translateText("user_setting.build_hydrogen_bomb")}
description=${translateText("user_setting.build_hydrogen_bomb_desc")}
defaultKey="Digit9"
.value=${this.getKeyValue("buildHydrogenBomb")}
.display=${this.getKeyChar("buildHydrogenBomb")}
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="buildMIRV"
label=${translateText("user_setting.build_mirv")}
description=${translateText("user_setting.build_mirv_desc")}
defaultKey="Digit0"
.value=${this.getKeyValue("buildMIRV")}
.display=${this.getKeyChar("buildMIRV")}
@change=${this.handleKeybindChange}
></setting-keybind>
<h2
class="text-blue-200 text-xl font-bold mt-8 mb-3 border-b border-white/10 pb-2"
>
${translateText("user_setting.attack_ratio_controls")}
</h2>
<setting-keybind
action="attackRatioDown"
label=${translateText("user_setting.attack_ratio_down")}
description=${translateText("user_setting.attack_ratio_down_desc")}
defaultKey="KeyT"
.value=${this.getKeyValue("attackRatioDown")}
.display=${this.getKeyChar("attackRatioDown")}
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="attackRatioUp"
label=${translateText("user_setting.attack_ratio_up")}
description=${translateText("user_setting.attack_ratio_up_desc")}
defaultKey="KeyY"
.value=${this.getKeyValue("attackRatioUp")}
.display=${this.getKeyChar("attackRatioUp")}
@change=${this.handleKeybindChange}
></setting-keybind>
<h2
class="text-blue-200 text-xl font-bold mt-8 mb-3 border-b border-white/10 pb-2"
>
${translateText("user_setting.attack_keybinds")}
</h2>
<setting-keybind
action="boatAttack"
label=${translateText("user_setting.boat_attack")}
description=${translateText("user_setting.boat_attack_desc")}
defaultKey="KeyB"
.value=${this.getKeyValue("boatAttack")}
.display=${this.getKeyChar("boatAttack")}
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="groundAttack"
label=${translateText("user_setting.ground_attack")}
description=${translateText("user_setting.ground_attack_desc")}
defaultKey="KeyG"
.value=${this.getKeyValue("groundAttack")}
.display=${this.getKeyChar("groundAttack")}
@change=${this.handleKeybindChange}
></setting-keybind>
<h2
class="text-blue-200 text-xl font-bold mt-8 mb-3 border-b border-white/10 pb-2"
>
${translateText("user_setting.zoom_controls")}
</h2>
<setting-keybind
action="zoomOut"
label=${translateText("user_setting.zoom_out")}
description=${translateText("user_setting.zoom_out_desc")}
defaultKey="KeyQ"
.value=${this.getKeyValue("zoomOut")}
.display=${this.getKeyChar("zoomOut")}
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="zoomIn"
label=${translateText("user_setting.zoom_in")}
description=${translateText("user_setting.zoom_in_desc")}
defaultKey="KeyE"
.value=${this.getKeyValue("zoomIn")}
.display=${this.getKeyChar("zoomIn")}
@change=${this.handleKeybindChange}
></setting-keybind>
<h2
class="text-blue-200 text-xl font-bold mt-8 mb-3 border-b border-white/10 pb-2"
>
${translateText("user_setting.camera_movement")}
</h2>
<setting-keybind
action="centerCamera"
label=${translateText("user_setting.center_camera")}
description=${translateText("user_setting.center_camera_desc")}
defaultKey="KeyC"
.value=${this.getKeyValue("centerCamera")}
.display=${this.getKeyChar("centerCamera")}
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="moveUp"
label=${translateText("user_setting.move_up")}
description=${translateText("user_setting.move_up_desc")}
defaultKey="KeyW"
.value=${this.getKeyValue("moveUp")}
.display=${this.getKeyChar("moveUp")}
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="moveLeft"
label=${translateText("user_setting.move_left")}
description=${translateText("user_setting.move_left_desc")}
defaultKey="KeyA"
.value=${this.getKeyValue("moveLeft")}
.display=${this.getKeyChar("moveLeft")}
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="moveDown"
label=${translateText("user_setting.move_down")}
description=${translateText("user_setting.move_down_desc")}
defaultKey="KeyS"
.value=${this.getKeyValue("moveDown")}
.display=${this.getKeyChar("moveDown")}
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="moveRight"
label=${translateText("user_setting.move_right")}
description=${translateText("user_setting.move_right_desc")}
defaultKey="KeyD"
.value=${this.getKeyValue("moveRight")}
.display=${this.getKeyChar("moveRight")}
@change=${this.handleKeybindChange}
></setting-keybind>
`;
}
private renderBasicSettings() {
return html`
<!-- 🚩 Flag Selector -->
@@ -450,6 +923,7 @@ export class UserSettingModal extends BaseModal {
protected onOpen(): void {
window.addEventListener("keydown", this.handleEasterEggKey);
this.loadKeybindsFromStorage();
}
public open() {
+2 -2
View File
@@ -63,7 +63,7 @@ export class UsernameInput extends LitElement {
@input=${this.handleClanTagChange}
placeholder="${translateText("username.tag")}"
maxlength="5"
class="w-20 bg-transparent border-b border-white/20 text-white placeholder-white/30 text-xl font-bold text-center focus:outline-none focus:border-white/50 transition-colors uppercase shrink-0"
class="w-[6rem] bg-transparent border-b border-white/20 text-white placeholder-white/30 text-xl font-bold text-center focus:outline-none focus:border-white/50 transition-colors uppercase shrink-0"
/>
<input
type="text"
@@ -71,7 +71,7 @@ export class UsernameInput extends LitElement {
@input=${this.handleUsernameChange}
placeholder="${translateText("username.enter_username")}"
maxlength="${MAX_USERNAME_LENGTH}"
class="flex-1 min-w-0 bg-transparent border-0 text-white placeholder-white/30 text-2xl font-bold text-left focus:outline-none focus:ring-0 focus:bg-white/5 transition-colors overflow-x-auto whitespace-nowrap text-ellipsis pr-2"
class="flex-1 min-w-0 bg-transparent border-0 text-white placeholder-white/30 text-2xl font-bold text-left focus:outline-none focus:ring-0 transition-colors overflow-x-auto whitespace-nowrap text-ellipsis pr-2"
/>
</div>
${this.validationError
+49 -1
View File
@@ -1,6 +1,6 @@
import IntlMessageFormat from "intl-messageformat";
import { MessageType } from "../core/game/Game";
import { LangSelector } from "./LangSelector";
import type { LangSelector } from "./LangSelector";
export function renderDuration(totalSeconds: number): string {
if (totalSeconds <= 0) return "0s";
@@ -61,6 +61,12 @@ export function renderNumber(
}
}
export function formatPercentage(value: number): string {
const perc = value * 100;
if (Number.isNaN(perc)) return "0%";
return perc.toFixed(1) + "%";
}
/**
* Formats a keyboard key code for user-friendly display.
* Handles empty values, spaces, and normalizes key codes like "Digit1" and "KeyA".
@@ -144,6 +150,18 @@ export function generateCryptoRandomUUID(): string {
);
}
export function formatDebugTranslation(
key: string,
params: Record<string, string | number>,
): string {
const entries = Object.entries(params);
if (entries.length === 0) return key;
const serializedParams = entries
.map(([paramKey, value]) => `${paramKey}=${String(value)}`)
.join(",");
return `${key}::${serializedParams}`;
}
export const translateText = (
key: string,
params: Record<string, string | number> = {},
@@ -158,6 +176,10 @@ export const translateText = (
return key;
}
if (langSelector.currentLang === "debug") {
return formatDebugTranslation(key, params);
}
if (
!langSelector.translations ||
Object.keys(langSelector.translations).length === 0
@@ -371,3 +393,29 @@ export async function getSvgAspectRatio(src: string): Promise<number | null> {
return null;
}
export function getDiscordAvatarUrl(user: {
id: string;
avatar: string | null;
discriminator?: string;
}): string | null {
if (user.avatar) {
// - id is a Discord numeric string
// - avatar is a hash, optionally prefixed with "a_" for animated avatars
const validId = /^\d+$/.test(user.id);
const validAvatar =
/^[a-f0-9]+$/.test(user.avatar) || /^a_[a-f0-9]+$/.test(user.avatar);
if (validId && validAvatar) {
const extension = user.avatar.startsWith("a_") ? "gif" : "png";
return `https://cdn.discordapp.com/avatars/${encodeURIComponent(user.id)}/${encodeURIComponent(user.avatar)}.${extension}?size=64`;
}
}
if (user.discriminator !== undefined) {
const idx = Number(user.discriminator) % 5;
return `https://cdn.discordapp.com/embed/avatars/${idx}.png`;
}
return null;
}
+187
View File
@@ -0,0 +1,187 @@
import { LitElement, html } from "lit";
import { customElement } from "lit/decorators.js";
@customElement("desktop-nav-bar")
export class DesktopNavBar extends LitElement {
createRenderRoot() {
return this;
}
connectedCallback() {
super.connectedCallback();
window.addEventListener("showPage", this._onShowPage);
const current = (window as any).currentPageId;
if (current) {
// Wait for render
this.updateComplete.then(() => {
this._updateActiveState(current);
});
}
}
disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener("showPage", this._onShowPage);
}
private _onShowPage = (e: Event) => {
const pageId = (e as CustomEvent).detail;
this._updateActiveState(pageId);
};
private _updateActiveState(pageId: string) {
this.querySelectorAll(".nav-menu-item").forEach((el) => {
if ((el as HTMLElement).dataset.page === pageId) {
el.classList.add("active");
} else {
el.classList.remove("active");
}
});
}
render() {
return html`
<nav
class="hidden lg:flex w-full bg-slate-950/70 backdrop-blur-md border-b border-white/10 items-center justify-center gap-8 py-4 shrink-0 transition-opacity z-50 relative"
>
<div class="flex flex-col items-center justify-center">
<div class="h-8 text-[#2563eb]">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 1364 259"
fill="currentColor"
class="h-full w-auto drop-shadow-[0_0_10px_rgba(37,99,235,0.4)]"
>
<path
d="M0,174V51h15.24v-17.14h16.81v-16.98h16.96V0h1266v17.23h17.13v16.81h16.98v16.96h14.88v123h-15.13v17.08h-17.08v17.08h-16.9v17.04H324.9v16.86h-16.9v16.95h-102v-17.12h-17.07v-17.05H48.73v-17.05h-16.89v-16.89H14.94v-16.89H0ZM1297.95,17.35H65.9v16.7h-17.08v17.08h-14.5v123.08h14.85v16.9h17.08v17.08h139.9v17.08h17.08v16.36h67.9v-16.72h17.08v-17.07h989.88v-17.07h17.08v-16.9h14.44V50.8h-14.75v-17.08h-16.9v-16.37Z"
/>
<path
d="M189.1,154.78v17.07h-16.9v16.75h-51.07v-16.42h-16.9v-17.07h-16.97v-84.88h16.63v-17.07h16.9v-16.84h51.07v16.5h17.07v17.07h16.7v84.89h-16.54ZM137.87,53.1v17.15h-16.6v84.86h16.97v16.61h16.89v-16.97h16.6v-84.86h-16.97v-16.79h-16.89Z"
/>
<path
d="M273.91,104.06v-16.73h50.92v16.45h16.85v68.05h-16.44v17.06h-50.96v16.88h16.4v16.96h-67.28v-16.61h16.33v-101.86h-16.38v-16.98h33.4v16.63c6.12,0,11.72,0,17.31,0,0,22.56,0,45.13,0,67.75h33.59v-67.61h-33.73Z"
/>
<path
d="M631.12,188.64v-16.36h16.53V53.2h-16.25v-16.86h118.33v33.29h-16.65v-16.36h-50.72v50.44h33.36v-16.35h16.99v50.25h-16.6v-16.33h-33.73v50.65h16.37v16.72h-67.63Z"
/>
<path
d="M596.78,103.8v84.94h-33.54v-84.39h-34.03v84.25h-33.85v-101.29h84.5v16.49h16.93Z"
/>
<path
d="M1107.12,188.71v-84.34h-34.03v84.37h-33.7v-101.41h84.42v16.41h16.86v84.96h-33.54Z"
/>
<path
d="M988.1,171.78v16.87h-67.88v-16.38h-16.87v-68.06h16.38v-16.87h68.06v16.38h16.87v68.06h-16.55ZM970.78,104.35h-33.39v67.38h33.39v-67.38Z"
/>
<path
d="M460.77,155.38v16.49h-16.58v16.83h-68.05v-16.5h-16.83v-68.05h16.49v-16.83h68.05v16.49h16.83v34.06h-67.31v33.82h33.47v-16.31h33.92ZM393.39,104.18v16.56h33.3v-16.56h-33.3Z"
/>
<path
d="M1209.13,172h-16.9v-67.9h-16.96v-16.9h16.68v-17.08h16.9v-16.82h16.9v33.58h50.98v16.91h-50.4v67.96h16.48v-16.43h50.95v16.54h-16.55v16.76h-68.08v-16.6Z"
/>
<path
d="M834.91,120.94v16.96h-16.65v33.88h16.41v16.96h-67.29v-16.63h16.34v-67.87h-16.4v-16.97h50.42v33.81h17.3l-.14-.14Z"
/>
<path
d="M835.05,121.08v-33.75h33.76v16.43h16.85v33.96h-33.43v-16.79c-6.13,0-11.73,0-17.32,0,0,0,.14.14.14.14Z"
/>
</svg>
</div>
<div
id="game-version"
class="l-header__highlightText text-center"
></div>
</div>
<!-- Desktop Navigation Menu Items -->
<button
class="nav-menu-item text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
data-page="page-play"
data-i18n="main.play"
></button>
<button
class="nav-menu-item text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
data-page="page-news"
data-i18n="main.news"
></button>
<button
class="nav-menu-item text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500 relative"
data-page="page-item-store"
data-i18n="main.store"
></button>
<button
class="nav-menu-item text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
data-page="page-settings"
data-i18n="main.settings"
></button>
<button
class="nav-menu-item text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
data-page="page-stats"
data-i18n="main.stats"
></button>
<button
class="nav-menu-item text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
data-page="page-help"
data-i18n="main.help"
></button>
<lang-selector></lang-selector>
<button
id="nav-account-button"
class="nav-menu-item relative h-10 rounded-full overflow-hidden flex items-center justify-center gap-2 px-3 bg-transparent border border-white/20 text-white/80 hover:text-white cursor-pointer transition-colors [&.active]:text-white"
data-page="page-account"
data-i18n-aria-label="main.account"
data-i18n-title="main.account"
>
<img
id="nav-account-avatar"
class="hidden w-8 h-8 rounded-full object-cover"
alt=""
data-i18n-alt="main.discord_avatar_alt"
referrerpolicy="no-referrer"
/>
<svg
id="nav-account-person-icon"
class="w-5 h-5"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M20 21a8 8 0 0 0-16 0" />
<path d="M12 13a4 4 0 1 0-4-4 4 4 0 0 0 4 4Z" />
</svg>
<span
id="nav-account-email-badge"
class="hidden absolute bottom-1 right-1 w-4 h-4 rounded-full bg-slate-900/80 border border-white/20 flex items-center justify-center"
aria-hidden="true"
>
<svg
class="w-2.5 h-2.5 text-white/80"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M4 4h16v16H4z" opacity="0" />
<path d="M4 6h16v12H4z" />
<path d="m4 7 8 6 8-6" />
</svg>
</span>
<span
id="nav-account-signin-text"
class="text-xs font-bold tracking-widest"
data-i18n="main.sign_in"
>
</span>
</button>
</nav>
`;
}
}
+1 -1
View File
@@ -106,7 +106,7 @@ export class FluentSlider extends LitElement {
.min=${this.min}
.max=${this.max}
.valueAsNumber=${this.value}
class="w-[60px] bg-black/40 text-white border border-white/20 text-center rounded text-sm p-1 leading-none font-bold font-inherit mt-1 focus:outline-none focus:border-blue-500"
class="w-[60px] bg-black/60 text-white border border-white/20 text-center rounded text-sm p-1 leading-none font-bold font-inherit mt-1 focus:outline-none focus:border-blue-500"
@input=${this.handleNumberInput}
@blur=${() => {
this.isEditing = false;
+92
View File
@@ -0,0 +1,92 @@
import { LitElement, html } from "lit";
import { customElement } from "lit/decorators.js";
@customElement("page-footer")
export class Footer extends LitElement {
createRenderRoot() {
return this;
}
render() {
return html`
<footer
class="[.in-game_&]:hidden bg-black/60 backdrop-blur-md flex flex-col items-center justify-center gap-2 pt-[2px] pb-2 text-white/50 w-full border-t border-white/10 shrink-0 mt-auto"
>
<div class="flex items-center justify-center gap-6 pt-2">
<a
href="https://github.com/openfrontio/OpenFrontIO"
target="_blank"
rel="noopener noreferrer"
class="opacity-60 hover:opacity-100 hover:scale-110 transition-all"
>
<img
src="/icons/github-mark-white.svg"
data-i18n-alt="news.github_link"
class="h-7 w-7 object-contain"
/>
</a>
<a
href="https://www.reddit.com/r/OpenFrontIO/"
target="_blank"
rel="noopener noreferrer"
class="opacity-60 hover:opacity-100 hover:scale-110 transition-all"
>
<svg
class="h-7 w-7 object-contain"
viewBox="0 0 24 24"
fill="white"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 0 1-2.498.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 0 1 .042.52c0 2.694-3.13 4.87-7.004 4.87-3.874 0-7.004-2.176-7.004-4.87 0-.183.015-.366.043-.534A1.748 1.748 0 0 1 4.028 12c0-.968.786-1.754 1.754-1.754.463 0 .898.196 1.207.49 1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 0 1 .14-.197.35.35 0 0 1 .238-.042l2.906.617a1.214 1.214 0 0 1 1.108-.701zM9.25 12C8.561 12 8 12.562 8 13.25c0 .687.561 1.248 1.25 1.248.687 0 1.248-.561 1.249-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 0 0-.231.094.33.33 0 0 0 0 .463c.842.842 2.484.913 2.961.913.477 0 2.105-.056 2.961-.913a.361.361 0 0 0 .029-.463.33.33 0 0 0-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.979-.196-2.512-.73a.326.326 0 0 0-.232-.095z"
/>
</svg>
</a>
<a
href="https://discord.gg/jRpxXvG42t"
target="_blank"
rel="noopener noreferrer"
class="opacity-60 hover:opacity-100 hover:scale-110 transition-all"
>
<svg
class="h-7 w-7 object-contain"
viewBox="0 0 24 24"
fill="white"
>
<path
d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-14.36a.074.074 0 0 0-.032-.027zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.418 2.157-2.418 1.21 0 2.176 1.085 2.157 2.418 0 1.334-.956 2.419-2.157 2.419zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.418 2.157-2.418 1.21 0 2.176 1.085 2.157 2.418 0 1.334-.946 2.419-2.157 2.419z"
/>
</svg>
</a>
<a
href="https://openfront.wiki/Main_Page"
target="_blank"
rel="noopener noreferrer"
class="opacity-60 hover:opacity-100 hover:scale-110 transition-all"
>
<img
src="/icons/wiki-logo.svg"
data-i18n-alt="main.wiki"
class="h-7 w-7 object-contain"
/>
</a>
</div>
<div class="text-xs mt-2 flex items-center justify-center gap-4">
<a
href="/terms-of-service.html"
data-i18n="main.terms_of_service"
target="_blank"
class="hover:text-white transition-colors"
></a>
<span data-i18n="main.copyright"></span>
<a
href="/privacy-policy.html"
data-i18n="main.privacy_policy"
target="_blank"
class="hover:text-white transition-colors"
></a>
</div>
</footer>
`;
}
}
+32
View File
@@ -0,0 +1,32 @@
import { LitElement, html } from "lit";
import { customElement } from "lit/decorators.js";
@customElement("main-layout")
export class MainLayout extends LitElement {
private _initialChildren: Node[] = [];
createRenderRoot() {
return this;
}
connectedCallback() {
if (this._initialChildren.length === 0 && this.childNodes.length > 0) {
this._initialChildren = Array.from(this.childNodes);
}
super.connectedCallback();
}
render() {
return html`
<main
class="relative [.in-game_&]:hidden flex flex-col flex-1 overflow-hidden w-full px-[clamp(1.5rem,3vw,3rem)] pt-[clamp(0.75rem,1.5vw,1.5rem)] pb-[clamp(0.75rem,1.5vw,1.5rem)]"
>
<div
class="w-full max-w-[20cm] mx-auto flex flex-col flex-1 gap-[clamp(1.5rem,3vw,3rem)] overflow-y-auto overflow-x-hidden [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden lg:[scrollbar-width:auto] lg:[-ms-overflow-style:auto] lg:[&::-webkit-scrollbar]:block"
>
${this._initialChildren}
</div>
</main>
`;
}
}
+155
View File
@@ -0,0 +1,155 @@
import { LitElement, html } from "lit";
import { customElement } from "lit/decorators.js";
@customElement("mobile-nav-bar")
export class MobileNavBar extends LitElement {
createRenderRoot() {
return this;
}
connectedCallback() {
super.connectedCallback();
window.addEventListener("showPage", this._onShowPage);
const current = (window as any).currentPageId;
if (current) {
this.updateComplete.then(() => {
this._updateActiveState(current);
});
}
}
disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener("showPage", this._onShowPage);
}
private _onShowPage = (e: Event) => {
const pageId = (e as CustomEvent).detail;
this._updateActiveState(pageId);
};
private _updateActiveState(pageId: string) {
this.querySelectorAll(".nav-menu-item").forEach((el) => {
if ((el as HTMLElement).dataset.page === pageId) {
el.classList.add("active");
} else {
el.classList.remove("active");
}
});
}
render() {
return html`
<!-- Border Segments (Custom right border with gap for button) -->
<div
class="absolute right-0 top-0 w-px bg-transparent"
style="height: calc(50% - 64px)"
></div>
<div
class="absolute right-0 bottom-0 w-px bg-transparent"
style="height: calc(50% - 64px)"
></div>
<div
class="flex-1 w-full flex flex-col justify-start overflow-y-auto md:pt-[clamp(1rem,3vh,4rem)] md:pb-[clamp(0.5rem,2vh,2rem)] md:px-[clamp(1rem,1.5vw,2rem)] p-5 gap-[clamp(1rem,3vh,3rem)]"
>
<!-- Logo + Menu -->
<div
class="flex flex-col text-[#2563eb] mb-[clamp(1rem,2vh,2rem)] ml-[clamp(0.2rem,0.4vw,0.4vh)]"
>
<div class="flex flex-col items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 1364 259"
width="100%"
height="100%"
fill="currentColor"
class="w-[clamp(120px,15vw,192px)] h-[clamp(40px,6vh,64px)] drop-shadow-[0_0_10px_rgba(37,99,235,0.3)]"
>
<!-- (Logo paths preserved) -->
<path
d="M0,174V51h15.24v-17.14h16.81v-16.98h16.96V0h1266v17.23h17.13v16.81h16.98v16.96h14.88v123h-15.13v17.08h-17.08v17.08h-16.9v17.04H324.9v16.86h-16.9v16.95h-102v-17.12h-17.07v-17.05H48.73v-17.05h-16.89v-16.89H14.94v-16.89H0ZM1297.95,17.35H65.9v16.7h-17.08v17.08h-14.5v123.08h14.85v16.9h17.08v17.08h139.9v17.08h17.08v16.36h67.9v-16.72h17.08v-17.07h989.88v-17.07h17.08v-16.9h14.44V50.8h-14.75v-17.08h-16.9v-16.37Z"
/>
<path
d="M189.1,154.78v17.07h-16.9v16.75h-51.07v-16.42h-16.9v-17.07h-16.97v-84.88h16.63v-17.07h16.9v-16.84h51.07v16.5h17.07v17.07h16.7v84.89h-16.54ZM137.87,53.1v17.15h-16.6v84.86h16.97v16.61h16.89v-16.97h16.6v-84.86h-16.97v-16.79h-16.89Z"
/>
<path
d="M273.91,104.06v-16.73h50.92v16.45h16.85v68.05h-16.44v17.06h-50.96v16.88h16.4v16.96h-67.28v-16.61h16.33v-101.86h-16.38v-16.98h33.4v16.63c6.12,0,11.72,0,17.31,0,0,22.56,0,45.13,0,67.75h33.59v-67.61h-33.73Z"
/>
<path
d="M631.12,188.64v-16.36h16.53V53.2h-16.25v-16.86h118.33v33.29h-16.65v-16.36h-50.72v50.44h33.36v-16.35h16.99v50.25h-16.6v-16.33h-33.73v50.65h16.37v16.72h-67.63Z"
/>
<path
d="M596.78,103.8v84.94h-33.54v-84.39h-34.03v84.25h-33.85v-101.29h84.5v16.49h16.93Z"
/>
<path
d="M1107.12,188.71v-84.34h-34.03v84.37h-33.7v-101.41h84.42v16.41h16.86v84.96h-33.54Z"
/>
<path
d="M988.1,171.78v16.87h-67.88v-16.38h-16.87v-68.06h16.38v-16.87h68.06v16.38h16.87v68.06h-16.55ZM970.78,104.35h-33.39v67.38h33.39v-67.38Z"
/>
<path
d="M460.77,155.38v16.49h-16.58v16.83h-68.05v-16.5h-16.83v-68.05h16.49v-16.83h68.05v16.49h16.83v34.06h-67.31v33.82h33.47v-16.31h33.92ZM393.39,104.18v16.56h33.3v-16.56h-33.3Z"
/>
<path
d="M1209.13,172h-16.9v-67.9h-16.96v-16.9h16.68v-17.08h16.9v-16.82h16.9v33.58h50.98v16.91h-50.4v67.96h16.48v-16.43h50.95v16.54h-16.55v16.76h-68.08v-16.6Z"
/>
<path
d="M834.91,120.94v16.96h-16.65v33.88h16.41v16.96h-67.29v-16.63h16.34v-67.87h-16.4v-16.97h50.42v33.81h17.3l-.14-.14Z"
/>
<path
d="M835.05,121.08v-33.75h33.76v16.43h16.85v33.96h-33.43v-16.79c-6.13,0-11.73,0-17.32,0,0,0,.14.14.14.14Z"
/>
</svg>
<div
id="game-version"
class="l-header__highlightText text-center"
></div>
</div>
</div>
<!-- Mobile Navigation Menu Items -->
<button
class="nav-menu-item block w-full text-left font-bold uppercase tracking-[0.05em] text-white/70 transition-all duration-200 cursor-pointer hover:text-blue-600 hover:translate-x-2.5 hover:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] [&.active]:text-blue-600 [&.active]:translate-x-2.5 [&.active]:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] text-[clamp(18px,2.8vh,32px)] py-[clamp(0.2rem,0.8vh,0.75rem)]"
data-page="page-play"
data-i18n="main.play"
></button>
<button
class="nav-menu-item block w-full text-left font-bold uppercase tracking-[0.05em] text-white/70 transition-all duration-200 cursor-pointer hover:text-blue-600 hover:translate-x-2.5 hover:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] [&.active]:text-blue-600 [&.active]:translate-x-2.5 [&.active]:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] text-[clamp(18px,2.8vh,32px)] py-[clamp(0.2rem,0.8vh,0.75rem)]"
data-page="page-news"
data-i18n="main.news"
></button>
<button
class="nav-menu-item block w-full text-left font-bold uppercase tracking-[0.05em] text-white/70 transition-all duration-200 cursor-pointer hover:text-blue-600 hover:translate-x-2.5 hover:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] [&.active]:text-blue-600 [&.active]:translate-x-2.5 [&.active]:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] text-[clamp(18px,2.8vh,32px)] py-[clamp(0.2rem,0.8vh,0.75rem)]"
data-page="page-stats"
data-i18n="main.stats"
></button>
<button
class="nav-menu-item block w-full text-left font-bold uppercase tracking-[0.05em] text-white/70 transition-all duration-200 cursor-pointer hover:text-blue-600 hover:translate-x-2.5 hover:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] [&.active]:text-blue-600 [&.active]:translate-x-2.5 [&.active]:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] text-[clamp(18px,2.8vh,32px)] py-[clamp(0.2rem,0.8vh,0.75rem)]"
data-page="page-item-store"
data-i18n="main.store"
></button>
<button
class="nav-menu-item block w-full text-left font-bold uppercase tracking-[0.05em] text-white/70 transition-all duration-200 cursor-pointer hover:text-blue-600 hover:translate-x-2.5 hover:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] [&.active]:text-blue-600 [&.active]:translate-x-2.5 [&.active]:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] text-[clamp(18px,2.8vh,32px)] py-[clamp(0.2rem,0.8vh,0.75rem)]"
data-page="page-settings"
data-i18n="main.settings"
></button>
<button
class="nav-menu-item block w-full text-left font-bold uppercase tracking-[0.05em] text-white/70 transition-all duration-200 cursor-pointer hover:text-blue-600 hover:translate-x-2.5 hover:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] [&.active]:text-blue-600 [&.active]:translate-x-2.5 [&.active]:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] text-[clamp(18px,2.8vh,32px)] py-[clamp(0.2rem,0.8vh,0.75rem)]"
data-page="page-account"
data-i18n="main.account"
></button>
<button
class="nav-menu-item block w-full text-left font-bold uppercase tracking-[0.05em] text-white/70 transition-all duration-200 cursor-pointer hover:text-blue-600 hover:translate-x-2.5 hover:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] [&.active]:text-blue-600 [&.active]:translate-x-2.5 [&.active]:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] text-[clamp(18px,2.8vh,32px)] py-[clamp(0.2rem,0.8vh,0.75rem)]"
data-page="page-help"
data-i18n="main.help"
></button>
<div
class="flex flex-col w-full mt-auto [.in-game_&]:hidden items-end justify-end pt-4 border-t border-white/10"
>
<lang-selector></lang-selector>
</div>
</div>
`;
}
}
+1 -1
View File
@@ -188,7 +188,7 @@ function renderBlankPreview(width: number, height: number): TemplateResult {
</div>
</div>
<div
class="hidden md:flex items-center justify-center h-full w-full bg-white/5 rounded overflow-hidden relative border border-white/10 box-border text-center p-1"
class="hidden md:flex items-center justify-center h-full w-full rounded overflow-hidden relative text-center p-1"
>
<span
class="text-[10px] font-black text-white/40 uppercase leading-none break-words w-full"
+172
View File
@@ -0,0 +1,172 @@
import { LitElement, html } from "lit";
import { customElement } from "lit/decorators.js";
@customElement("play-page")
export class PlayPage extends LitElement {
createRenderRoot() {
return this;
}
render() {
return html`
<div
id="page-play"
class="flex flex-col gap-2 w-full max-w-6xl mx-auto px-0 sm:px-4 transition-all duration-300 my-auto min-h-0"
>
<token-login class="w-full hidden"></token-login>
<!-- Header / Identity Section -->
<div class="grid grid-cols-1 lg:grid-cols-12 gap-2 lg:gap-6 w-full">
<div
class="lg:col-span-9 flex flex-row flex-nowrap gap-x-2 h-[60px] items-center bg-slate-900/80 backdrop-blur-md p-3 rounded-xl border border-blue-500/20 relative z-20 text-sm sm:text-base shrink-0"
>
<!-- Flag -->
<div
class="h-[40px] sm:h-[50px] shrink-0 aspect-[4/3] flex items-center justify-center lg:hidden"
>
<!-- Hamburger (Mobile) -->
<button
id="hamburger-btn"
class="lg:hidden flex w-full h-full bg-slate-800/40 text-white/90 border border-blue-400/20 hover:bg-slate-700/40 p-0 rounded-md items-center justify-center cursor-pointer transition-all duration-200"
data-i18n-aria-label="main.menu"
aria-expanded="false"
aria-controls="sidebar-menu"
aria-haspopup="dialog"
data-i18n-title="main.menu"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-8 h-8"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
/>
</svg>
</button>
</div>
<!-- Username -->
<div class="flex-1 min-w-0 h-[40px] sm:h-[50px] flex items-center">
<username-input
class="relative w-full h-full block text-ellipsis overflow-hidden whitespace-nowrap"
></username-input>
</div>
<!-- Pattern button (Mobile - inside bar, Desktop - hidden here) -->
<pattern-input
id="pattern-input-mobile"
show-select-label
class="aspect-square h-[50px] sm:h-[50px] lg:hidden shrink-0"
></pattern-input>
</div>
<!-- Pattern & Flag buttons (Desktop only - separate column) -->
<div class="hidden lg:flex lg:col-span-3">
<div class="w-full h-[60px] flex gap-2">
<pattern-input
id="pattern-input-desktop"
show-select-label
class="flex-1 h-full"
></pattern-input>
<flag-input
id="flag-input-desktop"
show-select-label
class="flex-1 h-full"
></flag-input>
</div>
</div>
</div>
<!-- Primary Game Actions Area -->
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6 w-full">
<!-- Left Column: Featured Lobbies / Quick Play -->
<div class="lg:col-span-9 flex flex-col gap-6 min-w-0">
<!-- Public Lobby Card -->
<public-lobby
class="block w-full transition-all duration-[50ms]"
></public-lobby>
</div>
<!-- Right Column: Custom Games & Modes -->
<div class="lg:col-span-3">
<div
class="group relative isolate flex flex-col w-full h-40 lg:h-96 overflow-hidden rounded-2xl transition-all duration-300"
>
<div
class="h-full flex flex-col bg-slate-900/40 backdrop-blur-sm rounded-2xl border border-blue-400/10 overflow-hidden"
>
<div
class="py-2 bg-blue-900/20 border-b border-blue-400/10 text-center text-sm font-bold text-gray-300 uppercase tracking-widest"
data-i18n="host_modal.label"
></div>
<div class="flex-1 p-2 flex flex-row lg:flex-col gap-2">
<o-button
id="single-player"
data-i18n-title="main.solo"
translationKey="main.solo"
fill
class="flex-1 transition-transform"
></o-button>
<o-button
id="host-lobby-button"
data-i18n-title="main.create"
translationKey="main.create"
fill
secondary
class="flex-1 opacity-90 hover:opacity-100"
></o-button>
<o-button
id="join-private-lobby-button"
data-i18n-title="main.join"
translationKey="main.join"
fill
secondary
class="flex-1 opacity-90 hover:opacity-100"
></o-button>
</div>
</div>
</div>
</div>
<!-- Matchmaking Buttons (Full Width across entire grid) -->
<div class="lg:col-span-12 flex flex-col gap-6">
<!-- Not Logged In Button -->
<button
id="matchmaking-button-logged-out"
class="w-full h-20 bg-purple-600 hover:bg-purple-500 text-white font-black uppercase tracking-widest rounded-xl transition-all duration-200 flex flex-col items-center justify-center border border-purple-500/30 overflow-hidden relative cursor-pointer"
>
<span
class="relative z-10 text-2xl"
data-i18n="matchmaking_button.login_required"
></span>
</button>
<!-- Logged In Button -->
<button
id="matchmaking-button"
class="hidden w-full h-20 bg-purple-600 hover:bg-purple-500 text-white font-black uppercase tracking-widest rounded-xl transition-all duration-200 flex flex-col items-center justify-center border border-purple-500/30 group overflow-hidden relative"
data-i18n-title="matchmaking_modal.title"
>
<span
class="relative z-10 text-2xl"
data-i18n="matchmaking_button.play_ranked"
></span>
<span
class="relative z-10 text-xs font-medium text-purple-100 opacity-90 group-hover:opacity-100 transition-opacity"
data-i18n="matchmaking_button.description"
></span>
</button>
</div>
</div>
</div>
`;
}
}
+20 -17
View File
@@ -12,33 +12,36 @@ export class OButton extends LitElement {
@property({ type: Boolean }) blockDesktop = false;
@property({ type: Boolean }) disable = false;
@property({ type: Boolean }) fill = false;
private static readonly BASE_CLASS =
"bg-blue-600 hover:bg-blue-700 text-white font-bold uppercase tracking-wider px-4 py-3 rounded-xl transition-all duration-300 transform hover:-translate-y-px outline-none border border-transparent text-center text-base lg:text-lg";
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,
};
}
render() {
return html`
<button
class=${classMap({
"bg-blue-600 hover:bg-blue-700 text-white font-bold uppercase tracking-wider px-4 py-3 rounded-xl transition-all duration-300 transform hover:-translate-y-px outline-none border border-transparent text-center text-base lg:text-lg":
true,
"dark:bg-blue-500 dark:hover:bg-blue-600": 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-blue-100 text-gray-900 hover:bg-blue-200 dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-600":
this.secondary,
"disabled:opacity-70 disabled:cursor-not-allowed disabled:transform-none disabled:bg-gray-600 dark:disabled:bg-gray-600":
this.disable,
})}
class=${classMap(this.getButtonClasses())}
?disabled=${this.disable}
>
${`${this.translationKey}` === ""
? `${this.title}`
: `${translateText(this.translationKey)}`}
${this.translationKey === ""
? this.title
: translateText(this.translationKey)}
</button>
`;
}
@@ -41,7 +41,7 @@ export class SettingKeybind extends LitElement {
<div class="flex items-center gap-3 shrink-0">
<div
class="relative h-12 min-w-[80px] px-4 flex items-center justify-center bg-black/40 border border-white/20 rounded-lg text-xl font-bold font-mono shadow-inner hover:border-blue-500 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/50 transition-all cursor-pointer select-none text-white
class="relative h-12 min-w-[80px] px-4 flex items-center justify-center bg-black/60 border border-white/20 rounded-lg text-xl font-bold font-mono shadow-inner hover:border-blue-500 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/50 transition-all cursor-pointer select-none text-white
${this.listening
? "border-blue-500 text-blue-400 ring-2 ring-blue-500/50"
: ""}"
@@ -78,7 +78,7 @@ export class SettingKeybind extends LitElement {
}
private displayKey(key: string): string {
if (!key) return translateText("user_setting.press_a_key");
if (!key || key === "Null") return translateText("common.none");
return formatKeyForDisplay(key);
}
@@ -50,7 +50,7 @@ export class SettingNumber extends LitElement {
<input
type="number"
id="setting-number-input"
class="shrink-0 w-[100px] py-2 px-3 border border-white/20 rounded-lg bg-black/40 text-white font-mono text-center focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-all"
class="shrink-0 w-[100px] py-2 px-3 border border-white/20 rounded-lg bg-black/60 text-white font-mono text-center focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-all"
.value=${String(this.value ?? 0)}
min=${this.min}
max=${this.max}
@@ -52,7 +52,7 @@ export class SettingToggle extends LitElement {
@change=${this.handleChange}
/>
<span
class="absolute inset-0 bg-black/40 border border-white/10 transition-all duration-300 rounded-full
class="absolute inset-0 bg-black/60 border border-white/10 transition-all duration-300 rounded-full
before:absolute before:content-[''] before:h-5 before:w-5 before:left-[3px] before:top-[3px]
before:bg-white/40 before:transition-all before:duration-300 before:rounded-full before:shadow-sm hover:before:bg-white/60
peer-checked:bg-blue-600 peer-checked:border-blue-500 peer-checked:before:translate-x-[24px] peer-checked:before:bg-white"
@@ -1,7 +1,7 @@
import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import type { DiscordUser } from "../../../../core/ApiSchemas";
import { translateText } from "../../../Utils";
import { getDiscordAvatarUrl, translateText } from "../../../Utils";
@customElement("discord-user-header")
export class DiscordUserHeader extends LitElement {
@@ -23,15 +23,7 @@ export class DiscordUserHeader extends LitElement {
private get avatarUrl(): string | null {
const u = this._data;
if (!u) return null;
if (u.avatar) {
const ext = u.avatar.startsWith("a_") ? "gif" : "png";
return `https://cdn.discordapp.com/avatars/${u.id}/${u.avatar}.${ext}`;
}
if (u.discriminator !== undefined) {
const idx = Number(u.discriminator) % 5;
return `https://cdn.discordapp.com/embed/avatars/${idx}.png`;
}
return null;
return getDiscordAvatarUrl(u);
}
private get discordDisplayName(): string {
@@ -45,7 +45,10 @@ export class GameList extends LitElement {
class="flex flex-col sm:flex-row sm:items-center justify-between px-4 py-3 gap-3"
>
<div class="flex items-center gap-4">
<div class="p-2 bg-blue-500/20 rounded-lg text-blue-400">
<button
class="p-2 bg-blue-500/20 rounded-lg text-blue-400"
@click=${() => this.onViewGame?.(game.gameId)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-5 h-5"
@@ -59,7 +62,7 @@ export class GameList extends LitElement {
<circle cx="12" cy="12" r="10"></circle>
<polygon points="10 8 16 12 10 16 10 8"></polygon>
</svg>
</div>
</button>
<div>
<div class="text-sm font-bold text-white tracking-wide">
${new Date(game.start).toLocaleDateString()}
+6 -3
View File
@@ -126,10 +126,13 @@ export class HeadsUpMessage extends LitElement implements Layer {
${this.isVisible
? html`
<div
class="flex items-center relative
w-full justify-evenly h-8 lg:h-10 md:top-17.5 left-0 lg:left-4
class="fixed top-[10%] left-1/2 -translate-x-1/2 z-[11000]
inline-flex items-center justify-center h-8 lg:h-10
w-fit max-w-[90vw]
bg-gray-900/60 rounded-md lg:rounded-lg
backdrop-blur-md text-white text-md lg:text-xl p-1 lg:p-2"
backdrop-blur-md text-white text-md lg:text-xl px-3 lg:px-4
text-center break-words"
style="word-wrap: break-word; hyphens: auto;"
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
>
${this.getMessage()}
+1 -7
View File
@@ -4,7 +4,7 @@ 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 { renderNumber } from "../../Utils";
import { formatPercentage, renderNumber } from "../../Utils";
import { Layer } from "./Layer";
interface Entry {
@@ -274,9 +274,3 @@ export class Leaderboard extends LitElement implements Layer {
`;
}
}
function formatPercentage(value: number): string {
const perc = value * 100;
if (Number.isNaN(perc)) return "0%";
return perc.toFixed(1) + "%";
}
+1 -1
View File
@@ -187,7 +187,7 @@ export class SettingsModal extends LitElement implements Layer {
return html`
<div
class="modal-overlay fixed inset-0 bg-black/50 backdrop-blur-xs z-2000 flex items-center justify-center p-4"
class="modal-overlay fixed inset-0 bg-black/60 backdrop-blur-xs z-2000 flex items-center justify-center p-4"
@contextmenu=${(e: Event) => e.preventDefault()}
>
<div
+6 -7
View File
@@ -3,7 +3,12 @@ import { customElement, property } from "lit/decorators.js";
import { EventBus } from "../../../core/EventBus";
import { GameMode, Team, UnitType } from "../../../core/game/Game";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { renderNumber, renderTroops, translateText } from "../../Utils";
import {
formatPercentage,
renderNumber,
renderTroops,
translateText,
} from "../../Utils";
import { Layer } from "./Layer";
interface TeamEntry {
@@ -243,9 +248,3 @@ export class TeamStats extends LitElement implements Layer {
`;
}
}
function formatPercentage(value: number): string {
const perc = value * 100;
if (Number.isNaN(perc)) return "0%";
return perc.toFixed(1) + "%";
}
-13
View File
@@ -1,13 +0,0 @@
/* Deprecated global modal styles.
The component-scoped styles in src/client/components/baseComponents/Modal.ts
are the single source of truth now. Removing global overrides so the
component can control layout and internal scrolling behavior. */
/* Keep small helper rule for legacy button layout, remove global .c-modal rules */
o-modal o-button {
@media (min-width: 1024px) {
margin: 0 auto;
display: block;
text-align: center;
}
}
+3 -1
View File
@@ -192,9 +192,11 @@ export class AttackExecution implements Execution {
const deaths = this.attack.troops() * (malusPercent / 100);
if (deaths) {
this.mg.displayMessage(
`Attack cancelled, ${renderTroops(deaths)} soldiers killed during retreat.`,
"events_display.attack_cancelled_retreat",
MessageType.ATTACK_CANCELLED,
this._owner.id(),
undefined,
{ troops: renderTroops(deaths) },
);
}
if (this.removeTroops === false && this.sourceTile === null) {
+3 -1
View File
@@ -272,9 +272,11 @@ export class SAMLauncherExecution implements Execution {
// Message
this.mg.displayMessage(
`${mirvWarheadTargets.length} MIRV warheads intercepted`,
"events_display.mirv_warheads_intercepted",
MessageType.SAM_HIT,
samOwner.id(),
undefined,
{ count: mirvWarheadTargets.length },
);
mirvWarheadTargets.forEach(({ unit: u }) => {
+3 -1
View File
@@ -61,9 +61,11 @@ export class SAMMissileExecution implements Execution {
);
if (result === true) {
this.mg.displayMessage(
`Missile intercepted ${this.target.type()}`,
"events_display.missile_intercepted",
MessageType.SAM_HIT,
this._owner.id(),
undefined,
{ unit: this.target.type() },
);
this.active = false;
this.target.delete(true, this._owner);
+15 -3
View File
@@ -142,10 +142,14 @@ export class TradeShipExecution implements Execution {
if (this.wasCaptured) {
this.tradeShip!.owner().addGold(gold, this._dstPort.tile());
this.mg.displayMessage(
`Received ${renderNumber(gold)} gold from ship captured from ${this.origOwner.displayName()}`,
"events_display.received_gold_from_captured_ship",
MessageType.CAPTURED_ENEMY_UNIT,
this.tradeShip!.owner().id(),
gold,
{
gold: renderNumber(gold),
name: this.origOwner.displayName(),
},
);
// Record stats
this.mg
@@ -155,16 +159,24 @@ export class TradeShipExecution implements Execution {
this.srcPort.owner().addGold(gold);
this._dstPort.owner().addGold(gold, this._dstPort.tile());
this.mg.displayMessage(
`Received ${renderNumber(gold)} gold from trade with ${this.srcPort.owner().displayName()}`,
"events_display.received_gold_from_trade",
MessageType.RECEIVED_GOLD_FROM_TRADE,
this._dstPort.owner().id(),
gold,
{
gold: renderNumber(gold),
name: this.srcPort.owner().displayName(),
},
);
this.mg.displayMessage(
`Received ${renderNumber(gold)} gold from trade with ${this._dstPort.owner().displayName()}`,
"events_display.received_gold_from_trade",
MessageType.RECEIVED_GOLD_FROM_TRADE,
this.srcPort.owner().id(),
gold,
{
gold: renderNumber(gold),
name: this._dstPort.owner().displayName(),
},
);
// Record stats
this.mg
+6 -2
View File
@@ -77,9 +77,11 @@ export class TransportShipExecution implements Execution {
mg.config().boatMaxNumber()
) {
mg.displayMessage(
`No boats available, max ${mg.config().boatMaxNumber()}`,
"events_display.no_boats_available",
MessageType.ATTACK_FAILED,
this.attacker.id(),
undefined,
{ max: mg.config().boatMaxNumber() },
);
this.active = false;
return;
@@ -270,9 +272,11 @@ export class TransportShipExecution implements Execution {
.boatArriveTroops(this.attacker, this.target, survivors);
if (deaths) {
this.mg.displayMessage(
`Attack cancelled, ${renderTroops(deaths)} soldiers killed during retreat.`,
"events_display.attack_cancelled_retreat",
MessageType.ATTACK_CANCELLED,
this.attacker.id(),
undefined,
{ troops: renderTroops(deaths) },
);
}
return;
+1 -1
View File
@@ -114,7 +114,7 @@ export enum GameMapType {
StraitOfHormuz = "Strait of Hormuz",
Surrounded = "Surrounded",
Didier = "Didier",
DidierFrance = "Didier (France)",
DidierFrance = "Didier France",
AmazonRiver = "Amazon River",
}
+11 -4
View File
@@ -665,14 +665,18 @@ export class PlayerImpl implements Player {
this.sentDonations.push(new Donation(recipient, this.mg.ticks()));
this.mg.displayMessage(
`Sent ${renderTroops(troops)} troops to ${recipient.name()}`,
"events_display.sent_troops_to_player",
MessageType.SENT_TROOPS_TO_PLAYER,
this.id(),
undefined,
{ troops: renderTroops(troops), name: recipient.name() },
);
this.mg.displayMessage(
`Received ${renderTroops(troops)} troops from ${this.name()}`,
"events_display.received_troops_from_player",
MessageType.RECEIVED_TROOPS_FROM_PLAYER,
recipient.id(),
undefined,
{ troops: renderTroops(troops), name: this.name() },
);
return true;
}
@@ -685,15 +689,18 @@ export class PlayerImpl implements Player {
this.sentDonations.push(new Donation(recipient, this.mg.ticks()));
this.mg.displayMessage(
`Sent ${renderNumber(gold)} gold to ${recipient.name()}`,
"events_display.sent_gold_to_player",
MessageType.SENT_GOLD_TO_PLAYER,
this.id(),
undefined,
{ gold: renderNumber(gold), name: recipient.name() },
);
this.mg.displayMessage(
`Received ${renderNumber(gold)} gold from ${this.name()}`,
"events_display.received_gold_from_player",
MessageType.RECEIVED_GOLD_FROM_PLAYER,
recipient.id(),
gold,
{ gold: renderNumber(gold), name: this.name() },
);
return true;
}
+9 -3
View File
@@ -206,14 +206,18 @@ export class UnitImpl implements Unit {
this._owner._units.push(this);
this.mg.addUpdate(this.toUpdate());
this.mg.displayMessage(
`Your ${this.type()} was captured by ${newOwner.displayName()}`,
"events_display.unit_captured_by_enemy",
MessageType.UNIT_CAPTURED_BY_ENEMY,
this._lastOwner.id(),
undefined,
{ unit: this.type(), name: newOwner.displayName() },
);
this.mg.displayMessage(
`Captured ${this.type()} from ${this._lastOwner.displayName()}`,
"events_display.captured_enemy_unit",
MessageType.CAPTURED_ENEMY_UNIT,
newOwner.id(),
undefined,
{ unit: this.type(), name: this._lastOwner.displayName() },
);
}
@@ -304,9 +308,11 @@ export class UnitImpl implements Unit {
}
this.mg.displayMessage(
`Your ${this._type} was destroyed`,
"events_display.unit_destroyed",
MessageType.UNIT_DESTROYED,
this.owner().id(),
undefined,
{ unit: this._type },
);
}
+1 -1
View File
@@ -61,7 +61,7 @@ const frequency: Partial<Record<GameMapName, number>> = {
TwoLakes: 6,
StraitOfHormuz: 4,
Surrounded: 4,
DidierFrance: 2,
DidierFrance: 1,
AmazonRiver: 3,
};
+3 -3
View File
@@ -431,7 +431,7 @@ describe("InputHandler AutoUpgrade", () => {
expect((inputHandler as any).keybinds.moveUp).toBe("KeyX");
});
test("ignores non-string and 'Null' values and preserves defaults", () => {
test("ignores non-string values and preserves defaults, but keeps 'Null' for unbound keys", () => {
const mixed = {
moveUp: { key: "moveUp", value: null },
moveLeft: "Null",
@@ -440,9 +440,9 @@ describe("InputHandler AutoUpgrade", () => {
inputHandler.initialize();
// defaults from InputHandler should remain
expect((inputHandler as any).keybinds.moveUp).toBe("KeyW");
expect((inputHandler as any).keybinds.moveLeft).toBe("KeyA");
// "Null" is preserved to indicate unbound keybind
expect((inputHandler as any).keybinds.moveLeft).toBe("Null");
});
test("handles invalid JSON gracefully and warns", () => {