mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:10:42 +00:00
Improve Notification Panel (#3913)
Resolves #3910 ## Description: - Split the events HUD into two components: a new **`<actionable-events>`** that owns alliance prompts (request / renew) and a slimmed-down **`<events-display>`** for everything else. - Reworked `<events-display>` into two visual tiers: dim/scrolling tier 2 on top (trade results, unit losses, donations, alliance status), prominent tier 1 anchored at the bottom (inbound nukes, naval invasion, attack requests, alliance broken, conquered player, chat). Tier 2 caps at the 4 newest entries; events expire after 8s. - Added a transient **+gold pip** above the gold pill in `<control-panel>`, animated with a small fade-in. Fires for trade ships, trains, donations, and conquest. Trade-ship and train arrivals are removed from the events scroll since they're surfaced here instead. - New `MessageType.NUKE_DETONATED` and a server-side emission in `NukeExecution.detonate` — once an inbound nuke lands or gets intercepted, the inbound warning vanishes and a "detonated" entry takes its place. - `displayMessage` gained optional `unitID` and `focusPlayerID` params so events can link to a unit or a player. Unit captures and destructions now navigate to the unit's last tile when clicked; donations navigate to the other player. - ActionableEvents card width matches `<events-display>`; cards persist until the user clicks Accept/Reject/Renew/Ignore or the server-side request timeout expires. - Removed the in-events category filter UI and the gold-amount banner — `<events-display>` is now a lightweight log that hides entirely when empty. <img width="570" height="444" alt="Screenshot 2026-05-21 at 1 42 30 PM" src="https://github.com/user-attachments/assets/f103efb3-0e11-4b72-a11b-91ff6896177c" /> <img width="430" height="296" alt="Screenshot 2026-05-21 at 1 41 34 PM" src="https://github.com/user-attachments/assets/ae58475a-b252-4aa6-9ce5-99dea7575ce3" /> ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: evan
This commit is contained in:
+4
-1
@@ -315,7 +315,7 @@
|
||||
|
||||
<!-- events+chat: <sm between attacks and control (order-2), sm+ right side, lg+ col-3 -->
|
||||
<div
|
||||
class="flex flex-col pointer-events-none items-end order-2 sm:order-none sm:flex-1 lg:col-start-3 lg:self-end lg:justify-end min-[1200px]:mr-4"
|
||||
class="flex flex-col pointer-events-none items-end order-2 sm:order-none sm:flex-1 lg:col-start-3 lg:self-end lg:justify-end"
|
||||
>
|
||||
<chat-display
|
||||
class="w-full sm:w-auto pointer-events-auto"
|
||||
@@ -323,6 +323,9 @@
|
||||
<events-display
|
||||
class="w-full sm:w-auto pointer-events-auto"
|
||||
></events-display>
|
||||
<actionable-events
|
||||
class="w-full sm:w-auto pointer-events-auto"
|
||||
></actionable-events>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -947,7 +947,6 @@
|
||||
"youtube_tutorial": "Need some help?"
|
||||
},
|
||||
"leaderboard": {
|
||||
"hide": "Hide",
|
||||
"player": "Player",
|
||||
"team": "Team",
|
||||
"owned": "Owned",
|
||||
@@ -961,7 +960,6 @@
|
||||
"show_units": "Show Units"
|
||||
},
|
||||
"events_display": {
|
||||
"events": "Events",
|
||||
"retreating": "retreating",
|
||||
"alliance_request_status": "{name} {status} your alliance request",
|
||||
"alliance_accepted": "accepted",
|
||||
@@ -974,6 +972,8 @@
|
||||
"betrayed_you": "{name} broke their alliance with you",
|
||||
"about_to_expire": "Your alliance with {name} is about to expire!",
|
||||
"alliance_expired": "Your alliance with {name} expired",
|
||||
"atom_bomb_detonated": "{name} - atom bomb detonated",
|
||||
"hydrogen_bomb_detonated": "{name} - hydrogen bomb detonated",
|
||||
"attack_request": "{name} requests you attack {target}",
|
||||
"sent_emoji": "Sent {name}: {emoji}",
|
||||
"renew_alliance": "Request to renew",
|
||||
@@ -989,7 +989,6 @@
|
||||
"betrayal_debuff_ends": "{time} seconds left until betrayal debuff ends",
|
||||
"attack_cancelled_retreat": "Attack cancelled, {troops} soldiers killed during retreat",
|
||||
"received_gold_from_captured_ship": "Received {gold} gold from ship captured from {name}",
|
||||
"received_gold_from_trade": "Received {gold} gold from trade with {name}",
|
||||
"received_gold_from_conquest": "Conquered {name}, received {gold} gold",
|
||||
"conquered_no_gold": "Conquered {name} (didn't play, no gold awarded)",
|
||||
"missile_intercepted": "Missile intercepted {unit}",
|
||||
|
||||
+4
-6
@@ -499,22 +499,20 @@ export function getMessageTypeClasses(type: MessageType): string {
|
||||
switch (type) {
|
||||
case MessageType.SAM_HIT:
|
||||
case MessageType.CAPTURED_ENEMY_UNIT:
|
||||
case MessageType.RECEIVED_GOLD_FROM_TRADE:
|
||||
case MessageType.CONQUERED_PLAYER:
|
||||
case MessageType.DONATION_RECEIVED:
|
||||
case MessageType.ALLIANCE_ACCEPTED:
|
||||
return severityColors["success"];
|
||||
case MessageType.ATTACK_FAILED:
|
||||
case MessageType.ALLIANCE_REJECTED:
|
||||
case MessageType.ALLIANCE_BROKEN:
|
||||
case MessageType.UNIT_CAPTURED_BY_ENEMY:
|
||||
case MessageType.UNIT_DESTROYED:
|
||||
case MessageType.NUKE_DETONATED:
|
||||
return severityColors["fail"];
|
||||
case MessageType.ATTACK_CANCELLED:
|
||||
case MessageType.ATTACK_REQUEST:
|
||||
case MessageType.ALLIANCE_ACCEPTED:
|
||||
case MessageType.SENT_GOLD_TO_PLAYER:
|
||||
case MessageType.SENT_TROOPS_TO_PLAYER:
|
||||
case MessageType.RECEIVED_GOLD_FROM_PLAYER:
|
||||
case MessageType.RECEIVED_TROOPS_FROM_PLAYER:
|
||||
case MessageType.DONATION_SENT:
|
||||
return severityColors["blue"];
|
||||
case MessageType.MIRV_INBOUND:
|
||||
case MessageType.NUKE_INBOUND:
|
||||
|
||||
@@ -10,6 +10,7 @@ import { HoverHighlightController } from "../controllers/HoverHighlightControlle
|
||||
import { WarshipSelectionController } from "../controllers/WarshipSelectionController";
|
||||
import { GameView as WebGLGameView } from "../render/gl";
|
||||
import { FrameProfiler } from "./FrameProfiler";
|
||||
import { ActionableEvents } from "./layers/ActionableEvents";
|
||||
import { AlertFrame } from "./layers/AlertFrame";
|
||||
import { AttackingTroopsOverlay } from "./layers/AttackingTroopsOverlay";
|
||||
import { AttacksDisplay } from "./layers/AttacksDisplay";
|
||||
@@ -120,6 +121,16 @@ export function createRenderer(
|
||||
eventsDisplay.game = game;
|
||||
eventsDisplay.uiState = uiState;
|
||||
|
||||
const actionableEvents = document.querySelector(
|
||||
"actionable-events",
|
||||
) as ActionableEvents;
|
||||
if (!(actionableEvents instanceof ActionableEvents)) {
|
||||
console.error("actionable events not found");
|
||||
}
|
||||
actionableEvents.eventBus = eventBus;
|
||||
actionableEvents.game = game;
|
||||
actionableEvents.uiState = uiState;
|
||||
|
||||
const attacksDisplay = document.querySelector(
|
||||
"attacks-display",
|
||||
) as AttacksDisplay;
|
||||
@@ -265,6 +276,7 @@ export function createRenderer(
|
||||
new HoverHighlightController(game, eventBus, transformHandler, view),
|
||||
new AttackingTroopsOverlay(game, transformHandler, eventBus, userSettings),
|
||||
eventsDisplay,
|
||||
actionableEvents,
|
||||
attacksDisplay,
|
||||
chatDisplay,
|
||||
buildMenu,
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
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 {
|
||||
AllianceExtensionUpdate,
|
||||
AllianceRequestUpdate,
|
||||
BrokeAllianceUpdate,
|
||||
GameUpdateType,
|
||||
} from "../../../core/game/GameUpdates";
|
||||
import { GameView, PlayerView } from "../../../core/game/GameView";
|
||||
import { Controller } from "../../Controller";
|
||||
import { PlaySoundEffectEvent } from "../../sound/Sounds";
|
||||
import { GoToPlayerEvent } from "../../TransformHandler";
|
||||
import {
|
||||
SendAllianceExtensionIntentEvent,
|
||||
SendAllianceRejectIntentEvent,
|
||||
SendAllianceRequestIntentEvent,
|
||||
} from "../../Transport";
|
||||
import { UIState } from "../../UIState";
|
||||
import { getMessageTypeClasses, translateText } from "../../Utils";
|
||||
|
||||
interface ActionableEvent {
|
||||
description: string;
|
||||
type: MessageType;
|
||||
createdAt: number;
|
||||
focusID: number;
|
||||
buttons: {
|
||||
text: string;
|
||||
className: string;
|
||||
action: () => void;
|
||||
preventClose?: boolean;
|
||||
}[];
|
||||
priority?: number;
|
||||
allianceID?: number;
|
||||
duration?: Tick;
|
||||
}
|
||||
|
||||
@customElement("actionable-events")
|
||||
export class ActionableEvents extends LitElement implements Controller {
|
||||
public eventBus: EventBus;
|
||||
public game: GameView;
|
||||
public uiState: UIState;
|
||||
|
||||
private active = false;
|
||||
private events: ActionableEvent[] = [];
|
||||
// allianceID -> last checked at tick
|
||||
private alliancesCheckedAt = new Map<number, Tick>();
|
||||
@state() private _isVisible = false;
|
||||
|
||||
private updateMap = [
|
||||
[GameUpdateType.AllianceRequest, this.onAllianceRequestEvent.bind(this)],
|
||||
[GameUpdateType.BrokeAlliance, this.onBrokeAllianceEvent.bind(this)],
|
||||
[
|
||||
GameUpdateType.AllianceExtension,
|
||||
this.onAllianceExtensionEvent.bind(this),
|
||||
],
|
||||
] as const;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
private addEvent(event: ActionableEvent) {
|
||||
this.events = [...this.events, event];
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private removeEvent(index: number) {
|
||||
this.events = [
|
||||
...this.events.slice(0, index),
|
||||
...this.events.slice(index + 1),
|
||||
];
|
||||
}
|
||||
|
||||
private removeAllianceRenewalEvents(allianceID: number) {
|
||||
this.events = this.events.filter(
|
||||
(event) =>
|
||||
!(
|
||||
event.type === MessageType.RENEW_ALLIANCE &&
|
||||
event.allianceID === allianceID
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
tick() {
|
||||
this.active = true;
|
||||
|
||||
if (!this._isVisible && !this.game.inSpawnPhase()) {
|
||||
this._isVisible = true;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
const myPlayer = this.game.myPlayer();
|
||||
if (!myPlayer || !myPlayer.isAlive()) {
|
||||
if (this._isVisible) {
|
||||
this._isVisible = false;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
const remainingEvents = this.events.filter(
|
||||
(event) =>
|
||||
event.duration === undefined ||
|
||||
this.game.ticks() - event.createdAt < event.duration,
|
||||
);
|
||||
|
||||
if (this.events.length !== remainingEvents.length) {
|
||||
this.events = remainingEvents;
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private checkForAllianceExpirations() {
|
||||
const myPlayer = this.game.myPlayer();
|
||||
if (!myPlayer?.isAlive()) return;
|
||||
|
||||
const currentAllianceIds = new Set<number>();
|
||||
|
||||
for (const alliance of myPlayer.alliances()) {
|
||||
currentAllianceIds.add(alliance.id);
|
||||
|
||||
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()
|
||||
) {
|
||||
// Already prompted for this alliance in the current window.
|
||||
continue;
|
||||
}
|
||||
|
||||
this.alliancesCheckedAt.set(alliance.id, this.game.ticks());
|
||||
|
||||
const other = this.game.player(alliance.other) as PlayerView;
|
||||
|
||||
this.addEvent({
|
||||
description: translateText("events_display.about_to_expire", {
|
||||
name: other.displayName(),
|
||||
}),
|
||||
type: MessageType.RENEW_ALLIANCE,
|
||||
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.displayName(),
|
||||
}),
|
||||
className: "btn",
|
||||
action: () =>
|
||||
this.eventBus.emit(new SendAllianceExtensionIntentEvent(other)),
|
||||
},
|
||||
{
|
||||
text: translateText("events_display.ignore"),
|
||||
className: "btn-info",
|
||||
action: () => {},
|
||||
},
|
||||
],
|
||||
createdAt: this.game.ticks(),
|
||||
focusID: other.smallID(),
|
||||
allianceID: alliance.id,
|
||||
});
|
||||
}
|
||||
|
||||
for (const [allianceId] of this.alliancesCheckedAt) {
|
||||
if (!currentAllianceIds.has(allianceId)) {
|
||||
this.removeAllianceRenewalEvents(allianceId);
|
||||
this.alliancesCheckedAt.delete(allianceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
if (!requestor.isAlliedWith(recipient)) {
|
||||
this.eventBus.emit(new PlaySoundEffectEvent("alliance-suggested"));
|
||||
}
|
||||
this.addEvent({
|
||||
description: translateText("events_display.request_alliance", {
|
||||
name: requestor.displayName(),
|
||||
}),
|
||||
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 SendAllianceRequestIntentEvent(recipient, requestor),
|
||||
),
|
||||
},
|
||||
{
|
||||
text: translateText("events_display.reject_alliance"),
|
||||
className: "btn-info",
|
||||
action: () =>
|
||||
this.eventBus.emit(new SendAllianceRejectIntentEvent(requestor)),
|
||||
},
|
||||
],
|
||||
type: MessageType.ALLIANCE_REQUEST,
|
||||
createdAt: this.game.ticks(),
|
||||
priority: 0,
|
||||
duration: this.game.config().allianceRequestDuration(),
|
||||
focusID: update.requestorID,
|
||||
});
|
||||
}
|
||||
|
||||
onBrokeAllianceEvent(update: BrokeAllianceUpdate) {
|
||||
// Cleanup-only: any open renewal prompt for this alliance is now moot.
|
||||
this.removeAllianceRenewalEvents(update.allianceID);
|
||||
this.alliancesCheckedAt.delete(update.allianceID);
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private onAllianceExtensionEvent(update: AllianceExtensionUpdate) {
|
||||
const myPlayer = this.game.myPlayer();
|
||||
if (!myPlayer || myPlayer.smallID() !== update.playerID) return;
|
||||
this.removeAllianceRenewalEvents(update.allianceID);
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private emitGoToPlayerEvent(focusID: number) {
|
||||
const target = this.game.playerBySmallID(focusID) as PlayerView;
|
||||
if (!target) return;
|
||||
this.eventBus.emit(new GoToPlayerEvent(target));
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.active || !this._isVisible || this.events.length === 0) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
const sorted = [...this.events].sort((a, b) => {
|
||||
const aPrior = a.priority ?? 100000;
|
||||
const bPrior = b.priority ?? 100000;
|
||||
if (aPrior === bPrior) {
|
||||
return a.createdAt - b.createdAt;
|
||||
}
|
||||
return bPrior - aPrior;
|
||||
});
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="flex flex-col gap-2 w-full min-[1200px]:w-96 pointer-events-auto mt-2"
|
||||
>
|
||||
${sorted.map(
|
||||
(event) => html`
|
||||
<div
|
||||
class="bg-gray-800/92 backdrop-blur-sm rounded-lg shadow-lg border-l-4 border-yellow-400 p-3 lg:p-4 text-white"
|
||||
>
|
||||
<button
|
||||
class="text-left text-sm lg:text-base font-semibold w-full cursor-pointer ${getMessageTypeClasses(
|
||||
event.type,
|
||||
)}"
|
||||
@click=${() => this.emitGoToPlayerEvent(event.focusID)}
|
||||
>
|
||||
${event.description}
|
||||
</button>
|
||||
<div class="flex flex-wrap gap-1.5 mt-2">
|
||||
${event.buttons.map(
|
||||
(btn) => html`
|
||||
<button
|
||||
class="inline-block px-3 py-1 text-white rounded-sm text-xs lg:text-sm cursor-pointer transition-colors duration-300
|
||||
${btn.className.includes("btn-info")
|
||||
? "bg-blue-500 hover:bg-blue-600"
|
||||
: btn.className.includes("btn-gray")
|
||||
? "bg-gray-500 hover:bg-gray-600"
|
||||
: "bg-green-600 hover:bg-green-700"}"
|
||||
@click=${() => {
|
||||
btn.action();
|
||||
if (!btn.preventClose) {
|
||||
const index = this.events.findIndex(
|
||||
(e) => e === event,
|
||||
);
|
||||
if (index !== -1) this.removeEvent(index);
|
||||
}
|
||||
this.requestUpdate();
|
||||
}}
|
||||
>
|
||||
${btn.text}
|
||||
</button>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { keyed } from "lit/directives/keyed.js";
|
||||
import { assetUrl } from "../../../core/AssetUrls";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { Gold } from "../../../core/game/Game";
|
||||
import { GameUpdateType } from "../../../core/game/GameUpdates";
|
||||
import { GameView } from "../../../core/game/GameView";
|
||||
import { UserSettings } from "../../../core/game/UserSettings";
|
||||
import { ClientID } from "../../../core/Schemas";
|
||||
@@ -42,14 +44,16 @@ export class ControlPanel extends LitElement implements Controller {
|
||||
@state()
|
||||
private _attackingTroops: number = 0;
|
||||
|
||||
@state()
|
||||
private _goldGain: bigint | null = null;
|
||||
@state()
|
||||
private _goldGainPulseId: number = 0;
|
||||
private _goldGainTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
private _troopRateIsIncreasing: boolean = true;
|
||||
|
||||
private _lastTroopIncreaseRate: number;
|
||||
|
||||
getTickIntervalMs() {
|
||||
return 100;
|
||||
}
|
||||
|
||||
init() {
|
||||
this.attackRatio = new UserSettings().attackRatio();
|
||||
this.uiState.attackRatio = this.attackRatio;
|
||||
@@ -95,9 +99,66 @@ export class ControlPanel extends LitElement implements Controller {
|
||||
.map((a) => a.troops)
|
||||
.reduce((a, b) => a + b, 0);
|
||||
this.troopRate = this.game.config().troopIncreaseRate(player) * 10;
|
||||
|
||||
const updates = this.game.updatesSinceLastTick();
|
||||
if (updates) {
|
||||
const myID = player.id();
|
||||
const bonusEvents = updates[GameUpdateType.BonusEvent];
|
||||
if (bonusEvents) {
|
||||
for (const ev of bonusEvents) {
|
||||
if (ev.player === myID && ev.gold > 0) {
|
||||
this.addGoldGain(BigInt(ev.gold));
|
||||
}
|
||||
}
|
||||
}
|
||||
const conquestEvents = updates[GameUpdateType.ConquestEvent];
|
||||
if (conquestEvents) {
|
||||
for (const ev of conquestEvents) {
|
||||
if (ev.conquerorId === myID && ev.gold > 0n) {
|
||||
this.addGoldGain(ev.gold);
|
||||
}
|
||||
}
|
||||
}
|
||||
const donateEvents = updates[GameUpdateType.DonateEvent];
|
||||
if (donateEvents) {
|
||||
for (const ev of donateEvents) {
|
||||
if (
|
||||
ev.donationType === "gold" &&
|
||||
ev.recipientId === myID &&
|
||||
ev.amount > 0n
|
||||
) {
|
||||
this.addGoldGain(ev.amount);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
// Last-wins: when multiple gold events arrive in one tick, the pip shows
|
||||
// only the most recent amount (not a sum) — each gain restarts the pulse.
|
||||
private addGoldGain(amount: bigint) {
|
||||
this._goldGain = amount;
|
||||
this._goldGainPulseId++;
|
||||
if (this._goldGainTimeoutId !== null) {
|
||||
clearTimeout(this._goldGainTimeoutId);
|
||||
}
|
||||
this._goldGainTimeoutId = setTimeout(() => {
|
||||
this._goldGain = null;
|
||||
this._goldGainTimeoutId = null;
|
||||
this.requestUpdate();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
if (this._goldGainTimeoutId !== null) {
|
||||
clearTimeout(this._goldGainTimeoutId);
|
||||
this._goldGainTimeoutId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private updateTroopIncrease() {
|
||||
const player = this.game?.myPlayer();
|
||||
if (player === null) return;
|
||||
@@ -284,9 +345,18 @@ export class ControlPanel extends LitElement implements Controller {
|
||||
<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-sm py-0.5 px-1 w-[4.5rem]"
|
||||
class="flex items-center gap-1 shrink-0 border rounded-md border-yellow-400 font-bold text-yellow-400 text-sm py-0.5 px-1 w-[4.5rem] relative"
|
||||
translate="no"
|
||||
>
|
||||
${this._goldGain !== null
|
||||
? keyed(
|
||||
this._goldGainPulseId,
|
||||
html`<span
|
||||
class="gold-gain-pop absolute -top-5 right-[5px] min-[1015px]:right-[9px] text-green-400 text-sm font-extrabold tabular-nums whitespace-nowrap pointer-events-none drop-shadow-[0_2px_3px_rgba(0,0,0,0.9)]"
|
||||
>+${renderNumber(this._goldGain)}</span
|
||||
>`,
|
||||
)
|
||||
: ""}
|
||||
<img src=${goldCoinIcon} width="13" height="13" class="shrink-0" />
|
||||
<span class="tabular-nums">${renderNumber(this._gold)}</span>
|
||||
</div>
|
||||
@@ -329,9 +399,18 @@ export class ControlPanel extends LitElement implements Controller {
|
||||
<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"
|
||||
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 relative"
|
||||
translate="no"
|
||||
>
|
||||
${this._goldGain !== null
|
||||
? keyed(
|
||||
this._goldGainPulseId,
|
||||
html`<span
|
||||
class="gold-gain-pop absolute -top-5 right-[5px] min-[1015px]:right-[9px] text-green-400 text-xs font-extrabold tabular-nums whitespace-nowrap pointer-events-none drop-shadow-[0_2px_3px_rgba(0,0,0,0.9)]"
|
||||
>+${renderNumber(this._goldGain)}</span
|
||||
>`,
|
||||
)
|
||||
: ""}
|
||||
<img src=${goldCoinIcon} width="13" height="13" />
|
||||
<span class="px-0.5">${renderNumber(this._gold)}</span>
|
||||
</div>
|
||||
@@ -374,6 +453,21 @@ export class ControlPanel extends LitElement implements Controller {
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<style>
|
||||
@keyframes gold-gain-pop {
|
||||
0% {
|
||||
transform: translateY(4px);
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.gold-gain-pop {
|
||||
animation: gold-gain-pop 0.25s ease-out;
|
||||
}
|
||||
</style>
|
||||
<div
|
||||
class="relative pointer-events-auto ${this._isVisible
|
||||
? "relative w-full text-sm px-2 py-1"
|
||||
|
||||
@@ -2,71 +2,62 @@ import { html, LitElement } from "lit";
|
||||
import { customElement, query, state } from "lit/decorators.js";
|
||||
import { DirectiveResult } from "lit/directive.js";
|
||||
import { unsafeHTML, UnsafeHTMLDirective } from "lit/directives/unsafe-html.js";
|
||||
import { assetUrl } from "../../../core/AssetUrls";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import {
|
||||
AllPlayers,
|
||||
getMessageCategory,
|
||||
MessageCategory,
|
||||
MessageType,
|
||||
Tick,
|
||||
} from "../../../core/game/Game";
|
||||
import { AllPlayers, MessageType } from "../../../core/game/Game";
|
||||
import {
|
||||
AllianceExpiredUpdate,
|
||||
AllianceExtensionUpdate,
|
||||
AllianceRequestReplyUpdate,
|
||||
AllianceRequestUpdate,
|
||||
BrokeAllianceUpdate,
|
||||
DisplayChatMessageUpdate,
|
||||
DisplayMessageUpdate,
|
||||
DonateEventUpdate,
|
||||
EmojiUpdate,
|
||||
GameUpdateType,
|
||||
TargetPlayerUpdate,
|
||||
UnitIncomingUpdate,
|
||||
} from "../../../core/game/GameUpdates";
|
||||
import { Controller } from "../../Controller";
|
||||
import {
|
||||
SendAllianceExtensionIntentEvent,
|
||||
SendAllianceRejectIntentEvent,
|
||||
SendAllianceRequestIntentEvent,
|
||||
} from "../../Transport";
|
||||
import { SendAllianceRequestIntentEvent } from "../../Transport";
|
||||
|
||||
import { GameView, PlayerView, UnitView } from "../../../core/game/GameView";
|
||||
import { onlyImages } from "../../../core/Util";
|
||||
import { GoToPlayerEvent, GoToUnitEvent } from "../../TransformHandler";
|
||||
import { renderNumber } from "../../Utils";
|
||||
|
||||
import { PlaySoundEffectEvent } from "../../sound/Sounds";
|
||||
import { UIState } from "../../UIState";
|
||||
import { getMessageTypeClasses, translateText } from "../../Utils";
|
||||
const allianceIcon = assetUrl("images/AllianceIconWhite.svg");
|
||||
const chatIcon = assetUrl("images/ChatIconWhite.svg");
|
||||
const donateGoldIcon = assetUrl("images/DonateGoldIconWhite.svg");
|
||||
const nukeIcon = assetUrl("images/NukeIconWhite.svg");
|
||||
const swordIcon = assetUrl("images/SwordIconWhite.svg");
|
||||
import {
|
||||
getMessageTypeClasses,
|
||||
renderNumber,
|
||||
translateText,
|
||||
} from "../../Utils";
|
||||
|
||||
interface GameEvent {
|
||||
description: string;
|
||||
unsafeDescription?: boolean;
|
||||
buttons?: {
|
||||
text: string;
|
||||
className: string;
|
||||
action: () => void;
|
||||
preventClose?: boolean;
|
||||
}[];
|
||||
type: MessageType;
|
||||
highlight?: boolean;
|
||||
createdAt: number;
|
||||
onDelete?: () => void;
|
||||
// lower number: lower on the display
|
||||
priority?: number;
|
||||
duration?: Tick;
|
||||
focusID?: number;
|
||||
unitView?: UnitView;
|
||||
shouldDelete?: (game: GameView) => boolean;
|
||||
allianceID?: number;
|
||||
}
|
||||
|
||||
const TIER_1_TYPES: ReadonlySet<MessageType> = new Set([
|
||||
MessageType.NUKE_INBOUND,
|
||||
MessageType.HYDROGEN_BOMB_INBOUND,
|
||||
MessageType.MIRV_INBOUND,
|
||||
MessageType.NUKE_DETONATED,
|
||||
MessageType.NAVAL_INVASION_INBOUND,
|
||||
MessageType.ATTACK_REQUEST,
|
||||
MessageType.ALLIANCE_ACCEPTED,
|
||||
MessageType.ALLIANCE_BROKEN,
|
||||
MessageType.CONQUERED_PLAYER,
|
||||
MessageType.CHAT,
|
||||
MessageType.DONATION_RECEIVED,
|
||||
]);
|
||||
|
||||
const isTier1 = (type: MessageType): boolean => TIER_1_TYPES.has(type);
|
||||
|
||||
@customElement("events-display")
|
||||
export class EventsDisplay extends LitElement implements Controller {
|
||||
public eventBus: EventBus;
|
||||
@@ -76,21 +67,7 @@ export class EventsDisplay extends LitElement implements Controller {
|
||||
private active: boolean = false;
|
||||
private events: GameEvent[] = [];
|
||||
|
||||
// allianceID -> last checked at tick
|
||||
private alliancesCheckedAt = new Map<number, Tick>();
|
||||
@state() private _hidden: boolean = false;
|
||||
@state() private _isVisible: boolean = false;
|
||||
@state() private newEvents: number = 0;
|
||||
@state() private latestGoldAmount: bigint | null = null;
|
||||
@state() private goldAmountAnimating: boolean = false;
|
||||
private goldAmountTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
@state() private eventsFilters: Map<MessageCategory, boolean> = new Map([
|
||||
[MessageCategory.ATTACK, false],
|
||||
[MessageCategory.NUKE, false],
|
||||
[MessageCategory.TRADE, false],
|
||||
[MessageCategory.ALLIANCE, false],
|
||||
[MessageCategory.CHAT, false],
|
||||
]);
|
||||
|
||||
@query(".events-container")
|
||||
private _eventsContainer?: HTMLDivElement;
|
||||
@@ -136,40 +113,9 @@ export class EventsDisplay extends LitElement implements Controller {
|
||||
`;
|
||||
}
|
||||
|
||||
private renderToggleButton(src: string, category: MessageCategory) {
|
||||
// Adding the literal for the default size ensures tailwind will generate the class
|
||||
const toggleButtonSizeMap = { default: "h-5" };
|
||||
return this.renderButton({
|
||||
content: html`<img
|
||||
src="${src}"
|
||||
class="${toggleButtonSizeMap["default"]}"
|
||||
style="${this.eventsFilters.get(category)
|
||||
? "filter: grayscale(1) opacity(0.5);"
|
||||
: ""}"
|
||||
/>`,
|
||||
onClick: () => this.toggleEventFilter(category),
|
||||
className: "cursor-pointer pointer-events-auto",
|
||||
});
|
||||
}
|
||||
|
||||
private toggleHidden() {
|
||||
this._hidden = !this._hidden;
|
||||
if (this._hidden) {
|
||||
this.newEvents = 0;
|
||||
}
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private toggleEventFilter(filterName: MessageCategory) {
|
||||
const currentState = this.eventsFilters.get(filterName) ?? false;
|
||||
this.eventsFilters.set(filterName, !currentState);
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private updateMap = [
|
||||
[GameUpdateType.DisplayEvent, this.onDisplayMessageEvent.bind(this)],
|
||||
[GameUpdateType.DisplayChatEvent, this.onDisplayChatEvent.bind(this)],
|
||||
[GameUpdateType.AllianceRequest, this.onAllianceRequestEvent.bind(this)],
|
||||
[
|
||||
GameUpdateType.AllianceRequestReply,
|
||||
this.onAllianceRequestReplyEvent.bind(this),
|
||||
@@ -179,10 +125,7 @@ export class EventsDisplay extends LitElement implements Controller {
|
||||
[GameUpdateType.Emoji, this.onEmojiMessageEvent.bind(this)],
|
||||
[GameUpdateType.UnitIncoming, this.onUnitIncomingEvent.bind(this)],
|
||||
[GameUpdateType.AllianceExpired, this.onAllianceExpiredEvent.bind(this)],
|
||||
[
|
||||
GameUpdateType.AllianceExtension,
|
||||
this.onAllianceExtensionEvent.bind(this),
|
||||
],
|
||||
[GameUpdateType.DonateEvent, this.onDonateEvent.bind(this)],
|
||||
] as const;
|
||||
|
||||
constructor() {
|
||||
@@ -236,8 +179,6 @@ export class EventsDisplay extends LitElement implements Controller {
|
||||
return;
|
||||
}
|
||||
|
||||
this.checkForAllianceExpirations();
|
||||
|
||||
const updates = this.game.updatesSinceLastTick();
|
||||
if (updates) {
|
||||
for (const [ut, fn] of this.updateMap) {
|
||||
@@ -246,9 +187,17 @@ export class EventsDisplay extends LitElement implements Controller {
|
||||
}
|
||||
|
||||
let remainingEvents = this.events.filter((event) => {
|
||||
const shouldKeep =
|
||||
this.game.ticks() - event.createdAt < (event.duration ?? 600) &&
|
||||
!event.shouldDelete?.(this.game);
|
||||
const expired = this.game.ticks() - event.createdAt >= 80;
|
||||
const isInboundWarning =
|
||||
event.type === MessageType.NUKE_INBOUND ||
|
||||
event.type === MessageType.HYDROGEN_BOMB_INBOUND ||
|
||||
event.type === MessageType.MIRV_INBOUND ||
|
||||
event.type === MessageType.NAVAL_INVASION_INBOUND;
|
||||
const unitGone =
|
||||
isInboundWarning &&
|
||||
event.unitView !== undefined &&
|
||||
!event.unitView.isActive();
|
||||
const shouldKeep = !expired && !unitGone;
|
||||
if (!shouldKeep && event.onDelete) {
|
||||
event.onDelete();
|
||||
}
|
||||
@@ -267,108 +216,11 @@ export class EventsDisplay extends LitElement implements Controller {
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
if (this.goldAmountTimeoutId !== null) {
|
||||
clearTimeout(this.goldAmountTimeoutId);
|
||||
this.goldAmountTimeoutId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private checkForAllianceExpirations() {
|
||||
const myPlayer = this.game.myPlayer();
|
||||
if (!myPlayer?.isAlive()) return;
|
||||
|
||||
const currentAllianceIds = new Set<number>();
|
||||
|
||||
for (const alliance of myPlayer.alliances()) {
|
||||
currentAllianceIds.add(alliance.id);
|
||||
|
||||
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;
|
||||
|
||||
this.addEvent({
|
||||
description: translateText("events_display.about_to_expire", {
|
||||
name: other.displayName(),
|
||||
}),
|
||||
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.displayName(),
|
||||
}),
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
for (const [allianceId] of this.alliancesCheckedAt) {
|
||||
if (!currentAllianceIds.has(allianceId)) {
|
||||
this.removeAllianceRenewalEvents(allianceId);
|
||||
this.alliancesCheckedAt.delete(allianceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private addEvent(event: GameEvent) {
|
||||
this.events = [...this.events, event];
|
||||
if (this._hidden === true) {
|
||||
this.newEvents++;
|
||||
}
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private removeEvent(index: number) {
|
||||
this.events = [
|
||||
...this.events.slice(0, index),
|
||||
...this.events.slice(index + 1),
|
||||
];
|
||||
}
|
||||
|
||||
private removeAllianceRenewalEvents(allianceID: number) {
|
||||
this.events = this.events.filter(
|
||||
(event) =>
|
||||
!(
|
||||
event.type === MessageType.RENEW_ALLIANCE &&
|
||||
event.allianceID === allianceID
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
onDisplayMessageEvent(event: DisplayMessageUpdate) {
|
||||
const myPlayer = this.game.myPlayer();
|
||||
if (
|
||||
@@ -378,27 +230,10 @@ export class EventsDisplay extends LitElement implements Controller {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.goldAmount !== undefined) {
|
||||
const hasChanged = this.latestGoldAmount !== event.goldAmount;
|
||||
this.latestGoldAmount = event.goldAmount;
|
||||
|
||||
if (this.goldAmountTimeoutId !== null) {
|
||||
clearTimeout(this.goldAmountTimeoutId);
|
||||
}
|
||||
|
||||
this.goldAmountTimeoutId = setTimeout(() => {
|
||||
this.latestGoldAmount = null;
|
||||
this.goldAmountTimeoutId = null;
|
||||
this.requestUpdate();
|
||||
}, 5000);
|
||||
|
||||
if (hasChanged) {
|
||||
this.goldAmountAnimating = true;
|
||||
setTimeout(() => {
|
||||
this.goldAmountAnimating = false;
|
||||
this.requestUpdate();
|
||||
}, 600);
|
||||
}
|
||||
// Captured trade-ship gold is surfaced as a transient +gold pip in
|
||||
// control-panel rather than as a scroll-list entry.
|
||||
if (event.message === "events_display.received_gold_from_captured_ship") {
|
||||
return;
|
||||
}
|
||||
|
||||
let description: string = event.message;
|
||||
@@ -406,12 +241,16 @@ export class EventsDisplay extends LitElement implements Controller {
|
||||
description = translateText(event.message, event.params ?? {});
|
||||
}
|
||||
|
||||
const unitView =
|
||||
event.unitID !== undefined ? this.game.unit(event.unitID) : undefined;
|
||||
this.addEvent({
|
||||
description: description,
|
||||
createdAt: this.game.ticks(),
|
||||
highlight: true,
|
||||
type: event.messageType,
|
||||
unsafeDescription: true,
|
||||
unitView: unitView,
|
||||
focusID: event.focusPlayerID,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -461,81 +300,9 @@ export class EventsDisplay extends LitElement implements Controller {
|
||||
this.eventBus.emit(new PlaySoundEffectEvent("message"));
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
if (!requestor.isAlliedWith(recipient)) {
|
||||
this.eventBus.emit(new PlaySoundEffectEvent("alliance-suggested"));
|
||||
}
|
||||
this.addEvent({
|
||||
description: translateText("events_display.request_alliance", {
|
||||
name: requestor.displayName(),
|
||||
}),
|
||||
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 SendAllianceRequestIntentEvent(recipient, requestor),
|
||||
),
|
||||
},
|
||||
{
|
||||
text: translateText("events_display.reject_alliance"),
|
||||
className: "btn-info",
|
||||
action: () =>
|
||||
this.eventBus.emit(new SendAllianceRejectIntentEvent(requestor)),
|
||||
},
|
||||
],
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
// Noop unless the request was denied through other means (e.g attacking the requestor)
|
||||
this.events = this.events.filter(
|
||||
(event) =>
|
||||
!(
|
||||
event.type === MessageType.ALLIANCE_REQUEST &&
|
||||
event.focusID === update.request.requestorID
|
||||
),
|
||||
);
|
||||
this.requestUpdate();
|
||||
return;
|
||||
}
|
||||
if (update.request.requestorID !== myPlayer.smallID()) {
|
||||
if (!myPlayer || update.request.requestorID !== myPlayer.smallID()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -562,10 +329,6 @@ export class EventsDisplay extends LitElement implements Controller {
|
||||
const myPlayer = this.game.myPlayer();
|
||||
if (!myPlayer) return;
|
||||
|
||||
this.removeAllianceRenewalEvents(update.allianceID);
|
||||
this.alliancesCheckedAt.delete(update.allianceID);
|
||||
this.requestUpdate();
|
||||
|
||||
const betrayed = this.game.playerBySmallID(update.betrayedID) as PlayerView;
|
||||
const traitor = this.game.playerBySmallID(update.traitorID) as PlayerView;
|
||||
|
||||
@@ -600,14 +363,6 @@ export class EventsDisplay extends LitElement implements Controller {
|
||||
});
|
||||
} else if (betrayed === myPlayer) {
|
||||
this.eventBus.emit(new PlaySoundEffectEvent("alliance-broken"));
|
||||
const buttons = [
|
||||
{
|
||||
text: translateText("events_display.focus"),
|
||||
className: "btn-gray",
|
||||
action: () => this.eventBus.emit(new GoToPlayerEvent(traitor)),
|
||||
preventClose: true,
|
||||
},
|
||||
];
|
||||
this.addEvent({
|
||||
description: translateText("events_display.betrayed_you", {
|
||||
name: traitor.displayName(),
|
||||
@@ -616,7 +371,6 @@ export class EventsDisplay extends LitElement implements Controller {
|
||||
highlight: true,
|
||||
createdAt: this.game.ticks(),
|
||||
focusID: update.traitorID,
|
||||
buttons,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -646,11 +400,40 @@ export class EventsDisplay extends LitElement implements Controller {
|
||||
});
|
||||
}
|
||||
|
||||
private onAllianceExtensionEvent(update: AllianceExtensionUpdate) {
|
||||
onDonateEvent(update: DonateEventUpdate) {
|
||||
const myPlayer = this.game.myPlayer();
|
||||
if (!myPlayer || myPlayer.smallID() !== update.playerID) return;
|
||||
this.removeAllianceRenewalEvents(update.allianceID);
|
||||
this.requestUpdate();
|
||||
if (!myPlayer) return;
|
||||
|
||||
const isRecipient = update.recipientId === myPlayer.id();
|
||||
const isSender = update.senderId === myPlayer.id();
|
||||
if (!isRecipient && !isSender) return;
|
||||
|
||||
const other = isRecipient
|
||||
? (this.game.player(update.senderId) as PlayerView)
|
||||
: (this.game.player(update.recipientId) as PlayerView);
|
||||
|
||||
const isGold = update.donationType === "gold";
|
||||
const messageKey = isRecipient
|
||||
? isGold
|
||||
? "events_display.received_gold_from_player"
|
||||
: "events_display.received_troops_from_player"
|
||||
: isGold
|
||||
? "events_display.sent_gold_to_player"
|
||||
: "events_display.sent_troops_to_player";
|
||||
const params: Record<string, string | number> = {
|
||||
name: other.displayName(),
|
||||
[isGold ? "gold" : "troops"]: renderNumber(update.amount),
|
||||
};
|
||||
|
||||
this.addEvent({
|
||||
description: translateText(messageKey, params),
|
||||
type: isRecipient
|
||||
? MessageType.DONATION_RECEIVED
|
||||
: MessageType.DONATION_SENT,
|
||||
highlight: true,
|
||||
createdAt: this.game.ticks(),
|
||||
focusID: other.smallID(),
|
||||
});
|
||||
}
|
||||
|
||||
onTargetPlayerEvent(event: TargetPlayerUpdate) {
|
||||
@@ -769,244 +552,107 @@ export class EventsDisplay extends LitElement implements Controller {
|
||||
`;
|
||||
}
|
||||
|
||||
private renderEventRow(event: GameEvent) {
|
||||
return html`
|
||||
<tr>
|
||||
<td
|
||||
class="lg:px-2 lg:py-1 p-1 text-left ${getMessageTypeClasses(
|
||||
event.type,
|
||||
)}"
|
||||
>
|
||||
${event.focusID
|
||||
? this.renderButton({
|
||||
content: this.getEventDescription(event),
|
||||
onClick: () => {
|
||||
if (event.focusID) this.emitGoToPlayerEvent(event.focusID);
|
||||
},
|
||||
className: "text-left",
|
||||
})
|
||||
: event.unitView
|
||||
? this.renderButton({
|
||||
content: this.getEventDescription(event),
|
||||
onClick: () => {
|
||||
if (event.unitView) this.emitGoToUnitEvent(event.unitView);
|
||||
},
|
||||
className: "text-left",
|
||||
})
|
||||
: this.getEventDescription(event)}
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.active || !this._isVisible) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
const styles = html`
|
||||
<style>
|
||||
@keyframes goldBounce {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
30% {
|
||||
transform: scale(1.3);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
70% {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
const myPlayer = this.game.myPlayer();
|
||||
const showBetrayalTimer = !!(
|
||||
myPlayer &&
|
||||
myPlayer.isTraitor() &&
|
||||
myPlayer.getTraitorRemainingTicks() > 0
|
||||
);
|
||||
|
||||
const filteredEvents = this.events.filter((event) => {
|
||||
const category = getMessageCategory(event.type);
|
||||
return !this.eventsFilters.get(category);
|
||||
});
|
||||
const tier1Events: GameEvent[] = [];
|
||||
let tier2Events: GameEvent[] = [];
|
||||
for (const event of this.events) {
|
||||
(isTier1(event.type) ? tier1Events : tier2Events).push(event);
|
||||
}
|
||||
tier1Events.sort((a, b) => a.createdAt - b.createdAt);
|
||||
tier2Events.sort((a, b) => a.createdAt - b.createdAt);
|
||||
tier2Events = tier2Events.slice(-4);
|
||||
|
||||
filteredEvents.sort((a, b) => {
|
||||
const aPrior = a.priority ?? 100000;
|
||||
const bPrior = b.priority ?? 100000;
|
||||
if (aPrior === bPrior) {
|
||||
return a.createdAt - b.createdAt;
|
||||
}
|
||||
return bPrior - aPrior;
|
||||
});
|
||||
if (
|
||||
tier1Events.length === 0 &&
|
||||
tier2Events.length === 0 &&
|
||||
!showBetrayalTimer
|
||||
) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
return html`
|
||||
${styles}
|
||||
<!-- Events Toggle (when hidden) -->
|
||||
${this._hidden
|
||||
? html`
|
||||
<div class="relative w-fit z-50">
|
||||
${this.renderButton({
|
||||
content: html`
|
||||
<span class="flex items-center gap-2">
|
||||
${translateText("events_display.events")}
|
||||
${this.newEvents > 0
|
||||
? html`<span
|
||||
class="inline-block px-2 bg-red-500 rounded-lg text-sm"
|
||||
>${this.newEvents}</span
|
||||
>`
|
||||
: ""}
|
||||
</span>
|
||||
`,
|
||||
onClick: this.toggleHidden,
|
||||
className:
|
||||
"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/92 backdrop-blur-sm",
|
||||
})}
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<!-- Main Events Display -->
|
||||
<div
|
||||
class="relative w-full z-50 min-[1200px]:w-96 backdrop-blur-sm"
|
||||
>
|
||||
<!-- Button Bar -->
|
||||
<div class="flex flex-col gap-1 w-full min-[1200px]:w-96">
|
||||
${tier2Events.length > 0
|
||||
? html`
|
||||
<div
|
||||
class="w-full p-2 lg:p-3 bg-gray-800/92 backdrop-blur-sm sm:rounded-tl-lg min-[1200px]:rounded-t-lg"
|
||||
class="bg-gray-800/92 backdrop-blur-sm max-h-[12vh] lg:max-h-[22vh] overflow-y-auto rounded-lg opacity-90 events-container"
|
||||
>
|
||||
<div class="flex justify-between items-center gap-3">
|
||||
<div class="flex gap-4">
|
||||
${this.renderToggleButton(
|
||||
swordIcon,
|
||||
MessageCategory.ATTACK,
|
||||
)}
|
||||
${this.renderToggleButton(nukeIcon, MessageCategory.NUKE)}
|
||||
${this.renderToggleButton(
|
||||
donateGoldIcon,
|
||||
MessageCategory.TRADE,
|
||||
)}
|
||||
${this.renderToggleButton(
|
||||
allianceIcon,
|
||||
MessageCategory.ALLIANCE,
|
||||
)}
|
||||
${this.renderToggleButton(chatIcon, MessageCategory.CHAT)}
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
${this.latestGoldAmount !== null
|
||||
? html`<span
|
||||
class="text-green-400 font-semibold transition-all duration-300 ${this
|
||||
.goldAmountAnimating
|
||||
? "animate-pulse scale-110"
|
||||
: "scale-100"}"
|
||||
style="animation: ${this.goldAmountAnimating
|
||||
? "goldBounce 0.6s ease-out"
|
||||
: "none"}"
|
||||
>+${renderNumber(this.latestGoldAmount)}</span
|
||||
>`
|
||||
: ""}
|
||||
${this.renderButton({
|
||||
content: translateText("leaderboard.hide"),
|
||||
onClick: this.toggleHidden,
|
||||
className:
|
||||
"text-white cursor-pointer pointer-events-auto",
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<table
|
||||
class="w-full border-collapse text-white text-xs lg:text-sm pointer-events-auto"
|
||||
>
|
||||
<tbody>
|
||||
${tier2Events.map((event) => this.renderEventRow(event))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Content Area -->
|
||||
`
|
||||
: ""}
|
||||
${tier1Events.length > 0 || showBetrayalTimer
|
||||
? html`
|
||||
<div
|
||||
class="bg-gray-800/92 backdrop-blur-sm max-h-[15vh] lg:max-h-[30vh] overflow-y-auto w-full h-full min-[1200px]:rounded-b-xl events-container"
|
||||
class="bg-gray-800 backdrop-blur-sm rounded-lg shadow-lg border-l-4 border-red-500"
|
||||
>
|
||||
<div>
|
||||
<table
|
||||
class="w-full max-h-none border-collapse text-white shadow-lg text-xs lg:text-sm pointer-events-auto"
|
||||
>
|
||||
<tbody>
|
||||
${filteredEvents.map(
|
||||
(event, index) => html`
|
||||
<table
|
||||
class="w-full border-collapse text-white text-base lg:text-lg font-medium pointer-events-auto"
|
||||
>
|
||||
<tbody>
|
||||
${tier1Events.map((event) => this.renderEventRow(event))}
|
||||
${showBetrayalTimer
|
||||
? html`
|
||||
<tr>
|
||||
<td
|
||||
class="lg:px-2 lg:py-1 p-1 text-left ${getMessageTypeClasses(
|
||||
event.type,
|
||||
)}"
|
||||
>
|
||||
${event.focusID
|
||||
? this.renderButton({
|
||||
content: this.getEventDescription(event),
|
||||
onClick: () => {
|
||||
if (event.focusID)
|
||||
this.emitGoToPlayerEvent(event.focusID);
|
||||
},
|
||||
className: "text-left",
|
||||
})
|
||||
: event.unitView
|
||||
? this.renderButton({
|
||||
content: this.getEventDescription(event),
|
||||
onClick: () => {
|
||||
if (event.unitView)
|
||||
this.emitGoToUnitEvent(
|
||||
event.unitView,
|
||||
);
|
||||
},
|
||||
className: "text-left",
|
||||
})
|
||||
: this.getEventDescription(event)}
|
||||
<!-- Events with buttons (Alliance requests) -->
|
||||
${event.buttons
|
||||
? html`
|
||||
<div class="flex flex-wrap gap-1.5 mt-1">
|
||||
${event.buttons.map(
|
||||
(btn) => html`
|
||||
<button
|
||||
class="inline-block px-3 py-1 text-white rounded-sm text-xs lg:text-sm cursor-pointer transition-colors duration-300
|
||||
${btn.className.includes("btn-info")
|
||||
? "bg-blue-500 hover:bg-blue-600"
|
||||
: btn.className.includes(
|
||||
"btn-gray",
|
||||
)
|
||||
? "bg-gray-500 hover:bg-gray-600"
|
||||
: "bg-green-600 hover:bg-green-700"}"
|
||||
@click=${() => {
|
||||
btn.action();
|
||||
if (!btn.preventClose) {
|
||||
const originalIndex =
|
||||
this.events.findIndex(
|
||||
(e) => e === event,
|
||||
);
|
||||
if (originalIndex !== -1) {
|
||||
this.removeEvent(
|
||||
originalIndex,
|
||||
);
|
||||
}
|
||||
}
|
||||
this.requestUpdate();
|
||||
}}
|
||||
>
|
||||
${btn.text}
|
||||
</button>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
<td class="lg:px-2 lg:py-1 p-1 text-left">
|
||||
${this.renderBetrayalDebuffTimer()}
|
||||
</td>
|
||||
</tr>
|
||||
`,
|
||||
)}
|
||||
<!--- Betrayal debuff timer row -->
|
||||
${(() => {
|
||||
const myPlayer = this.game.myPlayer();
|
||||
return (
|
||||
myPlayer &&
|
||||
myPlayer.isTraitor() &&
|
||||
myPlayer.getTraitorRemainingTicks() > 0
|
||||
);
|
||||
})()
|
||||
? html`
|
||||
<tr class="lg:px-2 lg:py-1 p-1">
|
||||
<td class="lg:px-2 lg:py-1 p-1 text-left">
|
||||
${this.renderBetrayalDebuffTimer()}
|
||||
</td>
|
||||
</tr>
|
||||
`
|
||||
: ""}
|
||||
|
||||
<!--- Empty row when no events -->
|
||||
${filteredEvents.length === 0 &&
|
||||
!(() => {
|
||||
const myPlayer = this.game.myPlayer();
|
||||
return (
|
||||
myPlayer &&
|
||||
myPlayer.isTraitor() &&
|
||||
myPlayer.getTraitorRemainingTicks() > 0
|
||||
);
|
||||
})()
|
||||
? html`
|
||||
<tr>
|
||||
<td
|
||||
class="lg:px-2 lg:py-1 p-1 min-w-72 text-left"
|
||||
>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
`
|
||||
: ""}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
`
|
||||
: ""}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -35,16 +35,6 @@ export const GameUpdateType = {
|
||||
NukeDetonation: 22,
|
||||
} as const;
|
||||
|
||||
/** MessageType enum values from the game source. */
|
||||
export const MessageType = {
|
||||
SAM_HIT: 9,
|
||||
SENT_GOLD_TO_PLAYER: 18,
|
||||
RECEIVED_GOLD_FROM_PLAYER: 19,
|
||||
RECEIVED_GOLD_FROM_TRADE: 20,
|
||||
SENT_TROOPS_TO_PLAYER: 21,
|
||||
RECEIVED_TROOPS_FROM_PLAYER: 22,
|
||||
} as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Typed update payloads (keyed by GameUpdateType values)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -61,7 +61,7 @@ export type {
|
||||
} from "./Replay";
|
||||
|
||||
// Game update type constants and event payloads (shared between shim + codec)
|
||||
export { GameUpdateType, MessageType } from "./GameUpdates";
|
||||
export { GameUpdateType } from "./GameUpdates";
|
||||
export type {
|
||||
AllianceExpiredUpdate,
|
||||
AllianceReplyUpdate,
|
||||
|
||||
@@ -386,6 +386,27 @@ export class NukeExecution implements Execution {
|
||||
this.nuke.setReachedTarget();
|
||||
this.nuke.delete(false);
|
||||
|
||||
if (
|
||||
this.nukeType === UnitType.AtomBomb ||
|
||||
this.nukeType === UnitType.HydrogenBomb
|
||||
) {
|
||||
const messageKey =
|
||||
this.nukeType === UnitType.AtomBomb
|
||||
? "events_display.atom_bomb_detonated"
|
||||
: "events_display.hydrogen_bomb_detonated";
|
||||
for (const [impactedPlayer] of tilesPerPlayers) {
|
||||
mg.displayMessage(
|
||||
messageKey,
|
||||
MessageType.NUKE_DETONATED,
|
||||
impactedPlayer.id(),
|
||||
undefined,
|
||||
{ name: this.player.displayName() },
|
||||
undefined,
|
||||
this.player.id(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Record stats
|
||||
this.mg
|
||||
.stats()
|
||||
|
||||
@@ -189,28 +189,8 @@ export class TradeShipExecution implements Execution {
|
||||
.stats()
|
||||
.boatCapturedTrade(this.tradeShip!.owner(), this.origOwner, gold);
|
||||
} else {
|
||||
this.srcPort.owner().addGold(gold);
|
||||
this.srcPort.owner().addGold(gold, this.srcPort.tile());
|
||||
this._dstPort.owner().addGold(gold, this._dstPort.tile());
|
||||
this.mg.displayMessage(
|
||||
"events_display.received_gold_from_trade",
|
||||
MessageType.RECEIVED_GOLD_FROM_TRADE,
|
||||
this._dstPort.owner().id(),
|
||||
gold,
|
||||
{
|
||||
gold: renderNumber(gold),
|
||||
name: this.srcPort.owner().displayName(),
|
||||
},
|
||||
);
|
||||
this.mg.displayMessage(
|
||||
"events_display.received_gold_from_trade",
|
||||
MessageType.RECEIVED_GOLD_FROM_TRADE,
|
||||
this.srcPort.owner().id(),
|
||||
gold,
|
||||
{
|
||||
gold: renderNumber(gold),
|
||||
name: this._dstPort.owner().displayName(),
|
||||
},
|
||||
);
|
||||
// Record stats
|
||||
this.mg
|
||||
.stats()
|
||||
|
||||
+8
-10
@@ -926,6 +926,8 @@ export interface Game extends GameMap {
|
||||
playerID: PlayerID | null,
|
||||
goldAmount?: bigint,
|
||||
params?: Record<string, string | number>,
|
||||
unitID?: number,
|
||||
focusPlayerID?: PlayerID,
|
||||
): void;
|
||||
displayIncomingUnit(
|
||||
unitID: number,
|
||||
@@ -1030,6 +1032,7 @@ export enum MessageType {
|
||||
CONQUERED_PLAYER,
|
||||
MIRV_INBOUND,
|
||||
NUKE_INBOUND,
|
||||
NUKE_DETONATED,
|
||||
HYDROGEN_BOMB_INBOUND,
|
||||
NAVAL_INVASION_INBOUND,
|
||||
SAM_MISS,
|
||||
@@ -1042,11 +1045,8 @@ export enum MessageType {
|
||||
ALLIANCE_REQUEST,
|
||||
ALLIANCE_BROKEN,
|
||||
ALLIANCE_EXPIRED,
|
||||
SENT_GOLD_TO_PLAYER,
|
||||
RECEIVED_GOLD_FROM_PLAYER,
|
||||
RECEIVED_GOLD_FROM_TRADE,
|
||||
SENT_TROOPS_TO_PLAYER,
|
||||
RECEIVED_TROOPS_FROM_PLAYER,
|
||||
DONATION_SENT,
|
||||
DONATION_RECEIVED,
|
||||
CHAT,
|
||||
RENEW_ALLIANCE,
|
||||
}
|
||||
@@ -1068,6 +1068,7 @@ export const MESSAGE_TYPE_CATEGORIES: Record<MessageType, MessageCategory> = {
|
||||
[MessageType.CONQUERED_PLAYER]: MessageCategory.ATTACK,
|
||||
[MessageType.MIRV_INBOUND]: MessageCategory.NUKE,
|
||||
[MessageType.NUKE_INBOUND]: MessageCategory.NUKE,
|
||||
[MessageType.NUKE_DETONATED]: MessageCategory.NUKE,
|
||||
[MessageType.HYDROGEN_BOMB_INBOUND]: MessageCategory.NUKE,
|
||||
[MessageType.NAVAL_INVASION_INBOUND]: MessageCategory.ATTACK,
|
||||
[MessageType.SAM_MISS]: MessageCategory.ATTACK,
|
||||
@@ -1081,11 +1082,8 @@ export const MESSAGE_TYPE_CATEGORIES: Record<MessageType, MessageCategory> = {
|
||||
[MessageType.ALLIANCE_BROKEN]: MessageCategory.ALLIANCE,
|
||||
[MessageType.ALLIANCE_EXPIRED]: MessageCategory.ALLIANCE,
|
||||
[MessageType.RENEW_ALLIANCE]: MessageCategory.ALLIANCE,
|
||||
[MessageType.SENT_GOLD_TO_PLAYER]: MessageCategory.TRADE,
|
||||
[MessageType.RECEIVED_GOLD_FROM_PLAYER]: MessageCategory.TRADE,
|
||||
[MessageType.RECEIVED_GOLD_FROM_TRADE]: MessageCategory.TRADE,
|
||||
[MessageType.SENT_TROOPS_TO_PLAYER]: MessageCategory.TRADE,
|
||||
[MessageType.RECEIVED_TROOPS_FROM_PLAYER]: MessageCategory.TRADE,
|
||||
[MessageType.DONATION_SENT]: MessageCategory.TRADE,
|
||||
[MessageType.DONATION_RECEIVED]: MessageCategory.TRADE,
|
||||
[MessageType.CHAT]: MessageCategory.CHAT,
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -926,11 +926,17 @@ export class GameImpl implements Game {
|
||||
playerID: PlayerID | null,
|
||||
goldAmount?: bigint,
|
||||
params?: Record<string, string | number>,
|
||||
unitID?: number,
|
||||
focusPlayerID?: PlayerID,
|
||||
): void {
|
||||
let id: number | null = null;
|
||||
if (playerID !== null) {
|
||||
id = this.player(playerID).smallID();
|
||||
}
|
||||
const focusID =
|
||||
focusPlayerID !== undefined
|
||||
? this.player(focusPlayerID).smallID()
|
||||
: undefined;
|
||||
this.addUpdate({
|
||||
type: GameUpdateType.DisplayEvent,
|
||||
messageType: type,
|
||||
@@ -938,6 +944,8 @@ export class GameImpl implements Game {
|
||||
playerID: id,
|
||||
goldAmount: goldAmount,
|
||||
params: params,
|
||||
unitID: unitID,
|
||||
focusPlayerID: focusID,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ export enum GameUpdateType {
|
||||
EmbargoEvent,
|
||||
SpawnPhaseEnd,
|
||||
GamePaused,
|
||||
DonateEvent,
|
||||
}
|
||||
|
||||
export type GameUpdate =
|
||||
@@ -92,7 +93,8 @@ export type GameUpdate =
|
||||
| ConquestUpdate
|
||||
| EmbargoUpdate
|
||||
| SpawnPhaseEndUpdate
|
||||
| GamePausedUpdate;
|
||||
| GamePausedUpdate
|
||||
| DonateEventUpdate;
|
||||
|
||||
export interface BonusEventUpdate {
|
||||
type: GameUpdateType.BonusEvent;
|
||||
@@ -129,6 +131,14 @@ export interface ConquestUpdate {
|
||||
gold: Gold;
|
||||
}
|
||||
|
||||
export interface DonateEventUpdate {
|
||||
type: GameUpdateType.DonateEvent;
|
||||
donationType: "troops" | "gold";
|
||||
senderId: PlayerID;
|
||||
recipientId: PlayerID;
|
||||
amount: bigint;
|
||||
}
|
||||
|
||||
export interface UnitUpdate {
|
||||
type: GameUpdateType.Unit;
|
||||
unitType: UnitType;
|
||||
@@ -262,6 +272,8 @@ export interface DisplayMessageUpdate {
|
||||
goldAmount?: bigint;
|
||||
playerID: number | null;
|
||||
params?: Record<string, string | number>;
|
||||
unitID?: number;
|
||||
focusPlayerID?: number;
|
||||
}
|
||||
|
||||
export type DisplayChatMessageUpdate = {
|
||||
|
||||
+14
-30
@@ -1,4 +1,3 @@
|
||||
import { renderNumber, renderTroops } from "../../client/Utils";
|
||||
import { PseudoRandom } from "../PseudoRandom";
|
||||
import { ClientID } from "../Schemas";
|
||||
import {
|
||||
@@ -23,7 +22,6 @@ import {
|
||||
EmojiMessage,
|
||||
GameMode,
|
||||
Gold,
|
||||
MessageType,
|
||||
MutableAlliance,
|
||||
Player,
|
||||
PlayerBuildable,
|
||||
@@ -822,20 +820,13 @@ export class PlayerImpl implements Player {
|
||||
recipient.addTroops(removed);
|
||||
|
||||
this.sentDonations.push(new Donation(recipient, this.mg.ticks()));
|
||||
this.mg.displayMessage(
|
||||
"events_display.sent_troops_to_player",
|
||||
MessageType.SENT_TROOPS_TO_PLAYER,
|
||||
this.id(),
|
||||
undefined,
|
||||
{ troops: renderTroops(troops), name: recipient.displayName() },
|
||||
);
|
||||
this.mg.displayMessage(
|
||||
"events_display.received_troops_from_player",
|
||||
MessageType.RECEIVED_TROOPS_FROM_PLAYER,
|
||||
recipient.id(),
|
||||
undefined,
|
||||
{ troops: renderTroops(troops), name: this.displayName() },
|
||||
);
|
||||
this.mg.addUpdate({
|
||||
type: GameUpdateType.DonateEvent,
|
||||
donationType: "troops",
|
||||
senderId: this.id(),
|
||||
recipientId: recipient.id(),
|
||||
amount: BigInt(removed),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -846,20 +837,13 @@ export class PlayerImpl implements Player {
|
||||
recipient.addGold(removed);
|
||||
|
||||
this.sentDonations.push(new Donation(recipient, this.mg.ticks()));
|
||||
this.mg.displayMessage(
|
||||
"events_display.sent_gold_to_player",
|
||||
MessageType.SENT_GOLD_TO_PLAYER,
|
||||
this.id(),
|
||||
undefined,
|
||||
{ gold: renderNumber(gold), name: recipient.displayName() },
|
||||
);
|
||||
this.mg.displayMessage(
|
||||
"events_display.received_gold_from_player",
|
||||
MessageType.RECEIVED_GOLD_FROM_PLAYER,
|
||||
recipient.id(),
|
||||
gold,
|
||||
{ gold: renderNumber(gold), name: this.displayName() },
|
||||
);
|
||||
this.mg.addUpdate({
|
||||
type: GameUpdateType.DonateEvent,
|
||||
donationType: "gold",
|
||||
senderId: this.id(),
|
||||
recipientId: recipient.id(),
|
||||
amount: removed,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -219,6 +219,7 @@ export class UnitImpl implements Unit {
|
||||
this._lastOwner.id(),
|
||||
undefined,
|
||||
{ unit: this.type(), name: newOwner.displayName() },
|
||||
this.id(),
|
||||
);
|
||||
this.mg.displayMessage(
|
||||
"events_display.captured_enemy_unit",
|
||||
@@ -226,6 +227,7 @@ export class UnitImpl implements Unit {
|
||||
newOwner.id(),
|
||||
undefined,
|
||||
{ unit: this.type(), name: this._lastOwner.displayName() },
|
||||
this.id(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -336,6 +338,7 @@ export class UnitImpl implements Unit {
|
||||
this.owner().id(),
|
||||
undefined,
|
||||
{ unit: this._type },
|
||||
this.id(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+9
-9
@@ -21,10 +21,10 @@ vi.mock("lit/directives/unsafe-html.js", () => ({
|
||||
UnsafeHTMLDirective: class {},
|
||||
}));
|
||||
|
||||
import { EventsDisplay } from "../../../../src/client/hud/layers/EventsDisplay";
|
||||
import { ActionableEvents } from "../../../../src/client/hud/layers/ActionableEvents";
|
||||
import { MessageType } from "../../../../src/core/game/Game";
|
||||
|
||||
describe("EventsDisplay - alliance renewal cleanup (allianceID based)", () => {
|
||||
describe("ActionableEvents - alliance renewal cleanup (allianceID based)", () => {
|
||||
function makeRenewal(
|
||||
allianceID: number,
|
||||
focusID: number,
|
||||
@@ -40,7 +40,7 @@ describe("EventsDisplay - alliance renewal cleanup (allianceID based)", () => {
|
||||
}
|
||||
|
||||
test("removes ONLY renewal events for the broken alliance", () => {
|
||||
const display = new EventsDisplay();
|
||||
const display = new ActionableEvents();
|
||||
|
||||
const allianceAB = 1;
|
||||
const allianceAC = 2;
|
||||
@@ -67,7 +67,7 @@ describe("EventsDisplay - alliance renewal cleanup (allianceID based)", () => {
|
||||
});
|
||||
|
||||
test("does NOT remove renewals just because the same player is involved", () => {
|
||||
const display = new EventsDisplay();
|
||||
const display = new ActionableEvents();
|
||||
|
||||
const allianceAB = 10;
|
||||
const allianceAC = 11;
|
||||
@@ -86,7 +86,7 @@ describe("EventsDisplay - alliance renewal cleanup (allianceID based)", () => {
|
||||
});
|
||||
|
||||
test("breaking one alliance does not affect renewals between other players", () => {
|
||||
const display = new EventsDisplay();
|
||||
const display = new ActionableEvents();
|
||||
|
||||
const allianceAB = 100;
|
||||
const allianceCD = 200;
|
||||
@@ -105,7 +105,7 @@ describe("EventsDisplay - alliance renewal cleanup (allianceID based)", () => {
|
||||
});
|
||||
|
||||
test("onAllianceExtensionEvent removes renewal when playerID matches myPlayer", () => {
|
||||
const display = new EventsDisplay();
|
||||
const display = new ActionableEvents();
|
||||
|
||||
const allianceID = 42;
|
||||
const mySmallID = 7;
|
||||
@@ -127,7 +127,7 @@ describe("EventsDisplay - alliance renewal cleanup (allianceID based)", () => {
|
||||
});
|
||||
|
||||
test("onAllianceExtensionEvent keeps renewal when playerID does not match myPlayer", () => {
|
||||
const display = new EventsDisplay();
|
||||
const display = new ActionableEvents();
|
||||
|
||||
const allianceID = 42;
|
||||
const mySmallID = 7;
|
||||
@@ -150,7 +150,7 @@ describe("EventsDisplay - alliance renewal cleanup (allianceID based)", () => {
|
||||
});
|
||||
|
||||
test("onAllianceExtensionEvent keeps renewal when myPlayer is null", () => {
|
||||
const display = new EventsDisplay();
|
||||
const display = new ActionableEvents();
|
||||
|
||||
const allianceID = 42;
|
||||
|
||||
@@ -171,7 +171,7 @@ describe("EventsDisplay - alliance renewal cleanup (allianceID based)", () => {
|
||||
});
|
||||
|
||||
test("does not affect non-RENEW_ALLIANCE events", () => {
|
||||
const display = new EventsDisplay();
|
||||
const display = new ActionableEvents();
|
||||
|
||||
(display as any).events = [
|
||||
{
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NukeExecution } from "../../../src/core/execution/NukeExecution";
|
||||
import {
|
||||
Game,
|
||||
MessageType,
|
||||
Player,
|
||||
PlayerInfo,
|
||||
PlayerType,
|
||||
@@ -119,6 +120,99 @@ describe("NukeExecution", () => {
|
||||
expect(player.isAlliedWith(otherPlayer)).toBe(false);
|
||||
});
|
||||
|
||||
test("AtomBomb detonation emits NUKE_DETONATED to each impacted player", () => {
|
||||
player.buildUnit(UnitType.MissileSilo, game.ref(1, 1), {});
|
||||
// Give otherPlayer a cluster around (50,50) so the blast intersects them.
|
||||
for (let x = 48; x < 53; x++) {
|
||||
for (let y = 48; y < 53; y++) {
|
||||
otherPlayer.conquer(game.ref(x, y));
|
||||
}
|
||||
}
|
||||
|
||||
const displayMessageSpy = vi.spyOn(game, "displayMessage");
|
||||
|
||||
game.addExecution(
|
||||
new NukeExecution(
|
||||
UnitType.AtomBomb,
|
||||
player,
|
||||
game.ref(50, 50),
|
||||
game.ref(1, 1),
|
||||
),
|
||||
);
|
||||
executeTicks(game, 200);
|
||||
|
||||
const detonatedCalls = displayMessageSpy.mock.calls.filter(
|
||||
(call) => call[1] === MessageType.NUKE_DETONATED,
|
||||
);
|
||||
expect(detonatedCalls.length).toBeGreaterThan(0);
|
||||
const otherCall = detonatedCalls.find(
|
||||
(call) => call[2] === otherPlayer.id(),
|
||||
);
|
||||
expect(otherCall).toBeDefined();
|
||||
expect(otherCall![0]).toBe("events_display.atom_bomb_detonated");
|
||||
// focusPlayerID (7th positional) is the launcher
|
||||
expect(otherCall![6]).toBe(player.id());
|
||||
|
||||
displayMessageSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("HydrogenBomb detonation emits NUKE_DETONATED with hydrogen_bomb key", () => {
|
||||
player.buildUnit(UnitType.MissileSilo, game.ref(1, 1), {});
|
||||
for (let x = 48; x < 53; x++) {
|
||||
for (let y = 48; y < 53; y++) {
|
||||
otherPlayer.conquer(game.ref(x, y));
|
||||
}
|
||||
}
|
||||
|
||||
const displayMessageSpy = vi.spyOn(game, "displayMessage");
|
||||
|
||||
game.addExecution(
|
||||
new NukeExecution(
|
||||
UnitType.HydrogenBomb,
|
||||
player,
|
||||
game.ref(50, 50),
|
||||
game.ref(1, 1),
|
||||
),
|
||||
);
|
||||
executeTicks(game, 300);
|
||||
|
||||
const detonatedCalls = displayMessageSpy.mock.calls.filter(
|
||||
(call) => call[1] === MessageType.NUKE_DETONATED,
|
||||
);
|
||||
expect(detonatedCalls.length).toBeGreaterThan(0);
|
||||
expect(detonatedCalls[0][0]).toBe("events_display.hydrogen_bomb_detonated");
|
||||
|
||||
displayMessageSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("MIRVWarhead detonation does NOT emit NUKE_DETONATED", () => {
|
||||
player.buildUnit(UnitType.MissileSilo, game.ref(1, 1), {});
|
||||
for (let x = 48; x < 53; x++) {
|
||||
for (let y = 48; y < 53; y++) {
|
||||
otherPlayer.conquer(game.ref(x, y));
|
||||
}
|
||||
}
|
||||
|
||||
const displayMessageSpy = vi.spyOn(game, "displayMessage");
|
||||
|
||||
game.addExecution(
|
||||
new NukeExecution(
|
||||
UnitType.MIRVWarhead,
|
||||
player,
|
||||
game.ref(50, 50),
|
||||
game.ref(1, 1),
|
||||
),
|
||||
);
|
||||
executeTicks(game, 200);
|
||||
|
||||
const detonatedCalls = displayMessageSpy.mock.calls.filter(
|
||||
(call) => call[1] === MessageType.NUKE_DETONATED,
|
||||
);
|
||||
expect(detonatedCalls).toHaveLength(0);
|
||||
|
||||
displayMessageSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("nuke should break alliance when destroying ally's building even with few tiles", async () => {
|
||||
const req = player.createAllianceRequest(otherPlayer);
|
||||
req!.accept();
|
||||
|
||||
@@ -143,6 +143,7 @@ describe("TradeShipExecution", () => {
|
||||
tradeShipExecution.tick(1);
|
||||
expect(tradeShip.delete).toHaveBeenCalledWith(false);
|
||||
expect(tradeShipExecution.isActive()).toBe(false);
|
||||
expect(game.displayMessage).toHaveBeenCalled();
|
||||
expect(origOwner.addGold).toHaveBeenCalled();
|
||||
expect(dstOwner.addGold).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user