Files
OpenFrontIO/src/client/graphics/layers/EventsDisplay.ts
T
Cameron Clark 18da7134c8 Implement FX sound effects (#3394)
## Description:
Adds sound effects for approved events from the [sound asset
pack](https://drive.google.com/drive/folders/1KpGYJkmLxipy8XmTeyHf40XDC4P--Ck8?usp=sharing).
15 new sound effects triggered from `FxLayer`, `EventsDisplay`, and
`RadialMenu`. Sounds play even when visual FX are off, so disabling
explosions doesn't kill audio. Unapproved sounds are included as assets
but not wired up yet.

### SoundManager architecture

Reworked `SoundManager` per [maintainer
feedback](https://github.com/openfrontio/OpenFrontIO/issues/1893#issuecomment-4184649434)
and [follow-up
review](https://github.com/openfrontio/OpenFrontIO/pull/3394):

- No more singleton. `SoundManager` is instantiated in
`createClientGame()` with `EventBus` and `UserSettings`
- Layers emit events (`PlaySoundEffectEvent`,
`SetBackgroundMusicVolumeEvent`, `SetSoundEffectsVolumeEvent`) via
EventBus instead of holding a `SoundManager` reference
- `SoundManager` subscribes to these events in its constructor
- `SoundEffect` is a type union (not an enum), per project convention
- All sound configuration (type, URL mapping, events) lives in
`Sounds.ts`
- Sound effects are lazy-loaded on first play
- Channel limit of 8 concurrent sounds. New sounds always play; when at
the limit, the oldest active sound gets stopped
- `SoundManager` bootstraps volume from `UserSettings` in its
constructor
- All Howler calls are wrapped in try/catch with error logging, so sound
failures never crash the game
- `dispose()` method unsubscribes from EventBus and unloads all Howl
instances on game shutdown
- Sound code stays entirely in `src/client/`, nothing in `core/` touches
it

## Sound approval status (per
[spreadsheet](https://drive.google.com/drive/folders/1KpGYJkmLxipy8XmTeyHf40XDC4P--Ck8?usp=sharing))

### Approved, wired up in this PR

| Event | Sound file | Trigger location |
|-------|-----------|-----------------|
| Message sent/received | `message.mp3` | EventsDisplay |
| Menu open/select | `click.mp3` | RadialMenu |
| Atom bomb launch | `atom-launch.mp3` | FxLayer (unit created) |
| Atom bomb / MIRV hit | `atom-hit.mp3` | FxLayer (reached target) |
| Hydrogen launch | `hydrogen-launch.mp3` | FxLayer (unit created) |
| Hydrogen hit | `hydrogen-hit.mp3` | FxLayer (reached target) |
| MIRV launch | `mirv-launch.mp3` | FxLayer (unit created) |
| Alliance suggested | `alliance-suggested.mp3` | EventsDisplay |
| Alliance broken | `alliance-broken.mp3` | EventsDisplay |
| Port built | `build-port.mp3` | FxLayer (construction complete) |
| City built | `build-city.mp3` | FxLayer (construction complete) |
| Defense post built | `build-defense-post.mp3` | FxLayer (construction
complete) |
| Warship built | `build-warship.mp3` | FxLayer (unit created) |
| SAM built | `sam-built.mp3` | FxLayer (construction complete) |

### Waiting for approval, sound files included but NOT wired up

| Event | Sound file | Notes |
|-------|-----------|-------|
| Missile Silo built | `silo-built.mp3` | Waiting for Approval |
| SAM shoot | `sam-shoot.mp3` | Waiting for Approval |
| SAM hit | - | Waiting for Approval, no sound file assigned |
| Warship sunk | - | Waiting for Approval, no sound file assigned |
| Warship shoot | - | Waiting for Approval, no sound file assigned |

### Not done, no sound files exist yet

| Event | Notes |
|-------|-------|
| Looted player | "Not sure if needed" |
| Invaded | - |
| Ship invasion incoming | - |
| Ship sent | - |
| Menu theme song | - |
| Ambience | "Not sure if needed" |

## Test plan
- [x] Start a private game and launch atom/hydrogen/MIRV nukes, verify
launch and detonation sounds
- [x] Build structures (city, port, defense post, SAM), verify build
completion sounds
- [x] Build a warship, verify warship built sound
- [x] Receive an alliance request, verify alliance suggested sound
- [x] Break an alliance, verify alliance broken sound
- [ ] Receive a chat message, verify message sound
- [x] Open the radial menu and click items, verify click sound
- [x] Disable visual FX in settings, verify sounds still play
- [x] Adjust SFX volume slider, verify it affects all new sounds
- [x] Verify no audio issues with rapid/overlapping events
- [x] Verify SoundManager responds to EventBus events and unsubscribes
cleanly on dispose
- [x] Verify SoundManager swallows Howler errors without crashing the
game
- [x] Verify channel limit of 8, oldest sound stopped when at cap

## Checklist
- [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

Resolves #1893

## Please put your Discord username so you can be contacted if a bug or
regression is found:
cool_clarky
2026-04-06 21:01:23 -07:00

1004 lines
32 KiB
TypeScript

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 {
AllianceExpiredUpdate,
AllianceExtensionUpdate,
AllianceRequestReplyUpdate,
AllianceRequestUpdate,
BrokeAllianceUpdate,
DisplayChatMessageUpdate,
DisplayMessageUpdate,
EmojiUpdate,
GameUpdateType,
TargetPlayerUpdate,
UnitIncomingUpdate,
} from "../../../core/game/GameUpdates";
import {
SendAllianceExtensionIntentEvent,
SendAllianceRejectIntentEvent,
SendAllianceRequestIntentEvent,
} from "../../Transport";
import { Layer } from "./Layer";
import { GameView, PlayerView, UnitView } from "../../../core/game/GameView";
import { onlyImages } from "../../../core/Util";
import { renderNumber } from "../../Utils";
import { GoToPlayerEvent, GoToUnitEvent } from "./Leaderboard";
import { PlaySoundEffectEvent } from "../../sound/Sounds";
import { getMessageTypeClasses, translateText } from "../../Utils";
import { UIState } from "../UIState";
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");
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;
}
@customElement("events-display")
export class EventsDisplay extends LitElement implements Layer {
public eventBus: EventBus;
public game: GameView;
public uiState: UIState;
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;
private _shouldScrollToBottom = true;
updated(changed: Map<string, unknown>) {
super.updated(changed);
if (this._eventsContainer && this._shouldScrollToBottom) {
this._eventsContainer.scrollTop = this._eventsContainer.scrollHeight;
}
}
private renderButton(options: {
content: any; // Can be string, TemplateResult, or other renderable content
onClick?: () => void;
className?: string;
disabled?: boolean;
translate?: boolean;
hidden?: boolean;
}) {
const {
content,
onClick,
className = "",
disabled = false,
translate = true,
hidden = false,
} = options;
if (hidden) {
return html``;
}
return html`
<button
class="${className}"
@click=${onClick}
?disabled=${disabled}
?translate=${translate}
>
${content}
</button>
`;
}
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),
],
[GameUpdateType.BrokeAlliance, this.onBrokeAllianceEvent.bind(this)],
[GameUpdateType.TargetPlayer, this.onTargetPlayerEvent.bind(this)],
[GameUpdateType.Emoji, this.onEmojiMessageEvent.bind(this)],
[GameUpdateType.UnitIncoming, this.onUnitIncomingEvent.bind(this)],
[GameUpdateType.AllianceExpired, this.onAllianceExpiredEvent.bind(this)],
[
GameUpdateType.AllianceExtension,
this.onAllianceExtensionEvent.bind(this),
],
] as const;
constructor() {
super();
this.events = [];
}
init() {}
tick() {
this.active = true;
if (this._eventsContainer) {
const el = this._eventsContainer;
this._shouldScrollToBottom =
el.scrollHeight - el.scrollTop - el.clientHeight < 5;
} else {
this._shouldScrollToBottom = 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);
}
}
let remainingEvents = this.events.filter((event) => {
const shouldKeep =
this.game.ticks() - event.createdAt < (event.duration ?? 600) &&
!event.shouldDelete?.(this.game);
if (!shouldKeep && event.onDelete) {
event.onDelete();
}
return shouldKeep;
});
if (remainingEvents.length > 30) {
remainingEvents = remainingEvents.slice(-30);
}
if (this.events.length !== remainingEvents.length) {
this.events = remainingEvents;
this.requestUpdate();
}
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),
];
}
shouldTransform(): boolean {
return false;
}
renderLayer(): void {}
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 (
event.playerID !== null &&
(!myPlayer || myPlayer.smallID() !== event.playerID)
) {
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);
}
}
let description: string = event.message;
if (event.message.startsWith("events_display.")) {
description = translateText(event.message, event.params ?? {});
}
this.addEvent({
description: description,
createdAt: this.game.ticks(),
highlight: true,
type: event.messageType,
unsafeDescription: true,
});
}
onDisplayChatEvent(event: DisplayChatMessageUpdate) {
const myPlayer = this.game.myPlayer();
if (
event.playerID === null ||
!myPlayer ||
myPlayer.smallID() !== event.playerID
) {
return;
}
const baseMessage = translateText(`chat.${event.category}.${event.key}`);
let translatedMessage = baseMessage;
if (event.target) {
try {
const targetPlayer = this.game.player(event.target);
const targetName = targetPlayer?.displayName() ?? event.target;
translatedMessage = baseMessage.replace("[P1]", targetName);
} catch (e) {
console.warn(
`Failed to resolve player for target ID '${event.target}'`,
e,
);
return;
}
}
let otherPlayerDiplayName: string = "";
if (event.recipient !== null) {
//'recipient' parameter contains sender ID or recipient ID
const player = this.game.player(event.recipient);
otherPlayerDiplayName = player ? player.displayName() : "";
}
this.addEvent({
description: translateText(event.isFrom ? "chat.from" : "chat.to", {
user: otherPlayerDiplayName,
msg: translatedMessage,
}),
createdAt: this.game.ticks(),
highlight: true,
type: MessageType.CHAT,
unsafeDescription: false,
});
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()) {
return;
}
const recipient = this.game.playerBySmallID(
update.request.recipientID,
) as PlayerView;
this.addEvent({
description: translateText("events_display.alliance_request_status", {
name: recipient.displayName(),
status: update.accepted
? translateText("events_display.alliance_accepted")
: translateText("events_display.alliance_rejected"),
}),
type: update.accepted
? MessageType.ALLIANCE_ACCEPTED
: MessageType.ALLIANCE_REJECTED,
highlight: true,
createdAt: this.game.ticks(),
focusID: update.request.recipientID,
});
}
onBrokeAllianceEvent(update: BrokeAllianceUpdate) {
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;
if (betrayed.isDisconnected()) return; // Do not send the message if betraying a disconnected player
if (!betrayed.isTraitor() && traitor === myPlayer) {
this.eventBus.emit(new PlaySoundEffectEvent("alliance-broken"));
const malusPercent = Math.round(
(1 - this.game.config().traitorDefenseDebuff()) * 100,
);
const traitorDuration = Math.floor(
this.game.config().traitorDuration() * 0.1,
);
const durationText =
traitorDuration === 1
? translateText("events_display.duration_second")
: translateText("events_display.duration_seconds_plural", {
seconds: traitorDuration,
});
this.addEvent({
description: translateText("events_display.betrayal_description", {
name: betrayed.displayName(),
malusPercent: malusPercent,
durationText: durationText,
}),
type: MessageType.ALLIANCE_BROKEN,
highlight: true,
createdAt: this.game.ticks(),
focusID: update.betrayedID,
});
} 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(),
}),
type: MessageType.ALLIANCE_BROKEN,
highlight: true,
createdAt: this.game.ticks(),
focusID: update.traitorID,
buttons,
});
}
}
onAllianceExpiredEvent(update: AllianceExpiredUpdate) {
const myPlayer = this.game.myPlayer();
if (!myPlayer) return;
const otherID =
update.player1ID === myPlayer.smallID()
? update.player2ID
: update.player2ID === myPlayer.smallID()
? update.player1ID
: null;
if (otherID === null) return;
const other = this.game.playerBySmallID(otherID) as PlayerView;
if (!other || !myPlayer.isAlive() || !other.isAlive()) return;
this.addEvent({
description: translateText("events_display.alliance_expired", {
name: other.displayName(),
}),
type: MessageType.ALLIANCE_EXPIRED,
highlight: true,
createdAt: this.game.ticks(),
focusID: otherID,
});
}
private onAllianceExtensionEvent(update: AllianceExtensionUpdate) {
const myPlayer = this.game.myPlayer();
if (!myPlayer || myPlayer.smallID() !== update.playerID) return;
this.removeAllianceRenewalEvents(update.allianceID);
this.requestUpdate();
}
onTargetPlayerEvent(event: TargetPlayerUpdate) {
const other = this.game.playerBySmallID(event.playerID) as PlayerView;
const myPlayer = this.game.myPlayer() as PlayerView;
if (!myPlayer || !myPlayer.isFriendly(other)) return;
const target = this.game.playerBySmallID(event.targetID) as PlayerView;
this.addEvent({
description: translateText("events_display.attack_request", {
name: other.displayName(),
target: target.displayName(),
}),
type: MessageType.ATTACK_REQUEST,
highlight: true,
createdAt: this.game.ticks(),
focusID: event.targetID,
});
}
emitGoToPlayerEvent(attackerID: number) {
const attacker = this.game.playerBySmallID(attackerID) as PlayerView;
if (!attacker) return;
this.eventBus.emit(new GoToPlayerEvent(attacker));
}
emitGoToUnitEvent(unit: UnitView) {
this.eventBus.emit(new GoToUnitEvent(unit));
}
onEmojiMessageEvent(update: EmojiUpdate) {
const myPlayer = this.game.myPlayer();
if (!myPlayer) return;
const recipient =
update.emoji.recipientID === AllPlayers
? AllPlayers
: this.game.playerBySmallID(update.emoji.recipientID);
const sender = this.game.playerBySmallID(
update.emoji.senderID,
) as PlayerView;
if (recipient === myPlayer) {
this.addEvent({
description: `${sender.displayName()}: ${update.emoji.message}`,
unsafeDescription: true,
type: MessageType.CHAT,
highlight: true,
createdAt: this.game.ticks(),
focusID: update.emoji.senderID,
});
} else if (sender === myPlayer && recipient !== AllPlayers) {
this.addEvent({
description: translateText("events_display.sent_emoji", {
name: (recipient as PlayerView).displayName(),
emoji: update.emoji.message,
}),
unsafeDescription: true,
type: MessageType.CHAT,
highlight: true,
createdAt: this.game.ticks(),
focusID: recipient.smallID(),
});
}
}
onUnitIncomingEvent(event: UnitIncomingUpdate) {
const myPlayer = this.game.myPlayer();
if (!myPlayer || myPlayer.smallID() !== event.playerID) {
return;
}
const unitView = this.game.unit(event.unitID);
this.addEvent({
description: event.message,
type: event.messageType,
unsafeDescription: false,
highlight: true,
createdAt: this.game.ticks(),
unitView: unitView,
});
}
private getEventDescription(
event: GameEvent,
): string | DirectiveResult<typeof UnsafeHTMLDirective> {
return event.unsafeDescription
? unsafeHTML(onlyImages(event.description))
: event.description;
}
private renderBetrayalDebuffTimer() {
const myPlayer = this.game.myPlayer();
if (!myPlayer || !myPlayer.isTraitor()) {
return html``;
}
const remainingTicks = myPlayer.getTraitorRemainingTicks();
const remainingSeconds = Math.ceil(remainingTicks / 10);
if (remainingSeconds <= 0) {
return html``;
}
return html`
${this.renderButton({
content: html`${translateText("events_display.betrayal_debuff_ends", {
time: remainingSeconds,
})}`,
className: "text-left text-yellow-400",
translate: false,
})}
`;
}
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 filteredEvents = this.events.filter((event) => {
const category = getMessageCategory(event.type);
return !this.eventsFilters.get(category);
});
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;
});
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="w-full p-2 lg:p-3 bg-gray-800/92 backdrop-blur-sm sm:rounded-tl-lg min-[1200px]:rounded-t-lg"
>
<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>
</div>
<!-- Content Area -->
<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"
>
<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`
<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>
</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"
>
&nbsp;
</td>
</tr>
`
: ""}
</tbody>
</table>
</div>
</div>
</div>
`}
`;
}
createRenderRoot() {
return this;
}
}