mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-25 01:34:36 +00:00
ff17d321c7
## Description: On Alliance extension, the key "events_display.alliance_renewed" is displayed in the Event Panel. Reported in https://discord.com/channels/1284581928254701718/1397197460085932184 Since displayMessage doesn't expect or do translations, just puts out hardcoded English messages so far, it doesn't do anything with the recieved key and just puts it in the Event Panel as is. This PR fixes it as a follow-up of #1359. With this, it also introduces a base which can be used to translate hardcoded messages coming from other Executions. That is out of the scope of this PR. PRs #1532 and #1536 both fix issues with Alliance Renewal in v24. If possible approve 1532 (this one) first and then 1536 not too far after if github can indeed merge them. BEFORE: <img width="533" height="353" alt="alliancerenewbefore" src="https://github.com/user-attachments/assets/b97f7279-8daf-4049-96fb-1d5a1e360ec4" /> AFTER: (tested locally with Nations by not checking their answer; they normally don't answer to alliance renewal request which is another issue) <img width="787" height="406" alt="After fix" src="https://github.com/user-attachments/assets/9fc3a0e2-b151-486f-b6ef-692177e387ad" /> ## 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 - [x] I have read and accepted the CLA aggreement (only required once). ## Please put your Discord username so you can be contacted if a bug or regression is found: tryout33
1152 lines
38 KiB
TypeScript
1152 lines
38 KiB
TypeScript
import { html, LitElement } from "lit";
|
|
import { customElement, state } from "lit/decorators.js";
|
|
import { DirectiveResult } from "lit/directive.js";
|
|
import { unsafeHTML, UnsafeHTMLDirective } from "lit/directives/unsafe-html.js";
|
|
import allianceIcon from "../../../../resources/images/AllianceIconWhite.svg";
|
|
import chatIcon from "../../../../resources/images/ChatIconWhite.svg";
|
|
import donateGoldIcon from "../../../../resources/images/DonateGoldIconWhite.svg";
|
|
import swordIcon from "../../../../resources/images/SwordIconWhite.svg";
|
|
import { EventBus } from "../../../core/EventBus";
|
|
import {
|
|
AllPlayers,
|
|
getMessageCategory,
|
|
MessageCategory,
|
|
MessageType,
|
|
PlayerType,
|
|
Tick,
|
|
UnitType,
|
|
} from "../../../core/game/Game";
|
|
import {
|
|
AllianceExpiredUpdate,
|
|
AllianceRequestReplyUpdate,
|
|
AllianceRequestUpdate,
|
|
AttackUpdate,
|
|
BrokeAllianceUpdate,
|
|
DisplayChatMessageUpdate,
|
|
DisplayMessageUpdate,
|
|
EmojiUpdate,
|
|
GameUpdateType,
|
|
TargetPlayerUpdate,
|
|
UnitIncomingUpdate,
|
|
} from "../../../core/game/GameUpdates";
|
|
import {
|
|
CancelAttackIntentEvent,
|
|
CancelBoatIntentEvent,
|
|
SendAllianceExtensionIntentEvent,
|
|
SendAllianceReplyIntentEvent,
|
|
} from "../../Transport";
|
|
import { Layer } from "./Layer";
|
|
|
|
import { GameView, PlayerView, UnitView } from "../../../core/game/GameView";
|
|
import { onlyImages } from "../../../core/Util";
|
|
import { renderNumber, renderTroops } from "../../Utils";
|
|
import {
|
|
GoToPlayerEvent,
|
|
GoToPositionEvent,
|
|
GoToUnitEvent,
|
|
} from "./Leaderboard";
|
|
|
|
import { getMessageTypeClasses, 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;
|
|
}
|
|
|
|
@customElement("events-display")
|
|
export class EventsDisplay extends LitElement implements Layer {
|
|
public eventBus: EventBus;
|
|
public game: GameView;
|
|
|
|
private active: boolean = false;
|
|
private events: GameEvent[] = [];
|
|
|
|
// allianceID -> last checked at tick
|
|
private alliancesCheckedAt = new Map<number, Tick>();
|
|
@state() private incomingAttacks: AttackUpdate[] = [];
|
|
@state() private outgoingAttacks: AttackUpdate[] = [];
|
|
@state() private outgoingLandAttacks: AttackUpdate[] = [];
|
|
@state() private outgoingBoats: UnitView[] = [];
|
|
@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.TRADE, false],
|
|
[MessageCategory.ALLIANCE, false],
|
|
[MessageCategory.CHAT, false],
|
|
]);
|
|
|
|
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 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 = new Map([
|
|
[GameUpdateType.DisplayEvent, (u) => this.onDisplayMessageEvent(u)],
|
|
[GameUpdateType.DisplayChatEvent, (u) => this.onDisplayChatEvent(u)],
|
|
[GameUpdateType.AllianceRequest, (u) => this.onAllianceRequestEvent(u)],
|
|
[
|
|
GameUpdateType.AllianceRequestReply,
|
|
(u) => this.onAllianceRequestReplyEvent(u),
|
|
],
|
|
[GameUpdateType.BrokeAlliance, (u) => this.onBrokeAllianceEvent(u)],
|
|
[GameUpdateType.TargetPlayer, (u) => this.onTargetPlayerEvent(u)],
|
|
[GameUpdateType.Emoji, (u) => this.onEmojiMessageEvent(u)],
|
|
[GameUpdateType.UnitIncoming, (u) => this.onUnitIncomingEvent(u)],
|
|
[GameUpdateType.AllianceExpired, (u) => this.onAllianceExpiredEvent(u)],
|
|
]);
|
|
|
|
constructor() {
|
|
super();
|
|
this.events = [];
|
|
this.incomingAttacks = [];
|
|
this.outgoingAttacks = [];
|
|
this.outgoingBoats = [];
|
|
}
|
|
|
|
init() {}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
let remainingEvents = this.events.filter((event) => {
|
|
const shouldKeep =
|
|
this.game.ticks() - event.createdAt < (event.duration ?? 600);
|
|
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();
|
|
}
|
|
|
|
// Update attacks
|
|
this.incomingAttacks = myPlayer.incomingAttacks().filter((a) => {
|
|
const t = (this.game.playerBySmallID(a.attackerID) as PlayerView).type();
|
|
return t !== PlayerType.Bot;
|
|
});
|
|
|
|
this.outgoingAttacks = myPlayer
|
|
.outgoingAttacks()
|
|
.filter((a) => a.targetID !== 0);
|
|
|
|
this.outgoingLandAttacks = myPlayer
|
|
.outgoingAttacks()
|
|
.filter((a) => a.targetID === 0);
|
|
|
|
this.outgoingBoats = myPlayer
|
|
.units()
|
|
.filter((u) => u.type() === UnitType.TransportShip);
|
|
|
|
this.requestUpdate();
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
if (this.goldAmountTimeoutId !== null) {
|
|
clearTimeout(this.goldAmountTimeoutId);
|
|
this.goldAmountTimeoutId = null;
|
|
}
|
|
}
|
|
|
|
private checkForAllianceExpirations() {
|
|
const myPlayer = this.game.myPlayer();
|
|
if (!myPlayer?.isAlive()) return;
|
|
|
|
for (const alliance of myPlayer.alliances()) {
|
|
if (
|
|
alliance.expiresAt >
|
|
this.game.ticks() + this.game.config().allianceExtensionPromptOffset()
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
if (
|
|
(this.alliancesCheckedAt.get(alliance.id) ?? 0) >=
|
|
this.game.ticks() - this.game.config().allianceExtensionPromptOffset()
|
|
) {
|
|
// We've already displayed a message for this alliance.
|
|
continue;
|
|
}
|
|
|
|
this.alliancesCheckedAt.set(alliance.id, this.game.ticks());
|
|
|
|
const other = this.game.player(alliance.other) as PlayerView;
|
|
if (!other.isAlive()) continue;
|
|
|
|
this.addEvent({
|
|
description: translateText("events_display.about_to_expire", {
|
|
name: other.name(),
|
|
}),
|
|
type: MessageType.RENEW_ALLIANCE,
|
|
duration: this.game.config().allianceExtensionPromptOffset() - 3 * 10, // 3 second buffer
|
|
buttons: [
|
|
{
|
|
text: translateText("events_display.focus"),
|
|
className: "btn-gray",
|
|
action: () => this.eventBus.emit(new GoToPlayerEvent(other)),
|
|
preventClose: true,
|
|
},
|
|
{
|
|
text: translateText("events_display.renew_alliance", {
|
|
name: other.name(),
|
|
}),
|
|
className: "btn",
|
|
action: () =>
|
|
this.eventBus.emit(new SendAllianceExtensionIntentEvent(other)),
|
|
},
|
|
{
|
|
text: translateText("events_display.ignore"),
|
|
className: "btn-info",
|
|
action: () => {},
|
|
},
|
|
],
|
|
highlight: true,
|
|
createdAt: this.game.ticks(),
|
|
focusID: other.smallID(),
|
|
});
|
|
}
|
|
}
|
|
|
|
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 {}
|
|
|
|
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.params !== undefined) {
|
|
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,
|
|
});
|
|
}
|
|
|
|
onAllianceRequestEvent(update: AllianceRequestUpdate) {
|
|
const myPlayer = this.game.myPlayer();
|
|
if (!myPlayer || update.recipientID !== myPlayer.smallID()) {
|
|
return;
|
|
}
|
|
|
|
const requestor = this.game.playerBySmallID(
|
|
update.requestorID,
|
|
) as PlayerView;
|
|
const recipient = this.game.playerBySmallID(
|
|
update.recipientID,
|
|
) as PlayerView;
|
|
|
|
this.addEvent({
|
|
description: translateText("events_display.request_alliance", {
|
|
name: requestor.name(),
|
|
}),
|
|
buttons: [
|
|
{
|
|
text: translateText("events_display.focus"),
|
|
className: "btn-gray",
|
|
action: () => this.eventBus.emit(new GoToPlayerEvent(requestor)),
|
|
preventClose: true,
|
|
},
|
|
{
|
|
text: translateText("events_display.accept_alliance"),
|
|
className: "btn",
|
|
action: () =>
|
|
this.eventBus.emit(
|
|
new SendAllianceReplyIntentEvent(requestor, recipient, true),
|
|
),
|
|
},
|
|
{
|
|
text: translateText("events_display.reject_alliance"),
|
|
className: "btn-info",
|
|
action: () =>
|
|
this.eventBus.emit(
|
|
new SendAllianceReplyIntentEvent(requestor, recipient, false),
|
|
),
|
|
},
|
|
],
|
|
highlight: true,
|
|
type: MessageType.ALLIANCE_REQUEST,
|
|
createdAt: this.game.ticks(),
|
|
onDelete: () =>
|
|
this.eventBus.emit(
|
|
new SendAllianceReplyIntentEvent(requestor, recipient, false),
|
|
),
|
|
priority: 0,
|
|
duration: 150,
|
|
focusID: update.requestorID,
|
|
});
|
|
}
|
|
|
|
onAllianceRequestReplyEvent(update: AllianceRequestReplyUpdate) {
|
|
const myPlayer = this.game.myPlayer();
|
|
if (!myPlayer || 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.name(),
|
|
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;
|
|
|
|
const betrayed = this.game.playerBySmallID(update.betrayedID) as PlayerView;
|
|
const traitor = this.game.playerBySmallID(update.traitorID) as PlayerView;
|
|
|
|
if (!betrayed.isTraitor() && traitor === myPlayer) {
|
|
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.name(),
|
|
malusPercent: malusPercent,
|
|
durationText: durationText,
|
|
}),
|
|
type: MessageType.ALLIANCE_BROKEN,
|
|
highlight: true,
|
|
createdAt: this.game.ticks(),
|
|
focusID: update.betrayedID,
|
|
});
|
|
} else if (betrayed === myPlayer) {
|
|
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.name(),
|
|
}),
|
|
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.name(),
|
|
}),
|
|
type: MessageType.ALLIANCE_EXPIRED,
|
|
highlight: true,
|
|
createdAt: this.game.ticks(),
|
|
focusID: otherID,
|
|
});
|
|
}
|
|
|
|
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.name(),
|
|
target: target.name(),
|
|
}),
|
|
type: MessageType.ATTACK_REQUEST,
|
|
highlight: true,
|
|
createdAt: this.game.ticks(),
|
|
focusID: event.targetID,
|
|
});
|
|
}
|
|
|
|
emitCancelAttackIntent(id: string) {
|
|
const myPlayer = this.game.myPlayer();
|
|
if (!myPlayer) return;
|
|
this.eventBus.emit(new CancelAttackIntentEvent(id));
|
|
}
|
|
|
|
emitBoatCancelIntent(id: number) {
|
|
const myPlayer = this.game.myPlayer();
|
|
if (!myPlayer) return;
|
|
this.eventBus.emit(new CancelBoatIntentEvent(id));
|
|
}
|
|
|
|
emitGoToPlayerEvent(attackerID: number) {
|
|
const attacker = this.game.playerBySmallID(attackerID) as PlayerView;
|
|
if (!attacker) return;
|
|
this.eventBus.emit(new GoToPlayerEvent(attacker));
|
|
}
|
|
|
|
emitGoToPositionEvent(x: number, y: number) {
|
|
this.eventBus.emit(new GoToPositionEvent(x, y));
|
|
}
|
|
|
|
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 async attackWarningOnClick(attack: AttackUpdate) {
|
|
const playerView = this.game.playerBySmallID(attack.attackerID);
|
|
if (playerView !== undefined) {
|
|
if (playerView instanceof PlayerView) {
|
|
const averagePosition = await playerView.attackAveragePosition(
|
|
attack.attackerID,
|
|
attack.id,
|
|
);
|
|
|
|
if (averagePosition === null) {
|
|
this.emitGoToPlayerEvent(attack.attackerID);
|
|
} else {
|
|
this.emitGoToPositionEvent(averagePosition.x, averagePosition.y);
|
|
}
|
|
}
|
|
} else {
|
|
this.emitGoToPlayerEvent(attack.attackerID);
|
|
}
|
|
}
|
|
|
|
private renderIncomingAttacks() {
|
|
return html`
|
|
${this.incomingAttacks.length > 0
|
|
? html`
|
|
${this.incomingAttacks.map(
|
|
(attack) => html`
|
|
${this.renderButton({
|
|
content: html`
|
|
${renderTroops(attack.troops)}
|
|
${(
|
|
this.game.playerBySmallID(attack.attackerID) as PlayerView
|
|
)?.name()}
|
|
${attack.retreating
|
|
? `(${translateText("events_display.retreating")}...)`
|
|
: ""}
|
|
`,
|
|
onClick: () => this.attackWarningOnClick(attack),
|
|
className: "text-left text-red-400",
|
|
translate: false,
|
|
})}
|
|
`,
|
|
)}
|
|
`
|
|
: ""}
|
|
`;
|
|
}
|
|
|
|
private renderOutgoingAttacks() {
|
|
return html`
|
|
${this.outgoingAttacks.length > 0
|
|
? html`
|
|
<div class="flex flex-wrap gap-y-1 gap-x-2">
|
|
${this.outgoingAttacks.map(
|
|
(attack) => html`
|
|
<div class="inline-flex items-center gap-1">
|
|
${this.renderButton({
|
|
content: html`
|
|
${renderTroops(attack.troops)}
|
|
${(
|
|
this.game.playerBySmallID(
|
|
attack.targetID,
|
|
) as PlayerView
|
|
)?.name()}
|
|
`,
|
|
onClick: async () => this.attackWarningOnClick(attack),
|
|
className: "text-left text-blue-400",
|
|
translate: false,
|
|
})}
|
|
${!attack.retreating
|
|
? this.renderButton({
|
|
content: "❌",
|
|
onClick: () => this.emitCancelAttackIntent(attack.id),
|
|
className: "text-left flex-shrink-0",
|
|
disabled: attack.retreating,
|
|
})
|
|
: html`<span class="flex-shrink-0 text-blue-400"
|
|
>(${translateText(
|
|
"events_display.retreating",
|
|
)}...)</span
|
|
>`}
|
|
</div>
|
|
`,
|
|
)}
|
|
</div>
|
|
`
|
|
: ""}
|
|
`;
|
|
}
|
|
|
|
private renderOutgoingLandAttacks() {
|
|
return html`
|
|
${this.outgoingLandAttacks.length > 0
|
|
? html`
|
|
<div class="flex flex-wrap gap-y-1 gap-x-2">
|
|
${this.outgoingLandAttacks.map(
|
|
(landAttack) => html`
|
|
<div class="inline-flex items-center gap-1">
|
|
${this.renderButton({
|
|
content: html`${renderTroops(landAttack.troops)}
|
|
${translateText("help_modal.ui_wilderness")}`,
|
|
className: "text-left text-gray-400",
|
|
translate: false,
|
|
})}
|
|
${!landAttack.retreating
|
|
? this.renderButton({
|
|
content: "❌",
|
|
onClick: () =>
|
|
this.emitCancelAttackIntent(landAttack.id),
|
|
className: "text-left flex-shrink-0",
|
|
disabled: landAttack.retreating,
|
|
})
|
|
: html`<span class="flex-shrink-0 text-blue-400"
|
|
>(${translateText(
|
|
"events_display.retreating",
|
|
)}...)</span
|
|
>`}
|
|
</div>
|
|
`,
|
|
)}
|
|
</div>
|
|
`
|
|
: ""}
|
|
`;
|
|
}
|
|
|
|
private renderBoats() {
|
|
return html`
|
|
${this.outgoingBoats.length > 0
|
|
? html`
|
|
<div class="flex flex-wrap gap-y-1 gap-x-2">
|
|
${this.outgoingBoats.map(
|
|
(boat) => html`
|
|
<div class="inline-flex items-center gap-1">
|
|
${this.renderButton({
|
|
content: html`${translateText("events_display.boat")}:
|
|
${renderTroops(boat.troops())}`,
|
|
onClick: () => this.emitGoToUnitEvent(boat),
|
|
className: "text-left text-blue-400",
|
|
translate: false,
|
|
})}
|
|
${!boat.retreating()
|
|
? this.renderButton({
|
|
content: "❌",
|
|
onClick: () => this.emitBoatCancelIntent(boat.id()),
|
|
className: "text-left flex-shrink-0",
|
|
disabled: boat.retreating(),
|
|
})
|
|
: html`<span class="flex-shrink-0 text-blue-400"
|
|
>(${translateText(
|
|
"events_display.retreating",
|
|
)}...)</span
|
|
>`}
|
|
</div>
|
|
`,
|
|
)}
|
|
</div>
|
|
`
|
|
: ""}
|
|
`;
|
|
}
|
|
|
|
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 lg:bottom-2.5 lg:right-2.5 z-50">
|
|
${this.renderButton({
|
|
content: html`
|
|
Events
|
|
<span
|
|
class="${this.newEvents
|
|
? ""
|
|
: "hidden"} inline-block px-2 bg-red-500 rounded-xl text-sm"
|
|
>${this.newEvents}</span
|
|
>
|
|
`,
|
|
onClick: this.toggleHidden,
|
|
className:
|
|
"text-white cursor-pointer pointer-events-auto w-fit p-2 lg:p-3 rounded-md bg-gray-800/70 backdrop-blur",
|
|
})}
|
|
</div>
|
|
`
|
|
: html`
|
|
<!-- Main Events Display -->
|
|
<div
|
|
class="relative w-full sm:bottom-2.5 sm:right-2.5 z-50 sm:w-96 backdrop-blur"
|
|
>
|
|
<!-- Button Bar -->
|
|
<div
|
|
class="w-full p-2 lg:p-3 rounded-t-none md:rounded-t-md bg-gray-800/70"
|
|
>
|
|
<div class="flex justify-between items-center">
|
|
<div class="flex gap-4">
|
|
${this.renderButton({
|
|
content: html`<img
|
|
src="${swordIcon}"
|
|
class="w-5 h-5"
|
|
style="filter: ${this.eventsFilters.get(
|
|
MessageCategory.ATTACK,
|
|
)
|
|
? "grayscale(1) opacity(0.5)"
|
|
: "none"}"
|
|
/>`,
|
|
onClick: () =>
|
|
this.toggleEventFilter(MessageCategory.ATTACK),
|
|
className: "cursor-pointer pointer-events-auto",
|
|
})}
|
|
${this.renderButton({
|
|
content: html`<img
|
|
src="${donateGoldIcon}"
|
|
class="w-5 h-5"
|
|
style="filter: ${this.eventsFilters.get(
|
|
MessageCategory.TRADE,
|
|
)
|
|
? "grayscale(1) opacity(0.5)"
|
|
: "none"}"
|
|
/>`,
|
|
onClick: () =>
|
|
this.toggleEventFilter(MessageCategory.TRADE),
|
|
className: "cursor-pointer pointer-events-auto",
|
|
})}
|
|
${this.renderButton({
|
|
content: html`<img
|
|
src="${allianceIcon}"
|
|
class="w-5 h-5"
|
|
style="filter: ${this.eventsFilters.get(
|
|
MessageCategory.ALLIANCE,
|
|
)
|
|
? "grayscale(1) opacity(0.5)"
|
|
: "none"}"
|
|
/>`,
|
|
onClick: () =>
|
|
this.toggleEventFilter(MessageCategory.ALLIANCE),
|
|
className: "cursor-pointer pointer-events-auto",
|
|
})}
|
|
${this.renderButton({
|
|
content: html`<img
|
|
src="${chatIcon}"
|
|
class="w-5 h-5"
|
|
style="filter: ${this.eventsFilters.get(
|
|
MessageCategory.CHAT,
|
|
)
|
|
? "grayscale(1) opacity(0.5)"
|
|
: "none"}"
|
|
/>`,
|
|
onClick: () =>
|
|
this.toggleEventFilter(MessageCategory.CHAT),
|
|
className: "cursor-pointer pointer-events-auto",
|
|
})}
|
|
</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="rounded-b-none md:rounded-b-md bg-gray-800/70 max-h-[30vh] flex flex-col-reverse overflow-y-auto w-full h-full"
|
|
>
|
|
<div>
|
|
<table
|
|
class="w-full max-h-none border-collapse text-white shadow-lg lg:text-base text-md md:text-xs"
|
|
style="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: () => {
|
|
event.focusID &&
|
|
this.emitGoToPlayerEvent(event.focusID);
|
|
},
|
|
className: "text-left",
|
|
})
|
|
: event.unitView
|
|
? this.renderButton({
|
|
content: this.getEventDescription(event),
|
|
onClick: () => {
|
|
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 text-md md: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>
|
|
`,
|
|
)}
|
|
<!--- Incoming attacks row -->
|
|
${this.incomingAttacks.length > 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.renderIncomingAttacks()}
|
|
</td>
|
|
</tr>
|
|
`
|
|
: ""}
|
|
|
|
<!--- Outgoing attacks row -->
|
|
${this.outgoingAttacks.length > 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.renderOutgoingAttacks()}
|
|
</td>
|
|
</tr>
|
|
`
|
|
: ""}
|
|
|
|
<!--- Outgoing land attacks row -->
|
|
${this.outgoingLandAttacks.length > 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.renderOutgoingLandAttacks()}
|
|
</td>
|
|
</tr>
|
|
`
|
|
: ""}
|
|
|
|
<!--- Boats row -->
|
|
${this.outgoingBoats.length > 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.renderBoats()}
|
|
</td>
|
|
</tr>
|
|
`
|
|
: ""}
|
|
|
|
<!--- Empty row when no events or attacks -->
|
|
${filteredEvents.length === 0 &&
|
|
this.incomingAttacks.length === 0 &&
|
|
this.outgoingAttacks.length === 0 &&
|
|
this.outgoingLandAttacks.length === 0 &&
|
|
this.outgoingBoats.length === 0
|
|
? html`
|
|
<tr>
|
|
<td
|
|
class="lg:px-2 lg:py-1 p-1 min-w-72 text-left"
|
|
>
|
|
|
|
</td>
|
|
</tr>
|
|
`
|
|
: ""}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`}
|
|
`;
|
|
}
|
|
|
|
createRenderRoot() {
|
|
return this;
|
|
}
|
|
}
|