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