mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 13:10:42 +00:00
1245 lines
41 KiB
TypeScript
1245 lines
41 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 allianceIcon from "../../../../resources/images/AllianceIconWhite.svg";
|
|
import chatIcon from "../../../../resources/images/ChatIconWhite.svg";
|
|
import donateGoldIcon from "../../../../resources/images/DonateGoldIconWhite.svg";
|
|
import nukeIcon from "../../../../resources/images/NukeIconWhite.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,
|
|
SendAttackIntentEvent,
|
|
} 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";
|
|
import { UIState } from "../UIState";
|
|
|
|
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;
|
|
}
|
|
|
|
@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 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.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="filter: ${this.eventsFilters.get(category)
|
|
? "grayscale(1) opacity(0.5)"
|
|
: "none"}"
|
|
/>`,
|
|
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)],
|
|
] as const;
|
|
|
|
constructor() {
|
|
super();
|
|
this.events = [];
|
|
this.incomingAttacks = [];
|
|
this.outgoingAttacks = [];
|
|
this.outgoingBoats = [];
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
// 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.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(),
|
|
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.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.isDisconnected()) return; // Do not send the message if betraying a disconnected player
|
|
|
|
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 handleRetaliate(attack: AttackUpdate) {
|
|
const attacker = this.game.playerBySmallID(attack.attackerID) as PlayerView;
|
|
if (!attacker) return;
|
|
|
|
const myPlayer = this.game.myPlayer();
|
|
if (!myPlayer) return;
|
|
|
|
const counterTroops = Math.min(
|
|
attack.troops,
|
|
this.uiState.attackRatio * myPlayer.troops(),
|
|
);
|
|
this.eventBus.emit(new SendAttackIntentEvent(attacker.id(), counterTroops));
|
|
}
|
|
|
|
private renderIncomingAttacks() {
|
|
return html`
|
|
${this.incomingAttacks.length > 0
|
|
? html`
|
|
<div class="flex flex-wrap gap-y-1 gap-x-2">
|
|
${this.incomingAttacks.map(
|
|
(attack) => html`
|
|
<div class="inline-flex items-center gap-1">
|
|
${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,
|
|
})}
|
|
${!attack.retreating
|
|
? this.renderButton({
|
|
content: translateText("events_display.retaliate"),
|
|
onClick: () => this.handleRetaliate(attack),
|
|
className:
|
|
"inline-block px-3 py-1 text-white rounded text-md md:text-sm cursor-pointer transition-colors duration-300 bg-red-600 hover:bg-red-700",
|
|
translate: true,
|
|
})
|
|
: ""}
|
|
</div>
|
|
`,
|
|
)}
|
|
</div>
|
|
`
|
|
: ""}
|
|
`;
|
|
}
|
|
|
|
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>
|
|
`
|
|
: ""}
|
|
`;
|
|
}
|
|
|
|
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 lg:bottom-4 lg:right-4 z-50">
|
|
${this.renderButton({
|
|
content: html`
|
|
Events
|
|
<span
|
|
class="${this.newEvents
|
|
? ""
|
|
: "hidden"} inline-block px-2 bg-red-500 rounded-lg text-sm"
|
|
>${this.newEvents}</span
|
|
>
|
|
`,
|
|
onClick: this.toggleHidden,
|
|
className:
|
|
"text-white cursor-pointer pointer-events-auto w-fit p-2 lg:p-3 rounded-lg bg-gray-800/70 backdrop-blur",
|
|
})}
|
|
</div>
|
|
`
|
|
: html`
|
|
<!-- Main Events Display -->
|
|
<div
|
|
class="relative w-full sm:bottom-4 sm:right-4 z-50 sm:w-96 backdrop-blur"
|
|
>
|
|
<!-- Button Bar -->
|
|
<div class="w-full p-2 lg:p-3 bg-gray-800/70 rounded-t-lg">
|
|
<div class="flex justify-between items-center">
|
|
<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/70 max-h-[30vh] overflow-y-auto w-full h-full sm:rounded-b-lg events-container"
|
|
>
|
|
<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: () => {
|
|
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 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>
|
|
`
|
|
: ""}
|
|
|
|
<!--- 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>
|
|
`
|
|
: ""}
|
|
|
|
<!--- 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 &&
|
|
!(() => {
|
|
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>
|
|
</div>
|
|
</div>
|
|
`}
|
|
`;
|
|
}
|
|
|
|
createRenderRoot() {
|
|
return this;
|
|
}
|
|
}
|