From 112326f48ad447e9b6df99cd4edc43700a2e3594 Mon Sep 17 00:00:00 2001 From: zimri-leisher Date: Wed, 14 Jan 2026 22:56:09 -0500 Subject: [PATCH 1/8] feat: Add alliance request panel with colored circle indicators (#2905) - Add AllianceRequestPanel component showing colored circles for incoming alliance requests and renewal notifications - Circles display player color with countdown overlay showing time remaining - Hover popup shows player name, seconds remaining, accept/reject buttons - Different icons: blue ? for requests, orange hourglass for renewals, green pulsing for 'wants to renew' - Move alliance request/renewal UI from EventsDisplay chat to dedicated panel - Position panel to left of chat at bottom right of screen --- index.html | 16 +- src/client/graphics/GameRenderer.ts | 11 + .../graphics/layers/AllianceRequestPanel.ts | 669 ++++++++++++++++++ src/client/graphics/layers/EventsDisplay.ts | 115 +-- src/core/configuration/DefaultConfig.ts | 2 +- 5 files changed, 694 insertions(+), 119 deletions(-) create mode 100644 src/client/graphics/layers/AllianceRequestPanel.ts diff --git a/index.html b/index.html index cad490b1ca..c85749ce47 100644 --- a/index.html +++ b/index.html @@ -241,17 +241,21 @@
-
- - -
+
+ +
+ + +
+
+ diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index b4cd3eb386..37562f4ec4 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -8,6 +8,7 @@ import { TransformHandler } from "./TransformHandler"; import { UIState } from "./UIState"; import { AdTimer } from "./layers/AdTimer"; import { AlertFrame } from "./layers/AlertFrame"; +import { AllianceRequestPanel } from "./layers/AllianceRequestPanel"; import { BuildMenu } from "./layers/BuildMenu"; import { ChatDisplay } from "./layers/ChatDisplay"; import { ChatModal } from "./layers/ChatModal"; @@ -122,6 +123,15 @@ export function createRenderer( eventsDisplay.game = game; eventsDisplay.uiState = uiState; + const allianceRequestPanel = document.querySelector( + "alliance-request-panel", + ) as AllianceRequestPanel; + if (!(allianceRequestPanel instanceof AllianceRequestPanel)) { + console.error("alliance request panel not found"); + } + allianceRequestPanel.eventBus = eventBus; + allianceRequestPanel.game = game; + const chatDisplay = document.querySelector("chat-display") as ChatDisplay; if (!(chatDisplay instanceof ChatDisplay)) { console.error("chat display not found"); @@ -261,6 +271,7 @@ export function createRenderer( new DynamicUILayer(game, transformHandler, eventBus), new NameLayer(game, transformHandler, eventBus), eventsDisplay, + allianceRequestPanel, chatDisplay, buildMenu, new MainRadialMenu( diff --git a/src/client/graphics/layers/AllianceRequestPanel.ts b/src/client/graphics/layers/AllianceRequestPanel.ts new file mode 100644 index 0000000000..80cf134935 --- /dev/null +++ b/src/client/graphics/layers/AllianceRequestPanel.ts @@ -0,0 +1,669 @@ +import { html, LitElement } from "lit"; +import { customElement, state } from "lit/decorators.js"; +import { EventBus } from "../../../core/EventBus"; +import { MessageType, Tick } from "../../../core/game/Game"; +import { + AllianceRequestReplyUpdate, + AllianceRequestUpdate, + GameUpdateType, +} from "../../../core/game/GameUpdates"; +import { GameView, PlayerView } from "../../../core/game/GameView"; +import { + SendAllianceExtensionIntentEvent, + SendAllianceReplyIntentEvent, +} from "../../Transport"; +import { translateText } from "../../Utils"; +import { Layer } from "./Layer"; +import { GoToPlayerEvent } from "./Leaderboard"; + +interface AllianceIndicator { + id: string; + type: "request" | "renewal"; + playerSmallID: number; + playerName: string; + playerColor: string; + createdAt: Tick; + duration: Tick; + // For requests + requestorView?: PlayerView; + recipientView?: PlayerView; + // For renewals + allianceID?: number; + otherPlayerView?: PlayerView; + otherPlayerWantsRenewal?: boolean; +} + +@customElement("alliance-request-panel") +export class AllianceRequestPanel extends LitElement implements Layer { + public eventBus: EventBus; + public game: GameView; + + private active: boolean = false; + @state() private indicators: AllianceIndicator[] = []; + @state() private hoveredIndicator: string | null = null; + @state() private popupHovered: boolean = false; + + // Queue of pending indicator additions while popup is open + private pendingIndicators: AllianceIndicator[] = []; + // Set of indicator IDs to remove when popup closes + private pendingRemovals: Set = new Set(); + + // allianceID -> last checked at tick (for renewal notifications) + private alliancesCheckedAt = new Map(); + + private updateMap = [ + [GameUpdateType.AllianceRequest, this.onAllianceRequestEvent.bind(this)], + [ + GameUpdateType.AllianceRequestReply, + this.onAllianceRequestReplyEvent.bind(this), + ], + ] as const; + + constructor() { + super(); + this.indicators = []; + } + + init() {} + + tick() { + this.active = true; + + const myPlayer = this.game.myPlayer(); + if (!myPlayer || !myPlayer.isAlive()) { + return; + } + + // If popup is open, don't modify the indicators list at all + // Queue changes to apply when popup closes + if (this.popupHovered) { + // Still check for new events but queue them + this.checkForAllianceExpirations(); + const updates = this.game.updatesSinceLastTick(); + if (updates) { + for (const [ut, fn] of this.updateMap) { + updates[ut]?.forEach(fn as (event: unknown) => void); + } + } + // Mark expired indicators for removal later + for (const indicator of this.indicators) { + const isExpired = + this.game.ticks() - indicator.createdAt >= indicator.duration || + this.shouldDeleteIndicator(indicator); + if (isExpired) { + this.pendingRemovals.add(indicator.id); + // If the hovered indicator expired, force close popup and apply changes + if (indicator.id === this.hoveredIndicator) { + this.popupHovered = false; + this.hoveredIndicator = null; + this.flushPendingChanges(); + return; + } + } + } + // Still update for countdown animation even when popup is hovered + this.requestUpdate(); + return; + } + + this.checkForAllianceExpirations(); + + const updates = this.game.updatesSinceLastTick(); + if (updates) { + for (const [ut, fn] of this.updateMap) { + updates[ut]?.forEach(fn as (event: unknown) => void); + } + } + + // Remove expired indicators + const remainingIndicators = this.indicators.filter((indicator) => { + const shouldKeep = + this.game.ticks() - indicator.createdAt < indicator.duration && + !this.shouldDeleteIndicator(indicator); + return shouldKeep; + }); + + if (this.indicators.length !== remainingIndicators.length) { + this.indicators = remainingIndicators; + } + // Always request update for countdown overlay animation + this.requestUpdate(); + } + + private shouldDeleteIndicator(indicator: AllianceIndicator): boolean { + if (indicator.type === "request") { + // Check if requestor and recipient are now allied + if (indicator.requestorView && indicator.recipientView) { + return indicator.requestorView.isAlliedWith(indicator.recipientView); + } + } else if (indicator.type === "renewal") { + // Check if the alliance still exists and is still in expiration window + const myPlayer = this.game.myPlayer(); + if (!myPlayer) return true; + + const alliance = myPlayer + .alliances() + .find((a) => a.id === indicator.allianceID); + + // Alliance no longer exists (expired or broken) + if (!alliance) return true; + + // Alliance was renewed (expiresAt is now far in the future) + if ( + alliance.expiresAt > + this.game.ticks() + this.game.config().allianceExtensionPromptOffset() + ) { + return true; + } + } + return false; + } + + private checkForAllianceExpirations() { + const myPlayer = this.game.myPlayer(); + if (!myPlayer?.isAlive()) return; + + for (const alliance of myPlayer.alliances()) { + if ( + alliance.expiresAt > + this.game.ticks() + this.game.config().allianceExtensionPromptOffset() + ) { + continue; + } + + if ( + (this.alliancesCheckedAt.get(alliance.id) ?? 0) >= + this.game.ticks() - this.game.config().allianceExtensionPromptOffset() + ) { + // We've already displayed a message for this alliance. + continue; + } + + this.alliancesCheckedAt.set(alliance.id, this.game.ticks()); + + const other = this.game.player(alliance.other) as PlayerView; + if (!other.isAlive()) continue; + + const color = other.territoryColor().toHex(); + + this.addIndicator({ + id: `renewal-${alliance.id}`, + type: "renewal", + playerSmallID: other.smallID(), + playerName: other.name(), + playerColor: color, + createdAt: this.game.ticks(), + duration: this.game.config().allianceExtensionPromptOffset() - 3 * 10, // 3 second buffer + allianceID: alliance.id, + otherPlayerView: other, + otherPlayerWantsRenewal: alliance.hasExtensionRequest, + }); + } + } + + private addIndicator(indicator: AllianceIndicator) { + // Check if indicator with same id already exists - always allow updating otherPlayerWantsRenewal + const existingIndex = this.indicators.findIndex( + (i) => i.id === indicator.id, + ); + if (existingIndex !== -1) { + // Update otherPlayerWantsRenewal if it changed (even during popup hover) + const existing = this.indicators[existingIndex]; + if ( + indicator.type === "renewal" && + existing.otherPlayerWantsRenewal !== indicator.otherPlayerWantsRenewal + ) { + this.indicators = this.indicators.map((i, idx) => + idx === existingIndex + ? { ...i, otherPlayerWantsRenewal: indicator.otherPlayerWantsRenewal } + : i, + ); + this.requestUpdate(); + } + return; // Already exists + } + + // If popup is open, queue new indicators for later + if (this.popupHovered) { + // Check if already in pending queue + if (!this.pendingIndicators.some((i) => i.id === indicator.id)) { + this.pendingIndicators.push(indicator); + } + return; + } + + // Add to the front of the array so new indicators appear on the left + this.indicators = [indicator, ...this.indicators]; + this.requestUpdate(); + } + + private removeIndicator(id: string, force: boolean = false) { + // Don't remove if popup is being hovered (unless forced) + if (!force && this.popupHovered && this.hoveredIndicator === id) { + return; + } + this.indicators = this.indicators.filter((i) => i.id !== id); + this.requestUpdate(); + } + + private flushPendingChanges() { + // Apply any pending removals + if (this.pendingRemovals.size > 0) { + this.indicators = this.indicators.filter( + (i) => !this.pendingRemovals.has(i.id), + ); + this.pendingRemovals.clear(); + } + + // Apply any pending additions + if (this.pendingIndicators.length > 0) { + for (const indicator of this.pendingIndicators) { + // Double-check it doesn't already exist + if (!this.indicators.some((i) => i.id === indicator.id)) { + this.indicators = [indicator, ...this.indicators]; + } + } + this.pendingIndicators = []; + } + + this.requestUpdate(); + } + + private closePopup() { + this.popupHovered = false; + // Don't clear hoveredIndicator - the wrapper mouseenter/mouseleave handles that + // This allows smooth transition from one popup to another circle + // Apply all pending changes now that popup is closed + this.flushPendingChanges(); + } + + onAllianceRequestEvent(update: AllianceRequestUpdate) { + const myPlayer = this.game.myPlayer(); + if (!myPlayer || update.recipientID !== myPlayer.smallID()) { + return; + } + + const requestor = this.game.playerBySmallID( + update.requestorID, + ) as PlayerView; + const recipient = this.game.playerBySmallID( + update.recipientID, + ) as PlayerView; + + const color = requestor.territoryColor().toHex(); + + this.addIndicator({ + id: `request-${update.requestorID}-${update.createdAt}`, + type: "request", + playerSmallID: update.requestorID, + playerName: requestor.name(), + playerColor: color, + createdAt: this.game.ticks(), + duration: this.game.config().allianceRequestDuration() - 20, // 2 second buffer + requestorView: requestor, + recipientView: recipient, + }); + } + + onAllianceRequestReplyEvent(update: AllianceRequestReplyUpdate) { + const myPlayer = this.game.myPlayer(); + if (!myPlayer) { + return; + } + // myPlayer can deny alliances without clicking on the button + if (update.request.recipientID === myPlayer.smallID()) { + // Remove alliance requests whose requestors are the same as the reply's requestor + this.indicators = this.indicators.filter( + (indicator) => + !( + indicator.type === "request" && + indicator.playerSmallID === update.request.requestorID + ), + ); + this.requestUpdate(); + } + } + + private handleAccept(indicator: AllianceIndicator) { + if (indicator.type === "request") { + this.eventBus.emit( + new SendAllianceReplyIntentEvent( + indicator.requestorView!, + indicator.recipientView!, + true, + ), + ); + } else if (indicator.type === "renewal") { + this.eventBus.emit( + new SendAllianceExtensionIntentEvent(indicator.otherPlayerView!), + ); + } + // Close popup and flush pending changes + this.popupHovered = false; + this.hoveredIndicator = null; + // Remove the clicked indicator plus any pending removals + this.pendingRemovals.add(indicator.id); + this.flushPendingChanges(); + } + + private handleReject(indicator: AllianceIndicator) { + if (indicator.type === "request") { + this.eventBus.emit( + new SendAllianceReplyIntentEvent( + indicator.requestorView!, + indicator.recipientView!, + false, + ), + ); + } + // For renewals, "ignore" just removes the indicator + // Close popup and flush pending changes + this.popupHovered = false; + this.hoveredIndicator = null; + // Remove the clicked indicator plus any pending removals + this.pendingRemovals.add(indicator.id); + this.flushPendingChanges(); + } + + private handleFocus(indicator: AllianceIndicator) { + const player = this.game.playerBySmallID(indicator.playerSmallID); + if (player) { + this.eventBus.emit(new GoToPlayerEvent(player as PlayerView)); + } + } + + shouldTransform(): boolean { + return false; + } + + renderLayer(): void {} + + render() { + // Don't show during spawn phase or if no indicators + if (!this.active || this.indicators.length === 0 || this.game.inSpawnPhase()) { + return html``; + } + + return html` + +
+ ${this.indicators.map((indicator) => { + const elapsed = this.game.ticks() - indicator.createdAt; + const remaining = indicator.duration - elapsed; + const remainingSeconds = Math.max(0, Math.ceil(remaining / 10)); + const percentExpired = Math.min(100, (elapsed / indicator.duration) * 100); + + return html` +
(this.hoveredIndicator = indicator.id)} + @mouseleave=${() => { + if (!this.popupHovered) { + this.hoveredIndicator = null; + } + }} + > +
this.handleFocus(indicator)} + > +
+
+
+ ${indicator.type === "request" + ? html`
?
` + : html`
`} + ${this.hoveredIndicator === indicator.id + ? html` +
(this.popupHovered = true)} + @mouseleave=${() => this.closePopup()} + > + +
${remainingSeconds}s remaining
+ ${indicator.type === "renewal" && indicator.otherPlayerWantsRenewal + ? html`
✓ Wants to renew!
` + : ''} + + +
+ ` + : ""} +
+ `; + })} +
+ `; + } + + createRenderRoot() { + return this; + } +} diff --git a/src/client/graphics/layers/EventsDisplay.ts b/src/client/graphics/layers/EventsDisplay.ts index 2b66f33367..fab6fe9a0e 100644 --- a/src/client/graphics/layers/EventsDisplay.ts +++ b/src/client/graphics/layers/EventsDisplay.ts @@ -28,8 +28,6 @@ import { import { CancelAttackIntentEvent, CancelBoatIntentEvent, - SendAllianceExtensionIntentEvent, - SendAllianceReplyIntentEvent, SendAttackIntentEvent, } from "../../Transport"; import { Layer } from "./Layer"; @@ -82,8 +80,6 @@ export class EventsDisplay extends LitElement implements Layer { private active: boolean = false; private events: GameEvent[] = []; - // allianceID -> last checked at tick - private alliancesCheckedAt = new Map(); @state() private incomingAttacks: AttackUpdate[] = []; @state() private outgoingAttacks: AttackUpdate[] = []; @state() private outgoingLandAttacks: AttackUpdate[] = []; @@ -283,63 +279,7 @@ export class EventsDisplay extends LitElement implements Layer { } private checkForAllianceExpirations() { - const myPlayer = this.game.myPlayer(); - if (!myPlayer?.isAlive()) return; - - for (const alliance of myPlayer.alliances()) { - if ( - alliance.expiresAt > - this.game.ticks() + this.game.config().allianceExtensionPromptOffset() - ) { - continue; - } - - if ( - (this.alliancesCheckedAt.get(alliance.id) ?? 0) >= - this.game.ticks() - this.game.config().allianceExtensionPromptOffset() - ) { - // We've already displayed a message for this alliance. - continue; - } - - this.alliancesCheckedAt.set(alliance.id, this.game.ticks()); - - const other = this.game.player(alliance.other) as PlayerView; - if (!other.isAlive()) continue; - - this.addEvent({ - description: translateText("events_display.about_to_expire", { - name: other.name(), - }), - type: MessageType.RENEW_ALLIANCE, - duration: this.game.config().allianceExtensionPromptOffset() - 3 * 10, // 3 second buffer - buttons: [ - { - text: translateText("events_display.focus"), - className: "btn-gray", - action: () => this.eventBus.emit(new GoToPlayerEvent(other)), - preventClose: true, - }, - { - text: translateText("events_display.renew_alliance", { - name: other.name(), - }), - className: "btn", - action: () => - this.eventBus.emit(new SendAllianceExtensionIntentEvent(other)), - }, - { - text: translateText("events_display.ignore"), - className: "btn-info", - action: () => {}, - }, - ], - highlight: true, - createdAt: this.game.ticks(), - focusID: other.smallID(), - allianceID: alliance.id, - }); - } + // Alliance expirations/renewals are now handled by AllianceRequestPanel } private addEvent(event: GameEvent) { @@ -465,57 +405,8 @@ export class EventsDisplay extends LitElement implements Layer { } onAllianceRequestEvent(update: AllianceRequestUpdate) { - const myPlayer = this.game.myPlayer(); - if (!myPlayer || update.recipientID !== myPlayer.smallID()) { - return; - } - - const requestor = this.game.playerBySmallID( - update.requestorID, - ) as PlayerView; - const recipient = this.game.playerBySmallID( - update.recipientID, - ) as PlayerView; - - this.addEvent({ - description: translateText("events_display.request_alliance", { - name: requestor.name(), - }), - buttons: [ - { - text: translateText("events_display.focus"), - className: "btn-gray", - action: () => this.eventBus.emit(new GoToPlayerEvent(requestor)), - preventClose: true, - }, - { - text: translateText("events_display.accept_alliance"), - className: "btn", - action: () => - this.eventBus.emit( - new SendAllianceReplyIntentEvent(requestor, recipient, true), - ), - }, - { - text: translateText("events_display.reject_alliance"), - className: "btn-info", - action: () => - this.eventBus.emit( - new SendAllianceReplyIntentEvent(requestor, recipient, false), - ), - }, - ], - highlight: true, - type: MessageType.ALLIANCE_REQUEST, - createdAt: this.game.ticks(), - priority: 0, - duration: this.game.config().allianceRequestDuration() - 20, // 2 second buffer - shouldDelete: (game) => { - // Recipient sent a separate request, so they became allied without the recipient responding. - return requestor.isAlliedWith(recipient); - }, - focusID: update.requestorID, - }); + // Alliance requests are now handled by AllianceRequestPanel + // This handler is kept to maintain the event filtering for the reply event } onAllianceRequestReplyEvent(update: AllianceRequestReplyUpdate) { diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 7311cb60c2..298e6ce98b 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -526,7 +526,7 @@ export class DefaultConfig implements Config { return 3; } numSpawnPhaseTurns(): number { - return this._gameConfig.gameType === GameType.Singleplayer ? 100 : 300; + return this._gameConfig.gameType === GameType.Singleplayer ? 300 : 300; } numBots(): number { return this.bots(); From 7e6f7c7c51df697e156b9545d3060999feb4d835 Mon Sep 17 00:00:00 2001 From: zimri-leisher Date: Wed, 14 Jan 2026 23:11:55 -0500 Subject: [PATCH 2/8] fix: Add translation keys for alliance request panel strings - Add events_display.seconds_remaining for countdown text - Add events_display.wants_to_renew for renewal indicator --- resources/lang/en.json | 2 ++ src/client/graphics/layers/AllianceRequestPanel.ts | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index c18d7be1a4..37add95c2f 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -715,6 +715,8 @@ "alliance_renewed": "Your alliance with {name} has been renewed", "wants_to_renew_alliance": "{name} wants to renew your alliance", "ignore": "Ignore", + "seconds_remaining": "{seconds}s remaining", + "wants_to_renew": "✓ Wants to renew!", "unit_voluntarily_deleted": "Unit voluntarily deleted", "betrayal_debuff_ends": "{time} seconds left until betrayal debuff ends", "attack_cancelled_retreat": "Attack cancelled, {troops} soldiers killed during retreat", diff --git a/src/client/graphics/layers/AllianceRequestPanel.ts b/src/client/graphics/layers/AllianceRequestPanel.ts index 80cf134935..c6b338a770 100644 --- a/src/client/graphics/layers/AllianceRequestPanel.ts +++ b/src/client/graphics/layers/AllianceRequestPanel.ts @@ -605,10 +605,10 @@ export class AllianceRequestPanel extends LitElement implements Layer { @mouseleave=${() => this.closePopup()} > -
${remainingSeconds}s remaining
+
${translateText("events_display.seconds_remaining", { seconds: remainingSeconds })}
${indicator.type === "renewal" && indicator.otherPlayerWantsRenewal - ? html`
✓ Wants to renew!
` - : ''} + ? html`
${translateText("events_display.wants_to_renew")}
` + : ''}}