rename client/graphics → client/hud

The contents (Lit web components for in-game chat, build menu, leaderboard,
attack displays, etc.) are HUD, not graphics — the actual graphics is in
client/render/.
This commit is contained in:
evanpelle
2026-05-18 13:07:26 -07:00
parent f23789883b
commit 7863529b2c
49 changed files with 12 additions and 12 deletions
+261
View File
@@ -0,0 +1,261 @@
import { LitElement, css, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { PlayerType } from "../../../core/game/Game";
import {
BrokeAllianceUpdate,
GameUpdateType,
} from "../../../core/game/GameUpdates";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { UserSettings } from "../../../core/game/UserSettings";
import { Controller } from "../../Controller";
// Parameters for the alert animation
const ALERT_SPEED = 1.6;
const ALERT_COUNT = 2;
const RETALIATION_WINDOW_TICKS = 15 * 10; // 15 seconds
const ALERT_COOLDOWN_TICKS = 15 * 10; // 15 seconds
@customElement("alert-frame")
export class AlertFrame extends LitElement implements Controller {
public game: GameView;
private userSettings: UserSettings = new UserSettings();
@state()
private isActive = false;
@state()
private alertType: "betrayal" | "land-attack" = "betrayal";
private animationTimeout: number | null = null;
private seenAttackIds: Set<string> = new Set();
private lastAlertTick: number = -1;
// Map of player ID -> tick when we last attacked them
private outgoingAttackTicks: Map<number, number> = new Map();
static styles = css`
.alert-border {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
border: 17px solid;
box-sizing: border-box;
z-index: 40;
opacity: 0;
}
.alert-border.betrayal {
border-color: #ee0000;
}
.alert-border.land-attack {
border-color: #ffa500;
}
.alert-border.animate {
animation: alertBlink ${ALERT_SPEED}s ease-in-out ${ALERT_COUNT};
}
@keyframes alertBlink {
0% {
opacity: 0;
}
50% {
opacity: 1;
}
100% {
opacity: 0;
}
}
`;
constructor() {
super();
if (!document.querySelector("style[data-alert-frame]")) {
const styleEl = document.createElement("style");
styleEl.setAttribute("data-alert-frame", "");
styleEl.textContent = AlertFrame.styles.cssText;
document.head.appendChild(styleEl);
}
}
createRenderRoot() {
return this;
}
init() {
// Listen for BrokeAllianceUpdate events directly from game updates
}
tick() {
if (!this.game) {
return; // Game not initialized yet
}
const myPlayer = this.game.myPlayer();
// Clear tracked attacks if player dies or doesn't exist
if (!myPlayer || !myPlayer.isAlive()) {
this.seenAttackIds.clear();
this.outgoingAttackTicks.clear();
this.lastAlertTick = -1;
return;
}
// Track outgoing attacks to detect retaliation
this.trackOutgoingAttacks();
// Check for BrokeAllianceUpdate events
this.game
.updatesSinceLastTick()
?.[GameUpdateType.BrokeAlliance]?.forEach((update) => {
this.onBrokeAllianceUpdate(update as BrokeAllianceUpdate);
});
// Check for new incoming attacks
this.checkForNewAttacks();
}
// The alert frame is not affected by the camera transform
private onBrokeAllianceUpdate(update: BrokeAllianceUpdate) {
const myPlayer = this.game.myPlayer();
if (!myPlayer) return;
const betrayed = this.game.playerBySmallID(update.betrayedID);
// Only trigger alert if the current player is the betrayed one
if (betrayed === myPlayer) {
this.alertType = "betrayal";
this.activateAlert();
}
}
private activateAlert() {
if (this.userSettings.alertFrame()) {
this.isActive = true;
this.lastAlertTick = this.game.ticks();
this.requestUpdate();
}
}
private trackOutgoingAttacks() {
const myPlayer = this.game.myPlayer();
if (!myPlayer || !myPlayer.isAlive()) {
return;
}
const currentTick = this.game.ticks();
const outgoingAttacks = myPlayer.outgoingAttacks();
// Track when we attack other players (not terra nullius)
for (const attack of outgoingAttacks) {
// Only track attacks on players (targetID !== 0 means it's a player, not unclaimed land)
if (attack.targetID !== 0 && !attack.retreating) {
const existingTick = this.outgoingAttackTicks.get(attack.targetID);
// Only update timestamp if:
// 1. This is a new attack (not in map yet), OR
// 2. The existing entry has expired (older than retaliation window)
if (
existingTick === undefined ||
currentTick - existingTick >= RETALIATION_WINDOW_TICKS
) {
this.outgoingAttackTicks.set(attack.targetID, currentTick);
}
}
}
// Clean up old entries (older than retaliation window)
for (const [playerID, tick] of this.outgoingAttackTicks.entries()) {
if (currentTick - tick > RETALIATION_WINDOW_TICKS) {
this.outgoingAttackTicks.delete(playerID);
}
}
}
private checkForNewAttacks() {
const myPlayer = this.game.myPlayer();
if (!myPlayer || !myPlayer.isAlive()) {
return;
}
const incomingAttacks = myPlayer.incomingAttacks();
const currentTick = this.game.ticks();
// Check if we're in cooldown (within 10 seconds of last alert)
const inCooldown =
this.lastAlertTick !== -1 &&
currentTick - this.lastAlertTick < ALERT_COOLDOWN_TICKS;
// Find new attacks that we haven't seen yet
const playerTroops = myPlayer.troops();
const minAttackTroopsThreshold = playerTroops / 5; // 1/5 of current troops
for (const attack of incomingAttacks) {
// Only alert for non-retreating attacks
if (!attack.retreating && !this.seenAttackIds.has(attack.id)) {
const attacker = this.game.playerBySmallID(attack.attackerID);
if ((attacker as PlayerView).type() === PlayerType.Bot) {
this.seenAttackIds.add(attack.id);
continue;
}
// Check if this is a retaliation (we attacked them recently)
const ourAttackTick = this.outgoingAttackTicks.get(attack.attackerID);
const isRetaliation =
ourAttackTick !== undefined &&
currentTick - ourAttackTick < RETALIATION_WINDOW_TICKS;
// Check if attack is too small (less than 1/5 of our troops)
const isSmallAttack = attack.troops < minAttackTroopsThreshold;
// Don't alert if:
// 1. We're in cooldown from a recent alert
// 2. This is a retaliation (we attacked them within 15 seconds)
// 3. The attack is too small (less than 1/5 of our troops)
if (!inCooldown && !isRetaliation && !isSmallAttack) {
this.seenAttackIds.add(attack.id);
this.alertType = "land-attack";
this.activateAlert();
} else {
// Still mark as seen so we don't alert later
this.seenAttackIds.add(attack.id);
}
}
}
// Clean up IDs for attacks that are no longer active (retreating or completed)
const activeAttackIds = new Set(incomingAttacks.map((a) => a.id));
// Remove IDs for attacks that are no longer in the incoming attacks list
for (const attackId of this.seenAttackIds) {
if (!activeAttackIds.has(attackId)) {
this.seenAttackIds.delete(attackId);
}
}
}
public dismissAlert() {
this.isActive = false;
if (this.animationTimeout) {
clearTimeout(this.animationTimeout);
this.animationTimeout = null;
}
this.requestUpdate();
}
render() {
if (!this.isActive) {
return html``;
}
return html`
<div
class=${`alert-border animate ${this.alertType}`}
@animationend=${() => this.dismissAlert()}
></div>
`;
}
}
@@ -0,0 +1,338 @@
import { EventBus } from "../../../core/EventBus";
import { Cell, PlayerType } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import { UserSettings } from "../../../core/game/UserSettings";
import { Controller } from "../../Controller";
import { AlternateViewEvent } from "../../InputHandler";
import { TransformHandler } from "../../TransformHandler";
import { renderTroops } from "../../Utils";
// Match AttacksDisplay: aquarius for outgoing, red-400 for incoming.
const OUTGOING_COLOR = "var(--color-aquarius)";
const INCOMING_COLOR = "var(--color-red-400)";
// At/above this zoom the label is rendered at full size; below it shrinks
// linearly toward LABEL_MIN_RENDERED_SIZE as zoom→0.
const LABEL_FULL_SIZE_ZOOM = 4.0;
const LABEL_MIN_RENDERED_SIZE = 0.63;
// Overall size multiplier applied to the rendered label.
const LABEL_SIZE_MULTIPLIER = 1.0;
// Counter-scale against the container's `scale(zoom)`. At/above
// LABEL_FULL_SIZE_ZOOM the rendered size is capped at LABEL_SIZE_MULTIPLIER;
// below it the rendered size shrinks linearly toward
// LABEL_SIZE_MULTIPLIER * LABEL_MIN_RENDERED_SIZE as zoom→0.
export function computeLabelScale(zoom: number): number {
const t = Math.min(1, zoom / LABEL_FULL_SIZE_ZOOM);
const renderedSize =
LABEL_SIZE_MULTIPLIER *
(LABEL_MIN_RENDERED_SIZE + (1 - LABEL_MIN_RENDERED_SIZE) * t);
return renderedSize / zoom;
}
// Worker returns clusters sorted by size; two near-equal-size fronts can flip
// ordering tick-to-tick. If swapping brings each new position closer to where
// its label already is, swap `next` in place. (clusteredPositions caps at 2.)
export function alignClusterOrder(next: Cell[], prev: (Cell | null)[]): void {
const [a, b] = prev;
if (next.length !== 2 || !a || !b) return;
const dist = (p: Cell, q: Cell) => Math.abs(p.x - q.x) + Math.abs(p.y - q.y);
const direct = dist(next[0], a) + dist(next[1], b);
const swapped = dist(next[1], a) + dist(next[0], b);
if (swapped < direct) [next[0], next[1]] = [next[1], next[0]];
}
// An attack can have multiple disconnected front-line segments, so elements
// and positions are parallel arrays with one entry per segment.
interface AttackLabel {
elements: HTMLDivElement[];
positions: (Cell | null)[];
isIncoming: boolean;
attackerTroops: number;
}
export class AttackingTroopsOverlay implements Controller {
private container: HTMLDivElement;
private labelTemplate: HTMLDivElement;
private labels = new Map<string, AttackLabel>();
// Guard against queuing multiple worker requests in the same tick window.
private inFlightRequest = false;
private isVisible = true;
private onAlternateView: (e: AlternateViewEvent) => void;
// Last transform string written per element; lets renderLayer skip identical
// re-assignments every frame (~60fps × N labels).
private lastTransform = new WeakMap<HTMLDivElement, string>();
constructor(
private readonly game: GameView,
private readonly transformHandler: TransformHandler,
private readonly eventBus: EventBus,
private readonly userSettings: UserSettings,
) {}
init() {
this.container = document.createElement("div");
this.container.style.position = "fixed";
this.container.style.left = "50%";
this.container.style.top = "50%";
this.container.style.pointerEvents = "none";
// z-index 4 places labels above NameLayer (z-index 3).
this.container.style.zIndex = "4";
document.body.appendChild(this.container);
this.labelTemplate = this.createLabelTemplate();
this.onAlternateView = (e) => {
this.isVisible = !e.alternateView;
this.container.style.display = this.isVisible ? "" : "none";
};
this.eventBus.on(AlternateViewEvent, this.onAlternateView);
// Self-driven RAF: DOM label positions must update every frame so the
// labels track the WebGL camera as the user pans/zooms. (Previously this
// ran via the now-deleted canvas2D RAF loop.)
const drive = () => {
this.updateLabelDOM();
requestAnimationFrame(drive);
};
requestAnimationFrame(drive);
}
destroy() {
if (!this.container) return;
this.clearAllLabels();
this.container.remove();
this.eventBus.off(AlternateViewEvent, this.onAlternateView);
}
getTickIntervalMs() {
return 200;
}
private labelScale(): number {
return computeLabelScale(this.transformHandler.scale);
}
tick() {
if (!this.userSettings.attackingTroopsOverlay() || !this.isVisible) {
if (this.labels.size > 0) this.clearAllLabels();
return;
}
const myPlayer = this.game.myPlayer();
if (!myPlayer) {
this.clearAllLabels();
return;
}
const activeIDs = new Set<string>();
// Outgoing: only label attacks targeting another player.
for (const attack of myPlayer.outgoingAttacks()) {
activeIDs.add(attack.id);
if (!attack.targetID) {
this.removeLabel(attack.id);
continue;
}
const defender = this.game.playerBySmallID(attack.targetID);
if (!defender || !defender.isPlayer()) {
this.removeLabel(attack.id);
continue;
}
this.ensureLabel(attack.id, attack.troops, false);
}
// Incoming: only label attacks coming from another player; skip tribes.
for (const attack of myPlayer.incomingAttacks()) {
activeIDs.add(attack.id);
const attacker = this.game.playerBySmallID(attack.attackerID);
if (
!attacker ||
!attacker.isPlayer() ||
attacker.type() === PlayerType.Bot
) {
this.removeLabel(attack.id);
continue;
}
this.ensureLabel(attack.id, attack.troops, true);
}
for (const [id] of this.labels) {
if (!activeIDs.has(id)) this.removeLabel(id);
}
// Single worker request per tick; skip if the previous one is still in flight.
if (this.inFlightRequest) return;
this.inFlightRequest = true;
void myPlayer
.attackClusteredPositions()
.then((attacks) => {
for (const { id, positions } of attacks) {
const lbl = this.labels.get(id);
if (!lbl) continue;
this.reconcileLabelPositions(lbl, positions);
}
})
.catch(() => {
// On error, hide all labels until the next successful response.
for (const lbl of this.labels.values()) lbl.positions.fill(null);
})
.finally(() => {
this.inFlightRequest = false;
});
}
private ensureLabel(
attackID: string,
attackerTroops: number,
isIncoming: boolean,
) {
let label = this.labels.get(attackID);
if (!label) {
label = {
elements: [],
positions: [],
isIncoming,
attackerTroops,
};
this.labels.set(attackID, label);
} else {
label.attackerTroops = attackerTroops;
}
for (const el of label.elements) {
this.updateLabelContent(el, attackerTroops);
}
}
private updateLabelDOM() {
const screenPosOld = this.transformHandler.worldToScreenCoordinates(
new Cell(0, 0),
);
const screenPos = new Cell(
screenPosOld.x - window.innerWidth / 2,
screenPosOld.y - window.innerHeight / 2,
);
this.container.style.transform = `translate(${screenPos.x}px, ${screenPos.y}px) scale(${this.transformHandler.scale})`;
// Hoist the per-frame label scale once; zoom is constant within a frame.
const scale = this.labelScale();
const innerTransform = `scale(${scale})`;
for (const label of this.labels.values()) {
for (let i = 0; i < label.elements.length; i++) {
const el = label.elements[i];
const pos = label.positions[i];
if (!pos || !this.transformHandler.isOnScreen(pos)) {
el.style.display = "none";
continue;
}
el.style.display = "";
const inner = el.children[0] as HTMLDivElement;
// Outer: world position only — the 0.25s transition smooths cluster
// shifts. Inner: scale only — applied without transition so zoom is
// instant.
const outerTransform = `translate(${pos.x}px, ${pos.y}px) translate(-50%, -50%)`;
if (this.lastTransform.get(el) !== outerTransform) {
el.style.transform = outerTransform;
this.lastTransform.set(el, outerTransform);
}
inner.style.transform = innerTransform;
}
}
}
private reconcileLabelPositions(lbl: AttackLabel, positions: Cell[]) {
// Add elements for new clusters.
while (lbl.elements.length < positions.length) {
lbl.elements.push(
this.createLabelElement(lbl.attackerTroops, lbl.isIncoming),
);
lbl.positions.push(null);
}
// Remove elements for clusters that no longer exist.
while (lbl.elements.length > positions.length) {
lbl.elements.pop()!.remove();
lbl.positions.pop();
}
alignClusterOrder(positions, lbl.positions);
// Snap teleport-sized jumps instantly; let the CSS transition handle the rest.
for (let i = 0; i < positions.length; i++) {
const old = lbl.positions[i];
const next = positions[i];
if (old && Math.hypot(next.x - old.x, next.y - old.y) > 200) {
const el = lbl.elements[i];
el.style.transition = "none";
const outerTransform = `translate(${next.x}px, ${next.y}px) translate(-50%, -50%)`;
el.style.transform = outerTransform;
this.lastTransform.set(el, outerTransform);
requestAnimationFrame(() => {
el.style.transition = "transform 0.25s linear";
});
}
lbl.positions[i] = next;
}
}
// Outer wraps position+transition (animates cluster moves). Inner holds the
// scale (instant on zoom) plus all visual chrome. Splitting them keeps the
// 0.25s transition off zoom changes.
private createLabelTemplate(): HTMLDivElement {
const outer = document.createElement("div");
outer.style.position = "absolute";
outer.style.display = "none";
outer.style.pointerEvents = "none";
outer.style.transition = "transform 0.25s linear";
const inner = document.createElement("div");
inner.style.whiteSpace = "nowrap";
inner.style.fontSize = "17px";
inner.style.fontWeight = "bold";
inner.style.lineHeight = "1.3";
inner.style.width = "max-content";
// No background — let the territory border show through. Stacked black
// text-shadows form a soft dark glow so the number stays readable over
// any terrain.
inner.style.textShadow =
"0 0 2px rgba(0,0,0,1), 0 0 3px rgba(0,0,0,0.85), 0 0 5px rgba(0,0,0,0.5)";
outer.appendChild(inner);
return outer;
}
private createLabelElement(
attackerTroops: number,
isIncoming: boolean,
): HTMLDivElement {
const el = this.labelTemplate.cloneNode(true) as HTMLDivElement;
const inner = el.children[0] as HTMLDivElement;
inner.style.fontFamily = this.game.config().theme().font();
inner.style.color = isIncoming ? INCOMING_COLOR : OUTGOING_COLOR;
inner.textContent = renderTroops(attackerTroops);
this.container.appendChild(el);
return el;
}
private updateLabelContent(el: HTMLDivElement, attackerTroops: number) {
const inner = el.children[0] as HTMLDivElement;
inner.textContent = renderTroops(attackerTroops);
}
private removeLabel(attackID: string) {
const label = this.labels.get(attackID);
if (!label) return;
for (const el of label.elements) el.remove();
this.labels.delete(attackID);
}
private clearAllLabels() {
for (const label of this.labels.values()) {
for (const el of label.elements) el.remove();
}
this.labels.clear();
}
}
+441
View File
@@ -0,0 +1,441 @@
import { html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
import { assetUrl } from "../../../core/AssetUrls";
import { EventBus } from "../../../core/EventBus";
import { MessageType, PlayerType, UnitType } from "../../../core/game/Game";
import {
AttackUpdate,
GameUpdateType,
UnitIncomingUpdate,
} from "../../../core/game/GameUpdates";
import { GameView, PlayerView, UnitView } from "../../../core/game/GameView";
import { Controller } from "../../Controller";
import {
GoToPlayerEvent,
GoToPositionEvent,
GoToUnitEvent,
} from "../../TransformHandler";
import {
CancelAttackIntentEvent,
CancelBoatIntentEvent,
SendAttackIntentEvent,
} from "../../Transport";
import { UIState } from "../../UIState";
import { renderTroops, translateText } from "../../Utils";
import { getColoredSprite } from "../SpriteLoader";
const soldierIcon = assetUrl("images/SoldierIcon.svg");
const swordIcon = assetUrl("images/SwordIcon.svg");
@customElement("attacks-display")
export class AttacksDisplay extends LitElement implements Controller {
public eventBus: EventBus;
public game: GameView;
public uiState: UIState;
private active: boolean = false;
private incomingBoatIDs: Set<number> = new Set();
private spriteDataURLCache: Map<string, string> = new Map();
@state() private _isVisible: boolean = false;
@state() private incomingAttacks: AttackUpdate[] = [];
@state() private outgoingAttacks: AttackUpdate[] = [];
@state() private outgoingLandAttacks: AttackUpdate[] = [];
@state() private outgoingBoats: UnitView[] = [];
@state() private incomingBoats: UnitView[] = [];
createRenderRoot() {
return this;
}
init() {}
tick() {
this.active = true;
if (!this._isVisible && !this.game.inSpawnPhase()) {
this._isVisible = true;
}
const myPlayer = this.game.myPlayer();
if (!myPlayer || !myPlayer.isAlive()) {
if (this._isVisible) {
this._isVisible = false;
}
return;
}
// Track incoming boat unit IDs from UnitIncoming events
const updates = this.game.updatesSinceLastTick();
if (updates) {
for (const event of updates[
GameUpdateType.UnitIncoming
] as UnitIncomingUpdate[]) {
if (
event.playerID === myPlayer.smallID() &&
event.messageType === MessageType.NAVAL_INVASION_INBOUND
) {
this.incomingBoatIDs.add(event.unitID);
}
}
}
// Resolve incoming boats from tracked IDs, remove inactive ones
const resolvedIncomingBoats: UnitView[] = [];
for (const unitID of this.incomingBoatIDs) {
const unit = this.game.unit(unitID);
if (unit && unit.isActive() && unit.type() === UnitType.TransportShip) {
resolvedIncomingBoats.push(unit);
} else {
this.incomingBoatIDs.delete(unitID);
}
}
this.incomingBoats = resolvedIncomingBoats;
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();
}
private renderButton(options: {
content: any;
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 emitCancelAttackIntent(id: string) {
const myPlayer = this.game.myPlayer();
if (!myPlayer) return;
this.eventBus.emit(new CancelAttackIntentEvent(id));
}
private emitBoatCancelIntent(id: number) {
const myPlayer = this.game.myPlayer();
if (!myPlayer) return;
this.eventBus.emit(new CancelBoatIntentEvent(id));
}
private emitGoToPlayerEvent(attackerID: number) {
const attacker = this.game.playerBySmallID(attackerID) as PlayerView;
this.eventBus.emit(new GoToPlayerEvent(attacker));
}
private getBoatSpriteDataURL(unit: UnitView): string {
const owner = unit.owner();
const key = `boat-${owner.id()}`;
const cached = this.spriteDataURLCache.get(key);
if (cached) return cached;
try {
const canvas = getColoredSprite(unit, this.game.config().theme());
const dataURL = canvas.toDataURL();
this.spriteDataURLCache.set(key, dataURL);
return dataURL;
} catch {
return "";
}
}
private async attackWarningOnClick(attack: AttackUpdate) {
const playerView = this.game.playerBySmallID(attack.attackerID);
if (playerView !== undefined) {
if (playerView instanceof PlayerView) {
const attacks = await playerView.attackClusteredPositions(attack.id);
const pos = attacks[0]?.positions[0];
if (!pos) {
this.emitGoToPlayerEvent(attack.attackerID);
} else {
this.eventBus.emit(new GoToPositionEvent(pos.x, pos.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() {
if (this.incomingAttacks.length === 0) return html``;
return this.incomingAttacks.map(
(attack) => html`
<div
class="flex items-center gap-0.5 w-full bg-gray-800/92 backdrop-blur-sm sm:rounded-lg px-1.5 py-0.5 overflow-hidden"
>
${this.renderButton({
content: html`<span class="inline-flex items-center"
><img
src="${soldierIcon}"
class="h-4 w-4"
style="filter: brightness(0) saturate(100%) invert(27%) sepia(91%) saturate(4551%) hue-rotate(348deg) brightness(89%) contrast(97%)"
/>↓</span
><span class="ml-1">${renderTroops(attack.troops)}</span>
<span class="truncate ml-1"
>${(
this.game.playerBySmallID(attack.attackerID) as PlayerView
)?.displayName()}</span
>
${attack.retreating
? `(${translateText("events_display.retreating")}...)`
: ""} `,
onClick: () => this.attackWarningOnClick(attack),
className:
"text-left text-red-400 inline-flex items-center gap-0.5 lg:gap-1 min-w-0",
translate: false,
})}
${!attack.retreating
? this.renderButton({
content: html`<img
src="${swordIcon}"
class="h-4 w-4"
style="filter: brightness(0) saturate(100%) invert(27%) sepia(91%) saturate(4551%) hue-rotate(348deg) brightness(89%) contrast(97%)"
/>`,
onClick: () => this.handleRetaliate(attack),
className:
"ml-auto inline-flex items-center justify-center cursor-pointer bg-red-900/50 hover:bg-red-800/70 sm:rounded-lg px-1.5 py-1 border border-red-700/50",
translate: false,
})
: ""}
</div>
`,
);
}
private renderOutgoingAttacks() {
if (this.outgoingAttacks.length === 0) return html``;
return this.outgoingAttacks.map(
(attack) => html`
<div
class="flex items-center gap-0.5 w-full bg-gray-800/92 backdrop-blur-sm sm:rounded-lg px-1.5 py-0.5 overflow-hidden"
>
${this.renderButton({
content: html`<span class="inline-flex items-center"
><img
src="${soldierIcon}"
class="h-4 w-4"
style="filter: brightness(0) saturate(100%) invert(62%) sepia(80%) saturate(500%) hue-rotate(175deg) brightness(100%)"
/>↑</span
><span class="ml-1">${renderTroops(attack.troops)}</span>
<span class="truncate ml-1"
>${(
this.game.playerBySmallID(attack.targetID) as PlayerView
)?.displayName()}</span
> `,
onClick: async () => this.attackWarningOnClick(attack),
className:
"text-left text-aquarius inline-flex items-center gap-0.5 lg:gap-1 min-w-0",
translate: false,
})}
${!attack.retreating
? this.renderButton({
content: "❌",
onClick: () => this.emitCancelAttackIntent(attack.id),
className: "ml-auto text-left shrink-0",
disabled: attack.retreating,
})
: html`<span class="ml-auto truncate text-aquarius"
>(${translateText("events_display.retreating")}...)</span
>`}
</div>
`,
);
}
private renderOutgoingLandAttacks() {
if (this.outgoingLandAttacks.length === 0) return html``;
return this.outgoingLandAttacks.map(
(landAttack) => html`
<div
class="flex items-center gap-0.5 w-full bg-gray-800/92 backdrop-blur-sm sm:rounded-lg px-1.5 py-0.5 overflow-hidden"
>
${this.renderButton({
content: html`<span class="inline-flex items-center"
><img
src="${soldierIcon}"
class="h-4 w-4"
style="filter: brightness(0) saturate(100%) invert(62%) sepia(80%) saturate(500%) hue-rotate(175deg) brightness(100%)"
/>↑</span
><span class="ml-1">${renderTroops(landAttack.troops)}</span>
${translateText("help_modal.ui_wilderness")}`,
className:
"text-left text-aquarius inline-flex items-center gap-0.5 lg:gap-1 min-w-0",
translate: false,
})}
${!landAttack.retreating
? this.renderButton({
content: "❌",
onClick: () => this.emitCancelAttackIntent(landAttack.id),
className: "ml-auto text-left shrink-0",
disabled: landAttack.retreating,
})
: html`<span class="ml-auto truncate text-aquarius"
>(${translateText("events_display.retreating")}...)</span
>`}
</div>
`,
);
}
private getBoatTargetName(boat: UnitView): string {
const target = boat.targetTile();
if (target === undefined) return "";
const ownerID = this.game.ownerID(target);
if (ownerID === 0) return "";
const player = this.game.playerBySmallID(ownerID) as PlayerView;
return player?.displayName() ?? "";
}
private renderBoatIcon(boat: UnitView) {
const dataURL = this.getBoatSpriteDataURL(boat);
if (!dataURL) return html``;
return html`<img
src="${dataURL}"
class="h-5 w-5 inline-block"
style="image-rendering: pixelated"
/>`;
}
private renderBoats() {
if (this.outgoingBoats.length === 0) return html``;
return this.outgoingBoats.map(
(boat) => html`
<div
class="flex items-center gap-0.5 w-full bg-gray-800/92 backdrop-blur-sm sm:rounded-lg px-1.5 py-0.5 overflow-hidden"
>
${this.renderButton({
content: html`${this.renderBoatIcon(boat)}
<span class="inline-block min-w-[3rem] text-right"
>${renderTroops(boat.troops())}</span
>
<span class="truncate text-xs ml-1"
>${this.getBoatTargetName(boat)}</span
>`,
onClick: () => this.eventBus.emit(new GoToUnitEvent(boat)),
className:
"text-left text-aquarius inline-flex items-center gap-0.5 lg:gap-1 min-w-0",
translate: false,
})}
${boat.transportShipState().isRetreating
? html`<span class="ml-auto truncate text-aquarius"
>(${translateText("events_display.retreating")}...)</span
>`
: this.renderButton({
content: "\u274C",
onClick: () => this.emitBoatCancelIntent(boat.id()),
className: "ml-auto text-left shrink-0",
disabled: boat.transportShipState().isRetreating,
})}
</div>
`,
);
}
private renderIncomingBoats() {
if (this.incomingBoats.length === 0) return html``;
return this.incomingBoats.map(
(boat) => html`
<div
class="flex items-center gap-0.5 w-full bg-gray-800/92 backdrop-blur-sm sm:rounded-lg px-1.5 py-0.5 overflow-hidden"
>
${this.renderButton({
content: html`${this.renderBoatIcon(boat)}
<span class="inline-block min-w-[3rem] text-right"
>${renderTroops(boat.troops())}</span
>
<span class="truncate text-xs ml-1"
>${boat.owner()?.displayName()}</span
>`,
onClick: () => this.eventBus.emit(new GoToUnitEvent(boat)),
className:
"text-left text-red-400 inline-flex items-center gap-0.5 lg:gap-1 min-w-0",
translate: false,
})}
</div>
`,
);
}
render() {
if (!this.active || !this._isVisible) {
return html``;
}
const hasAnything =
this.outgoingAttacks.length > 0 ||
this.outgoingLandAttacks.length > 0 ||
this.outgoingBoats.length > 0 ||
this.incomingAttacks.length > 0 ||
this.incomingBoats.length > 0;
if (!hasAnything) {
return html``;
}
return html`
<div
class="w-full mb-1 mt-1 sm:mt-0 pointer-events-auto grid grid-cols-2 gap-1 text-white text-sm lg:text-base max-h-[7rem] overflow-y-auto"
>
${this.renderOutgoingAttacks()} ${this.renderOutgoingLandAttacks()}
${this.renderBoats()} ${this.renderIncomingAttacks()}
${this.renderIncomingBoats()}
</div>
`;
}
}
+508
View File
@@ -0,0 +1,508 @@
import { css, html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
import { translateText } from "../../../client/Utils";
import { assetUrl } from "../../../core/AssetUrls";
import { EventBus } from "../../../core/EventBus";
import {
BuildableUnit,
BuildMenus,
Gold,
PlayerBuildableUnitType,
UnitType,
} from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import { GameView } from "../../../core/game/GameView";
import { Controller } from "../../Controller";
import {
CloseViewEvent,
MouseDownEvent,
ShowBuildMenuEvent,
ShowEmojiMenuEvent,
} from "../../InputHandler";
import { TransformHandler } from "../../TransformHandler";
import {
BuildUnitIntentEvent,
SendUpgradeStructureIntentEvent,
} from "../../Transport";
import { UIState } from "../../UIState";
import { renderNumber } from "../../Utils";
const warshipIcon = assetUrl("images/BattleshipIconWhite.svg");
const cityIcon = assetUrl("images/CityIconWhite.svg");
const factoryIcon = assetUrl("images/FactoryIconWhite.svg");
const goldCoinIcon = assetUrl("images/GoldCoinIcon.svg");
const mirvIcon = assetUrl("images/MIRVIcon.svg");
const missileSiloIcon = assetUrl("images/MissileSiloIconWhite.svg");
const hydrogenBombIcon = assetUrl("images/MushroomCloudIconWhite.svg");
const atomBombIcon = assetUrl("images/NukeIconWhite.svg");
const portIcon = assetUrl("images/PortIcon.svg");
const samlauncherIcon = assetUrl("images/SamLauncherIconWhite.svg");
const shieldIcon = assetUrl("images/ShieldIconWhite.svg");
export interface BuildItemDisplay {
unitType: PlayerBuildableUnitType;
icon: string;
description?: string;
key?: string;
countable?: boolean;
}
export const buildTable: BuildItemDisplay[][] = [
[
{
unitType: UnitType.AtomBomb,
icon: atomBombIcon,
description: "build_menu.desc.atom_bomb",
key: "unit_type.atom_bomb",
countable: false,
},
{
unitType: UnitType.MIRV,
icon: mirvIcon,
description: "build_menu.desc.mirv",
key: "unit_type.mirv",
countable: false,
},
{
unitType: UnitType.HydrogenBomb,
icon: hydrogenBombIcon,
description: "build_menu.desc.hydrogen_bomb",
key: "unit_type.hydrogen_bomb",
countable: false,
},
{
unitType: UnitType.Warship,
icon: warshipIcon,
description: "build_menu.desc.warship",
key: "unit_type.warship",
countable: true,
},
{
unitType: UnitType.Port,
icon: portIcon,
description: "build_menu.desc.port",
key: "unit_type.port",
countable: true,
},
{
unitType: UnitType.MissileSilo,
icon: missileSiloIcon,
description: "build_menu.desc.missile_silo",
key: "unit_type.missile_silo",
countable: true,
},
{
unitType: UnitType.SAMLauncher,
icon: samlauncherIcon,
description: "build_menu.desc.sam_launcher",
key: "unit_type.sam_launcher",
countable: true,
},
{
unitType: UnitType.DefensePost,
icon: shieldIcon,
description: "build_menu.desc.defense_post",
key: "unit_type.defense_post",
countable: true,
},
{
unitType: UnitType.City,
icon: cityIcon,
description: "build_menu.desc.city",
key: "unit_type.city",
countable: true,
},
{
unitType: UnitType.Factory,
icon: factoryIcon,
description: "build_menu.desc.factory",
key: "unit_type.factory",
countable: true,
},
],
];
export const flattenedBuildTable = buildTable.flat();
@customElement("build-menu")
export class BuildMenu extends LitElement implements Controller {
public game: GameView;
public eventBus: EventBus;
public uiState: UIState;
private clickedTile: TileRef;
public playerBuildables: BuildableUnit[] | null = null;
private filteredBuildTable: BuildItemDisplay[][] = buildTable;
public transformHandler: TransformHandler;
init() {
this.eventBus.on(ShowBuildMenuEvent, (e) => {
if (!this.game.myPlayer()?.isAlive()) {
return;
}
if (!this._hidden) {
// Players sometimes hold control while building a unit,
// so if the menu is already open, ignore the event.
return;
}
const clickedCell = this.transformHandler.screenToWorldCoordinates(
e.x,
e.y,
);
if (!this.game.isValidCoord(clickedCell.x, clickedCell.y)) {
return;
}
const tile = this.game.ref(clickedCell.x, clickedCell.y);
this.showMenu(tile);
});
this.eventBus.on(CloseViewEvent, () => this.hideMenu());
this.eventBus.on(ShowEmojiMenuEvent, () => this.hideMenu());
this.eventBus.on(MouseDownEvent, () => this.hideMenu());
}
tick() {
if (!this._hidden) {
this.refresh();
}
}
static styles = css`
:host {
display: block;
}
.build-menu {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 9999;
background-color: #1e1e1e;
padding: 15px;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
border-radius: 10px;
display: flex;
flex-direction: column;
align-items: center;
max-width: 95vw;
max-height: 95vh;
overflow-y: auto;
}
.build-description {
font-size: 0.6rem;
}
.build-row {
display: flex;
justify-content: center;
flex-wrap: wrap;
width: 100%;
}
.build-button {
position: relative;
width: 120px;
height: 140px;
border: 2px solid #444;
background-color: #2c2c2c;
color: white;
border-radius: 12px;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
margin: 8px;
padding: 10px;
gap: 5px;
}
.build-button:not(:disabled):hover {
background-color: #3a3a3a;
transform: scale(1.05);
border-color: #666;
}
.build-button:not(:disabled):active {
background-color: #4a4a4a;
transform: scale(0.95);
}
.build-button:disabled {
background-color: #1a1a1a;
border-color: #333;
cursor: not-allowed;
opacity: 0.7;
}
.build-button:disabled img {
opacity: 0.5;
}
.build-button:disabled .build-cost {
color: #ff4444;
}
.build-icon {
font-size: 40px;
margin-bottom: 5px;
}
.build-name {
font-size: 14px;
font-weight: bold;
margin-bottom: 5px;
text-align: center;
}
.build-cost {
font-size: 14px;
}
.hidden {
display: none !important;
}
.build-count-chip {
position: absolute;
top: -10px;
right: -10px;
background-color: #2c2c2c;
color: white;
padding: 2px 10px;
border-radius: 10000px;
transition: all 0.3s ease;
font-size: 12px;
display: flex;
justify-content: center;
align-content: center;
border: 1px solid #444;
}
.build-button:not(:disabled):hover > .build-count-chip {
background-color: #3a3a3a;
border-color: #666;
}
.build-button:not(:disabled):active > .build-count-chip {
background-color: #4a4a4a;
}
.build-button:disabled > .build-count-chip {
background-color: #1a1a1a;
border-color: #333;
cursor: not-allowed;
}
.build-count {
font-weight: bold;
font-size: 14px;
}
@media (max-width: 768px) {
.build-menu {
padding: 10px;
max-height: 80vh;
width: 80vw;
}
.build-button {
width: 140px;
height: 120px;
margin: 4px;
padding: 6px;
gap: 5px;
}
.build-icon {
font-size: 28px;
}
.build-name {
font-size: 12px;
margin-bottom: 3px;
}
.build-cost {
font-size: 11px;
}
.build-count {
font-weight: bold;
font-size: 10px;
}
.build-count-chip {
padding: 1px 5px;
}
}
@media (max-width: 480px) {
.build-menu {
padding: 8px;
max-height: 70vh;
}
.build-button {
width: calc(50% - 6px);
height: 100px;
margin: 3px;
padding: 4px;
border-width: 1px;
}
.build-icon {
font-size: 24px;
}
.build-name {
font-size: 10px;
margin-bottom: 2px;
}
.build-cost {
font-size: 9px;
}
.build-count {
font-weight: bold;
font-size: 8px;
}
.build-count-chip {
padding: 0 3px;
}
.build-button img {
width: 24px;
height: 24px;
}
.build-cost img {
width: 10px;
height: 10px;
}
}
`;
@state()
private _hidden = true;
public canBuildOrUpgrade(item: BuildItemDisplay): boolean {
if (this.game?.myPlayer() === null || this.playerBuildables === null) {
return false;
}
const unit = this.playerBuildables.find((u) => u.type === item.unitType);
return unit ? unit.canBuild !== false || unit.canUpgrade !== false : false;
}
public cost(item: BuildItemDisplay): Gold {
for (const bu of this.playerBuildables ?? []) {
if (bu.type === item.unitType) {
return bu.cost;
}
}
return 0n;
}
public count(item: BuildItemDisplay): string {
const player = this.game?.myPlayer();
if (!player) {
return "?";
}
return player.totalUnitLevels(item.unitType).toString();
}
public sendBuildOrUpgrade(buildableUnit: BuildableUnit, tile: TileRef): void {
if (buildableUnit.canUpgrade !== false) {
this.eventBus.emit(
new SendUpgradeStructureIntentEvent(
buildableUnit.canUpgrade,
buildableUnit.type,
),
);
} else if (buildableUnit.canBuild) {
const rocketDirectionUp =
buildableUnit.type === UnitType.AtomBomb ||
buildableUnit.type === UnitType.HydrogenBomb
? this.uiState.rocketDirectionUp
: undefined;
this.eventBus.emit(
new BuildUnitIntentEvent(buildableUnit.type, tile, rocketDirectionUp),
);
}
this.hideMenu();
}
render() {
return html`
<div
class="build-menu ${this._hidden ? "hidden" : ""}"
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
>
${this.filteredBuildTable.map(
(row) => html`
<div class="build-row">
${row.map((item) => {
const buildableUnit = this.playerBuildables?.find(
(bu) => bu.type === item.unitType,
);
if (buildableUnit === undefined) {
return html``;
}
const enabled =
buildableUnit.canBuild !== false ||
buildableUnit.canUpgrade !== false;
return html`
<button
class="build-button"
@click=${() =>
this.sendBuildOrUpgrade(buildableUnit, this.clickedTile)}
?disabled=${!enabled}
title=${!enabled
? translateText("build_menu.not_enough_money")
: ""}
>
<img
src=${item.icon}
alt="${item.unitType}"
width="40"
height="40"
/>
<span class="build-name"
>${item.key && translateText(item.key)}</span
>
<span class="build-description"
>${item.description &&
translateText(item.description)}</span
>
<span class="build-cost" translate="no">
${renderNumber(
this.game && this.game.myPlayer() ? this.cost(item) : 0,
)}
<img
src=${goldCoinIcon}
alt="gold"
width="12"
height="12"
class="align-middle"
/>
</span>
${item.countable
? html`<div class="build-count-chip">
<span class="build-count">${this.count(item)}</span>
</div>`
: ""}
</button>
`;
})}
</div>
`,
)}
</div>
`;
}
hideMenu() {
this._hidden = true;
this.requestUpdate();
}
showMenu(clickedTile: TileRef) {
this.clickedTile = clickedTile;
this._hidden = false;
this.refresh();
}
private refresh() {
this.game
.myPlayer()
?.buildables(this.clickedTile, BuildMenus.types)
.then((buildables) => {
this.playerBuildables = buildables;
this.requestUpdate();
});
// remove disabled buildings from the buildtable
this.filteredBuildTable = this.getBuildableUnits();
}
private getBuildableUnits(): BuildItemDisplay[][] {
return buildTable.map((row) =>
row.filter((item) => !this.game?.config()?.isUnitDisabled(item.unitType)),
);
}
get isVisible() {
return !this._hidden;
}
}
+186
View File
@@ -0,0 +1,186 @@
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 { EventBus } from "../../../core/EventBus";
import { MessageType } from "../../../core/game/Game";
import {
DisplayMessageUpdate,
GameUpdateType,
} from "../../../core/game/GameUpdates";
import { GameView } from "../../../core/game/GameView";
import { onlyImages } from "../../../core/Util";
import { Controller } from "../../Controller";
interface ChatEvent {
description: string;
unsafeDescription?: boolean;
createdAt: number;
highlight?: boolean;
}
@customElement("chat-display")
export class ChatDisplay extends LitElement implements Controller {
public eventBus: EventBus;
public game: GameView;
private active: boolean = false;
@state() private _hidden: boolean = false;
@state() private newEvents: number = 0;
@state() private chatEvents: ChatEvent[] = [];
private toggleHidden() {
this._hidden = !this._hidden;
if (this._hidden) {
this.newEvents = 0;
}
this.requestUpdate();
}
private addEvent(event: ChatEvent) {
this.chatEvents = [...this.chatEvents, event];
if (this._hidden) {
this.newEvents++;
}
this.requestUpdate();
}
private removeEvent(index: number) {
this.chatEvents = [
...this.chatEvents.slice(0, index),
...this.chatEvents.slice(index + 1),
];
}
onDisplayMessageEvent(event: DisplayMessageUpdate) {
if (event.messageType !== MessageType.CHAT) return;
const myPlayer = this.game.myPlayer();
if (
event.playerID !== null &&
(!myPlayer || myPlayer.smallID() !== event.playerID)
) {
return;
}
this.addEvent({
description: event.message,
createdAt: this.game.ticks(),
highlight: true,
unsafeDescription: true,
});
}
init() {}
tick() {
// this.active = true;
const updates = this.game.updatesSinceLastTick();
if (updates === null) return;
const messages = updates[GameUpdateType.DisplayEvent] as
| DisplayMessageUpdate[]
| undefined;
if (messages) {
for (const msg of messages) {
if (msg.messageType === MessageType.CHAT) {
const myPlayer = this.game.myPlayer();
if (
msg.playerID !== null &&
(!myPlayer || myPlayer.smallID() !== msg.playerID)
) {
continue;
}
this.chatEvents = [
...this.chatEvents,
{
description: msg.message,
unsafeDescription: true,
createdAt: this.game.ticks(),
},
];
}
}
}
if (this.chatEvents.length > 100) {
this.chatEvents = this.chatEvents.slice(-100);
}
this.requestUpdate();
}
private getChatContent(
chat: ChatEvent,
): string | DirectiveResult<typeof UnsafeHTMLDirective> {
return chat.unsafeDescription
? unsafeHTML(onlyImages(chat.description))
: chat.description;
}
render() {
if (!this.active) {
return html``;
}
return html`
<div
class="pointer-events-auto ${this._hidden
? "w-fit px-2.5 py-1.25"
: ""} rounded-md bg-black/60 relative max-h-[30vh] flex flex-col-reverse overflow-y-auto w-full lg:bottom-2.5 lg:right-2.5 z-50 lg:max-w-[30vw] lg:w-full lg:w-auto"
>
<div>
<div class="w-full bg-black/80 sticky top-0 px-2.5">
<button
class="text-white cursor-pointer pointer-events-auto ${this
._hidden
? "hidden"
: ""}"
@click=${this.toggleHidden}
>
Hide
</button>
</div>
<button
class="text-white cursor-pointer pointer-events-auto ${this._hidden
? ""
: "hidden"}"
@click=${this.toggleHidden}
>
Chat
<span
class="${this.newEvents
? ""
: "hidden"} inline-block px-2 bg-red-500 rounded-xs"
>${this.newEvents}</span
>
</button>
<table
class="w-full border-collapse text-white shadow-lg lg:text-xl text-xs pointer-events-none ${this
._hidden
? "hidden"
: ""}"
>
<tbody>
${this.chatEvents.map(
(chat) => html`
<tr class="border-b border-gray-200/0">
<td class="lg:p-3 p-1 text-left">
${this.getChatContent(chat)}
</td>
</tr>
`,
)}
</tbody>
</table>
</div>
</div>
`;
}
createRenderRoot() {
return this;
}
}
+102
View File
@@ -0,0 +1,102 @@
import { EventBus } from "../../../core/EventBus";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { SendQuickChatEvent } from "../../Transport";
import { translateText } from "../../Utils";
import { ChatModal, QuickChatPhrase, quickChatPhrases } from "./ChatModal";
import { COLORS, MenuElement, MenuElementParams } from "./RadialMenuElements";
export class ChatIntegration {
private ctModal: ChatModal;
constructor(
private game: GameView,
private eventBus: EventBus,
) {
this.ctModal = document.querySelector("chat-modal") as ChatModal;
if (!this.ctModal) {
throw new Error(
"Chat modal element not found. Ensure chat-modal element exists in DOM before initializing ChatIntegration",
);
}
}
setupChatModal(sender: PlayerView, recipient: PlayerView) {
this.ctModal.setSender(sender);
this.ctModal.setRecipient(recipient);
}
createQuickChatMenu(recipient: PlayerView): MenuElement[] {
if (!this.ctModal) {
throw new Error("Chat modal not set");
}
const myPlayer = this.game.myPlayer();
if (!myPlayer) {
throw new Error("Current player not found");
}
return this.ctModal.categories.map((category) => {
const categoryTranslation = translateText(`chat.cat.${category.id}`);
const categoryColor =
COLORS.chat[category.id as keyof typeof COLORS.chat] ||
COLORS.chat.default;
const phrases = quickChatPhrases[category.id] || [];
const phraseItems: MenuElement[] = phrases.map(
(phrase: QuickChatPhrase) => {
const phraseText = translateText(`chat.${category.id}.${phrase.key}`);
return {
id: `phrase-${category.id}-${phrase.key}`,
name: phraseText,
disabled: () => false,
text: this.shortenText(phraseText),
fontSize: "10px",
color: categoryColor,
tooltipItems: [
{
text: phraseText,
className: "description",
},
],
action: (params: MenuElementParams) => {
if (phrase.requiresPlayer) {
this.ctModal.openWithSelection(
category.id,
phrase.key,
myPlayer,
recipient,
);
} else {
this.eventBus.emit(
new SendQuickChatEvent(
recipient,
`${category.id}.${phrase.key}`,
undefined,
),
);
}
},
};
},
);
return {
id: `chat-category-${category.id}`,
name: categoryTranslation,
disabled: () => false,
text: categoryTranslation,
color: categoryColor,
_action: () => {}, // Empty action placeholder for RadialMenu
subMenu: () => phraseItems,
};
});
}
shortenText(text: string, maxLength = 15): string {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength - 3) + "...";
}
}
+333
View File
@@ -0,0 +1,333 @@
import { LitElement, html } from "lit";
import { customElement, query } from "lit/decorators.js";
import { PlayerType } from "../../../core/game/Game";
import { GameView, PlayerView } from "../../../core/game/GameView";
import quickChatData from "resources/QuickChat.json";
import { EventBus } from "../../../core/EventBus";
import { CloseViewEvent } from "../../InputHandler";
import { SendQuickChatEvent } from "../../Transport";
import { translateText } from "../../Utils";
export type QuickChatPhrase = {
key: string;
requiresPlayer: boolean;
};
export type QuickChatPhrases = Record<string, QuickChatPhrase[]>;
export const quickChatPhrases: QuickChatPhrases = quickChatData;
@customElement("chat-modal")
export class ChatModal extends LitElement {
@query("o-modal") private modalEl!: HTMLElement & {
open: () => void;
close: () => void;
};
createRenderRoot() {
return this;
}
private players: PlayerView[] = [];
private playerSearchQuery: string = "";
private previewText: string | null = null;
private requiresPlayerSelection: boolean = false;
private selectedCategory: string | null = null;
private selectedPhraseText: string | null = null;
private selectedPhraseTemplate: string | null = null;
private selectedQuickChatKey: string | null = null;
private selectedPlayer: PlayerView | null = null;
private recipient: PlayerView;
private sender: PlayerView;
public eventBus: EventBus;
public g: GameView;
quickChatPhrases: Record<
string,
Array<{ text: string; requiresPlayer: boolean }>
> = {
help: [{ text: "Please give me troops!", requiresPlayer: false }],
attack: [{ text: "Attack [P1]!", requiresPlayer: true }],
defend: [{ text: "Defend [P1]!", requiresPlayer: true }],
greet: [{ text: "Hello!", requiresPlayer: false }],
misc: [{ text: "Let's go!", requiresPlayer: false }],
};
public categories = [
{ id: "help" },
{ id: "attack" },
{ id: "defend" },
{ id: "greet" },
{ id: "misc" },
{ id: "warnings" },
];
private getPhrasesForCategory(categoryId: string) {
return quickChatPhrases[categoryId] ?? [];
}
render() {
return html`
<o-modal title="${translateText("chat.title")}">
<div class="chat-columns">
<div class="chat-column">
<div class="column-title">${translateText("chat.category")}</div>
${this.categories.map(
(category) => html`
<button
class="chat-option-button ${this.selectedCategory ===
category.id
? "selected"
: ""}"
@click=${() => this.selectCategory(category.id)}
>
${translateText(`chat.cat.${category.id}`)}
</button>
`,
)}
</div>
${this.selectedCategory
? html`
<div class="chat-column">
<div class="column-title">
${translateText("chat.phrase")}
</div>
<div class="phrase-scroll-area">
${this.getPhrasesForCategory(this.selectedCategory).map(
(phrase) => html`
<button
class="chat-option-button ${this
.selectedPhraseText ===
translateText(
`chat.${this.selectedCategory}.${phrase.key}`,
)
? "selected"
: ""}"
@click=${() => this.selectPhrase(phrase)}
>
${this.renderPhrasePreview(phrase)}
</button>
`,
)}
</div>
</div>
`
: null}
${this.requiresPlayerSelection || this.selectedPlayer
? html`
<div class="chat-column">
<div class="column-title">
${translateText("chat.player")}
</div>
<input
class="player-search-input"
type="text"
placeholder="${translateText("chat.search")}"
.value=${this.playerSearchQuery}
@input=${this.onPlayerSearchInput}
/>
<div class="player-scroll-area">
${this.getSortedFilteredPlayers().map(
(player) => html`
<button
class="chat-option-button ${this.selectedPlayer ===
player
? "selected"
: ""}"
style="border: 2px solid ${player
.territoryColor()
.toHex()};"
@click=${() => this.selectPlayer(player)}
>
${player.displayName()}
</button>
`,
)}
</div>
</div>
`
: null}
</div>
<div class="chat-preview">
${this.previewText
? translateText(this.previewText)
: translateText("chat.build")}
</div>
<div class="chat-send">
<button
class="chat-send-button"
@click=${this.sendChatMessage}
?disabled=${!this.previewText ||
(this.requiresPlayerSelection && !this.selectedPlayer)}
>
${translateText("chat.send")}
</button>
</div>
</o-modal>
`;
}
initEventBus(eventBus: EventBus) {
this.eventBus = eventBus;
eventBus.on(CloseViewEvent, (e) => {
if (!this.hidden) {
this.close();
}
});
}
private selectCategory(categoryId: string) {
this.selectedCategory = categoryId;
this.selectedPhraseText = null;
this.previewText = null;
this.requiresPlayerSelection = false;
this.requestUpdate();
}
private selectPhrase(phrase: QuickChatPhrase) {
this.selectedQuickChatKey = this.getFullQuickChatKey(
this.selectedCategory!,
phrase.key,
);
this.selectedPhraseTemplate = translateText(
`chat.${this.selectedCategory}.${phrase.key}`,
);
this.selectedPhraseText = translateText(
`chat.${this.selectedCategory}.${phrase.key}`,
);
this.previewText = `chat.${this.selectedCategory}.${phrase.key}`;
this.requiresPlayerSelection = phrase.requiresPlayer;
this.requestUpdate();
}
private renderPhrasePreview(phrase: { key: string }) {
return translateText(`chat.${this.selectedCategory}.${phrase.key}`);
}
private selectPlayer(player: PlayerView) {
if (this.previewText) {
this.previewText =
this.selectedPhraseTemplate?.replace("[P1]", player.displayName()) ??
null;
this.selectedPlayer = player;
this.requiresPlayerSelection = false;
this.requestUpdate();
}
}
private sendChatMessage() {
console.log("Sent message:", this.previewText);
console.log("Sender:", this.sender);
console.log("Recipient:", this.recipient);
console.log("Key:", this.selectedQuickChatKey);
if (this.sender && this.recipient && this.selectedQuickChatKey) {
this.eventBus.emit(
new SendQuickChatEvent(
this.recipient,
this.selectedQuickChatKey,
this.selectedPlayer?.id(),
),
);
}
this.previewText = null;
this.selectedCategory = null;
this.requiresPlayerSelection = false;
this.close();
this.requestUpdate();
}
private onPlayerSearchInput(e: Event) {
const target = e.target as HTMLInputElement;
this.playerSearchQuery = target.value.toLowerCase();
this.requestUpdate();
}
private getSortedFilteredPlayers(): PlayerView[] {
const sorted = [...this.players].sort((a, b) =>
a.displayName().localeCompare(b.displayName()),
);
const filtered = sorted.filter((p) =>
p.displayName().toLowerCase().includes(this.playerSearchQuery),
);
const others = sorted.filter(
(p) => !p.displayName().toLowerCase().includes(this.playerSearchQuery),
);
return [...filtered, ...others];
}
private getFullQuickChatKey(category: string, phraseKey: string): string {
return `${category}.${phraseKey}`;
}
public open(sender?: PlayerView, recipient?: PlayerView) {
if (sender && recipient) {
console.log("Sent message:", recipient);
console.log("Sent message:", sender);
this.players = this.g
.players()
.filter((p) => p.isAlive() && p.type() !== PlayerType.Bot);
this.recipient = recipient;
this.sender = sender;
}
this.requestUpdate();
this.modalEl?.open();
}
public close() {
this.selectedCategory = null;
this.selectedPhraseText = null;
this.previewText = null;
this.requiresPlayerSelection = false;
this.modalEl?.close();
}
public setRecipient(value: PlayerView) {
this.recipient = value;
}
public setSender(value: PlayerView) {
this.sender = value;
}
public openWithSelection(
categoryId: string,
phraseKey: string,
sender?: PlayerView,
recipient?: PlayerView,
) {
if (sender && recipient) {
this.players = this.g
.players()
.filter((p) => p.isAlive() && p.type() !== PlayerType.Bot);
this.recipient = recipient;
this.sender = sender;
}
this.selectCategory(categoryId);
const phrase = this.getPhrasesForCategory(categoryId).find(
(p) => p.key === phraseKey,
);
if (phrase) {
this.selectPhrase(phrase);
}
this.requestUpdate();
this.modalEl?.open();
}
}
+392
View File
@@ -0,0 +1,392 @@
import { LitElement, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { assetUrl } from "../../../core/AssetUrls";
import { EventBus } from "../../../core/EventBus";
import { Gold } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import { UserSettings } from "../../../core/game/UserSettings";
import { ClientID } from "../../../core/Schemas";
import { Controller } from "../../Controller";
import { AttackRatioEvent } from "../../InputHandler";
import { UIState } from "../../UIState";
import { renderNumber, renderTroops } from "../../Utils";
const goldCoinIcon = assetUrl("images/GoldCoinIcon.svg");
const soldierIcon = assetUrl("images/SoldierIcon.svg");
const swordIcon = assetUrl("images/SwordIcon.svg");
@customElement("control-panel")
export class ControlPanel extends LitElement implements Controller {
public game: GameView;
public clientID: ClientID;
public eventBus: EventBus;
public uiState: UIState;
@state()
private attackRatio: number = 0.2;
@state()
private _maxTroops: number;
@state()
private troopRate: number;
@state()
private _troops: number;
@state()
private _isVisible = false;
@state()
private _gold: Gold;
@state()
private _attackingTroops: number = 0;
private _troopRateIsIncreasing: boolean = true;
private _lastTroopIncreaseRate: number;
getTickIntervalMs() {
return 100;
}
init() {
this.attackRatio = new UserSettings().attackRatio();
this.uiState.attackRatio = this.attackRatio;
this.eventBus.on(AttackRatioEvent, (event) => {
let newAttackRatio = this.attackRatio + event.attackRatio / 100;
if (newAttackRatio < 0.01) {
newAttackRatio = 0.01;
}
if (newAttackRatio > 1) {
newAttackRatio = 1;
}
if (newAttackRatio === 0.11 && this.attackRatio === 0.01) {
// If we're changing the ratio from 1%, then set it to 10% instead of 11% to keep a consistency
newAttackRatio = 0.1;
}
this.attackRatio = newAttackRatio;
this.onAttackRatioChange(this.attackRatio);
});
}
tick() {
if (!this._isVisible && !this.game.inSpawnPhase()) {
this.setVisibile(true);
}
const player = this.game.myPlayer();
if (player === null || !player.isAlive()) {
this.setVisibile(false);
return;
}
this.updateTroopIncrease();
this._maxTroops = this.game.config().maxTroops(player);
this._gold = player.gold();
this._troops = player.troops();
this._attackingTroops = player
.outgoingAttacks()
.map((a) => a.troops)
.reduce((a, b) => a + b, 0);
this.troopRate = this.game.config().troopIncreaseRate(player) * 10;
this.requestUpdate();
}
private updateTroopIncrease() {
const player = this.game?.myPlayer();
if (player === null) return;
const troopIncreaseRate = this.game.config().troopIncreaseRate(player);
this._troopRateIsIncreasing =
troopIncreaseRate >= this._lastTroopIncreaseRate;
this._lastTroopIncreaseRate = troopIncreaseRate;
}
onAttackRatioChange(newRatio: number) {
this.uiState.attackRatio = newRatio;
}
setVisibile(visible: boolean) {
this._isVisible = visible;
this.requestUpdate();
}
private handleRatioSliderInput(e: Event) {
const input = e.target as HTMLInputElement;
const value = Number(input.value);
this.attackRatio = value / 100;
this.onAttackRatioChange(this.attackRatio);
}
private handleRatioSliderPointerUp(e: Event) {
(e.target as HTMLInputElement).blur();
}
private calculateTroopBar(): { greenPercent: number; orangePercent: number } {
const base = Math.max(this._maxTroops, 1);
const greenPercentRaw = (this._troops / base) * 100;
const orangePercentRaw = (this._attackingTroops / base) * 100;
const greenPercent = Math.max(0, Math.min(100, greenPercentRaw));
const orangePercent = Math.max(
0,
Math.min(100 - greenPercent, orangePercentRaw),
);
return { greenPercent, orangePercent };
}
private renderMobileTroopBar() {
const { greenPercent, orangePercent } = this.calculateTroopBar();
return html`
<div
class="w-full h-6 border border-gray-600 rounded-md bg-gray-900/60 overflow-hidden relative"
>
<div class="h-full flex">
${greenPercent > 0
? html`<div
class="h-full bg-malibu-blue transition-[width] duration-200"
style="width: ${greenPercent}%;"
></div>`
: ""}
${orangePercent > 0
? html`<div
class="h-full bg-aquarius transition-[width] duration-200"
style="width: ${orangePercent}%;"
></div>`
: ""}
</div>
<div
class="absolute inset-0 flex items-center justify-between px-1.5 text-xs font-bold leading-none pointer-events-none"
translate="no"
>
<span class="text-white drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
>${renderTroops(this._troops)}</span
>
<span class="text-white drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
>${renderTroops(this._maxTroops)}</span
>
</div>
<div
class="absolute inset-0 flex items-center justify-center gap-0.5 pointer-events-none"
translate="no"
>
<img
src=${soldierIcon}
alt=""
aria-hidden="true"
width="12"
height="12"
class="brightness-0 invert drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
/>
<span
class="text-[10px] font-bold drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)] ${this
._troopRateIsIncreasing
? "text-green-400"
: "text-orange-400"}"
>+${renderTroops(this.troopRate)}/s</span
>
</div>
</div>
`;
}
private renderDesktopTroopBar() {
const { greenPercent, orangePercent } = this.calculateTroopBar();
return html`
<div
class="w-full h-6 border border-gray-600 rounded-md bg-gray-900/60 overflow-hidden relative"
>
<div class="h-full flex">
${greenPercent > 0
? html`<div
class="h-full bg-malibu-blue transition-[width] duration-200"
style="width: ${greenPercent}%;"
></div>`
: ""}
${orangePercent > 0
? html`<div
class="h-full bg-aquarius transition-[width] duration-200"
style="width: ${orangePercent}%;"
></div>`
: ""}
</div>
<div
class="absolute inset-0 flex items-center text-lg font-bold leading-none pointer-events-none"
translate="no"
>
<span class="flex-1 flex justify-end h-full items-center pr-0.5">
<span class="text-white drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
>${renderTroops(this._troops)}</span
>
</span>
<span
class="h-full flex items-center px-0.5 text-white drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
>/</span
>
<span
class="flex-1 flex justify-start h-full items-center pl-0.5 gap-0.5"
>
<span
class="text-white tabular-nums w-[3.5rem] drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
>${renderTroops(this._maxTroops)}</span
>
<img
src=${soldierIcon}
alt=""
aria-hidden="true"
width="22"
height="22"
class="shrink-0 brightness-0 invert drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)] ml-1.5"
/>
</span>
</div>
</div>
`;
}
private renderDesktop() {
return html`
<!-- Row 1: troop rate | troop bar | gold -->
<div class="flex gap-1.5 items-center mb-1">
<!-- Troop rate -->
<div
class="flex items-center gap-1 shrink-0 border rounded-md font-bold text-sm py-0.5 px-1 w-[5.5rem] ${this
._troopRateIsIncreasing
? "border-green-400"
: "border-orange-400"}"
translate="no"
>
<img
src=${soldierIcon}
alt=""
aria-hidden="true"
width="13"
height="13"
class="shrink-0"
style="filter: ${this._troopRateIsIncreasing
? "brightness(0) saturate(100%) invert(74%) sepia(44%) saturate(500%) hue-rotate(83deg) brightness(103%)"
: "brightness(0) saturate(100%) invert(65%) sepia(60%) saturate(600%) hue-rotate(330deg) brightness(105%)"}"
/>
<span
class="text-sm font-bold tabular-nums ${this._troopRateIsIncreasing
? "text-green-400"
: "text-orange-400"}"
>+${renderTroops(this.troopRate)}/s</span
>
</div>
<!-- Troop bar -->
<div class="flex-1">${this.renderDesktopTroopBar()}</div>
<!-- Gold -->
<div
class="flex items-center gap-1 shrink-0 border rounded-md border-yellow-400 font-bold text-yellow-400 text-sm py-0.5 px-1 w-[4.5rem]"
translate="no"
>
<img src=${goldCoinIcon} width="13" height="13" class="shrink-0" />
<span class="tabular-nums">${renderNumber(this._gold)}</span>
</div>
</div>
<!-- Row 2: attack ratio | slider -->
<div class="flex items-center gap-1.5" translate="no">
<div
class="flex items-center gap-1 shrink-0 border border-gray-600 rounded-md px-1 py-0.5 text-sm font-bold text-white cursor-pointer w-[8rem]"
>
<img
src=${swordIcon}
alt=""
aria-hidden="true"
width="12"
height="12"
style="filter: brightness(0) invert(1);"
/>
<span
>${(this.attackRatio * 100).toFixed(0)}%
(${renderTroops(
(this.game?.myPlayer()?.troops() ?? 0) * this.attackRatio,
)})</span
>
</div>
<input
type="range"
min="1"
max="100"
.value=${String(Math.round(this.attackRatio * 100))}
@input=${(e: Event) => this.handleRatioSliderInput(e)}
@pointerup=${(e: Event) => this.handleRatioSliderPointerUp(e)}
class="flex-1 h-1.5 accent-aquarius cursor-pointer"
/>
</div>
`;
}
private renderMobile() {
return html`
<div class="flex gap-2 items-center">
<!-- Gold -->
<div
class="flex items-center justify-center p-1 gap-0.5 border rounded-md border-yellow-400 font-bold text-yellow-400 text-xs w-1/5 shrink-0"
translate="no"
>
<img src=${goldCoinIcon} width="13" height="13" />
<span class="px-0.5">${renderNumber(this._gold)}</span>
</div>
<!-- Troop bar -->
<div class="w-[40%] shrink-0 flex items-center">
${this.renderMobileTroopBar()}
</div>
<!-- Sword + % label -->
<div
class="flex flex-col items-center shrink-0 gap-0.5 w-8"
translate="no"
>
<img
src=${swordIcon}
alt=""
aria-hidden="true"
width="10"
height="10"
style="filter: brightness(0) invert(1);"
/>
<span class="text-white text-xs font-bold tabular-nums"
>${(this.attackRatio * 100).toFixed(0)}%</span
>
</div>
<!-- Attack ratio slider -->
<div class="flex-1" translate="no">
<input
type="range"
min="1"
max="100"
.value=${String(Math.round(this.attackRatio * 100))}
@input=${(e: Event) => this.handleRatioSliderInput(e)}
@pointerup=${(e: Event) => this.handleRatioSliderPointerUp(e)}
class="w-full h-1.5 accent-aquarius cursor-pointer"
/>
</div>
</div>
`;
}
render() {
return html`
<div
class="relative pointer-events-auto ${this._isVisible
? "relative w-full text-sm px-2 py-1"
: "hidden"}"
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
>
<div class="lg:hidden">${this.renderMobile()}</div>
<div class="hidden lg:block">${this.renderDesktop()}</div>
</div>
`;
}
createRenderRoot() {
return this; // Disable shadow DOM to allow Tailwind styles
}
}
+130
View File
@@ -0,0 +1,130 @@
import { LitElement, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { EventBus } from "../../../core/EventBus";
import { AllPlayers } from "../../../core/game/Game";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { TerraNulliusImpl } from "../../../core/game/TerraNulliusImpl";
import { Emoji, flattenedEmojiTable } from "../../../core/Util";
import { CloseViewEvent, ShowEmojiMenuEvent } from "../../InputHandler";
import { TransformHandler } from "../../TransformHandler";
import { SendEmojiIntentEvent } from "../../Transport";
@customElement("emoji-table")
export class EmojiTable extends LitElement {
@state() public isVisible = false;
public transformHandler: TransformHandler;
public game: GameView;
initEventBus(eventBus: EventBus) {
eventBus.on(ShowEmojiMenuEvent, (e) => {
this.isVisible = true;
const cell = this.transformHandler.screenToWorldCoordinates(e.x, e.y);
if (!this.game.isValidCoord(cell.x, cell.y)) {
return;
}
const tile = this.game.ref(cell.x, cell.y);
if (!this.game.hasOwner(tile)) {
return;
}
const targetPlayer = this.game.owner(tile);
// maybe redundant due to owner check but better safe than sorry
if (targetPlayer instanceof TerraNulliusImpl) {
return;
}
this.showTable((emoji) => {
const recipient =
targetPlayer === this.game.myPlayer()
? AllPlayers
: (targetPlayer as PlayerView);
eventBus.emit(
new SendEmojiIntentEvent(
recipient,
flattenedEmojiTable.indexOf(emoji as Emoji),
),
);
this.hideTable();
});
});
eventBus.on(CloseViewEvent, (e) => {
if (!this.hidden) {
this.hideTable();
}
});
}
private onEmojiClicked: (emoji: string) => void = () => {};
private handleBackdropClick = (e: MouseEvent) => {
const panelContent = this.querySelector(
'div[class*="bg-zinc-900"]',
) as HTMLElement;
if (panelContent && !panelContent.contains(e.target as Node)) {
this.hideTable();
}
};
render() {
if (!this.isVisible) {
return null;
}
return html`
<div
class="fixed inset-0 bg-black/15 backdrop-brightness-110 flex items-start sm:items-center justify-center z-10002 pt-4 sm:pt-0"
@click=${this.handleBackdropClick}
>
<div class="relative">
<!-- Close button -->
<button
class="absolute -top-3 -right-3 w-7 h-7 flex items-center justify-center
bg-zinc-700 hover:bg-red-500 text-white rounded-full shadow-sm transition-colors z-10004"
@click=${this.hideTable}
>
</button>
<div
class="bg-zinc-900/95 p-2 sm:p-3 rounded-[10px] z-10003 shadow-2xl shadow-black/50 ring-1 ring-white/5
w-[calc(100vw-32px)] sm:w-100 max-h-[calc(100vh-60px)] overflow-y-auto"
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
@wheel=${(e: WheelEvent) => e.stopPropagation()}
@click=${(e: MouseEvent) => e.stopPropagation()}
>
<div class="grid grid-cols-5 gap-1 sm:gap-2">
${flattenedEmojiTable.map(
(emoji) => html`
<button
class="flex items-center justify-center cursor-pointer aspect-square
border border-solid border-zinc-600 rounded-lg bg-zinc-800 hover:bg-zinc-700 active:bg-zinc-600
text-3xl sm:text-4xl transition-transform duration-300 hover:scale-110 active:scale-95"
@click=${() => this.onEmojiClicked(emoji)}
>
${emoji}
</button>
`,
)}
</div>
</div>
</div>
</div>
`;
}
hideTable() {
this.isVisible = false;
this.requestUpdate();
}
showTable(oneEmojiClicked: (emoji: string) => void) {
this.onEmojiClicked = oneEmojiClicked;
this.isVisible = true;
this.requestUpdate();
}
createRenderRoot() {
return this; // Disable shadow DOM to allow Tailwind styles
}
}
File diff suppressed because it is too large Load Diff
+205
View File
@@ -0,0 +1,205 @@
import { Colord } from "colord";
import { html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
import { assetUrl } from "../../../core/AssetUrls";
import { EventBus } from "../../../core/EventBus";
import { GameMode, Team } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import { Controller } from "../../Controller";
import { Platform } from "../../Platform";
import { getTranslatedPlayerTeamLabel, translateText } from "../../Utils";
import { ImmunityBarVisibleEvent } from "./ImmunityTimer";
import { SpawnBarVisibleEvent } from "./SpawnTimer";
const leaderboardRegularIcon = assetUrl(
"images/LeaderboardIconRegularWhite.svg",
);
const leaderboardSolidIcon = assetUrl("images/LeaderboardIconSolidWhite.svg");
const teamRegularIcon = assetUrl("images/TeamIconRegularWhite.svg");
const teamSolidIcon = assetUrl("images/TeamIconSolidWhite.svg");
@customElement("game-left-sidebar")
export class GameLeftSidebar extends LitElement implements Controller {
@state()
private isLeaderboardShow = false;
@state()
private isTeamLeaderboardShow = false;
@state()
private isVisible = false;
@state()
private isPlayerTeamLabelVisible = false;
@state()
private playerTeam: Team | null = null;
@state()
private spawnBarVisible = false;
@state()
private immunityBarVisible = false;
private playerColor: Colord = new Colord("#FFFFFF");
public game: GameView;
public eventBus: EventBus;
private _shownOnInit = false;
createRenderRoot() {
return this;
}
init() {
this.isVisible = true;
this.eventBus.on(SpawnBarVisibleEvent, (e) => {
this.spawnBarVisible = e.visible;
});
this.eventBus.on(ImmunityBarVisibleEvent, (e) => {
this.immunityBarVisible = e.visible;
});
if (this.isTeamGame) {
this.isPlayerTeamLabelVisible = true;
}
// Make it visible by default on large screens
if (Platform.isDesktopWidth) {
// lg breakpoint
this._shownOnInit = true;
}
this.requestUpdate();
}
tick() {
if (!this.playerTeam && this.game.myPlayer()?.team()) {
this.playerTeam = this.game.myPlayer()!.team();
if (this.playerTeam) {
this.playerColor = this.game
.config()
.theme()
.teamColor(this.playerTeam);
this.requestUpdate();
}
}
if (this._shownOnInit && !this.game.inSpawnPhase()) {
this._shownOnInit = false;
this.isLeaderboardShow = true;
this.requestUpdate();
}
if (!this.game.inSpawnPhase() && this.isPlayerTeamLabelVisible) {
this.isPlayerTeamLabelVisible = false;
this.requestUpdate();
}
}
private get barOffset(): number {
return (this.spawnBarVisible ? 7 : 0) + (this.immunityBarVisible ? 7 : 0);
}
private toggleLeaderboard(): void {
this.isLeaderboardShow = !this.isLeaderboardShow;
}
private toggleTeamLeaderboard(): void {
this.isTeamLeaderboardShow = !this.isTeamLeaderboardShow;
}
private get isTeamGame(): boolean {
return this.game?.config().gameConfig().gameMode === GameMode.Team;
}
render() {
return html`
<aside
class=${`fixed top-0 min-[1200px]:top-4 left-0 min-[1200px]:left-4 z-900 flex flex-col max-h-[calc(100vh-80px)] overflow-y-auto p-2 bg-gray-800/92 backdrop-blur-sm shadow-xs min-[1200px]:rounded-lg rounded-br-lg ${this.isLeaderboardShow || this.isTeamLeaderboardShow ? "max-[400px]:w-full max-[400px]:rounded-none" : ""} transition-all duration-300 ease-out transform ${
this.isVisible ? "translate-x-0" : "hidden"
}`}
style="margin-top: ${this.barOffset}px;"
>
<div class="flex items-center gap-4 xl:gap-6 text-white">
<div
class="cursor-pointer p-0.5 bg-gray-700/50 hover:bg-gray-600 border rounded-md border-slate-500 transition-colors"
@click=${this.toggleLeaderboard}
role="button"
tabindex="0"
@keydown=${(e: KeyboardEvent) => {
if (e.key === "Enter" || e.key === " " || e.code === "Space") {
e.preventDefault();
this.toggleLeaderboard();
}
}}
>
<img
src=${this.isLeaderboardShow
? leaderboardSolidIcon
: leaderboardRegularIcon}
alt=${translateText("help_modal.icon_alt_player_leaderboard") ||
"Player Leaderboard Icon"}
width="20"
height="20"
/>
</div>
${this.isTeamGame
? html`
<div
class="cursor-pointer p-0.5 bg-gray-700/50 hover:bg-gray-600 border rounded-md border-slate-500 transition-colors"
@click=${this.toggleTeamLeaderboard}
role="button"
tabindex="0"
@keydown=${(e: KeyboardEvent) => {
if (
e.key === "Enter" ||
e.key === " " ||
e.code === "Space"
) {
e.preventDefault();
this.toggleTeamLeaderboard();
}
}}
>
<img
src=${this.isTeamLeaderboardShow
? teamSolidIcon
: teamRegularIcon}
alt=${translateText(
"help_modal.icon_alt_team_leaderboard",
) || "Team Leaderboard Icon"}
width="20"
height="20"
/>
</div>
`
: null}
${this.isLeaderboardShow || this.isTeamLeaderboardShow
? html`<span
class="ml-auto text-[10px] text-slate-500 select-all leading-none self-start"
title=${translateText("help_modal.game_id_tooltip")}
>${this.game?.gameID() ?? ""}</span
>`
: null}
</div>
${this.isPlayerTeamLabelVisible
? html`
<div
class="flex items-center w-full text-white mt-2"
@contextmenu=${(e: Event) => e.preventDefault()}
>
${translateText("help_modal.ui_your_team")}
<span
style="--color: ${this.playerColor.toRgbString()}"
class="text-(--color)"
>
&nbsp;${getTranslatedPlayerTeamLabel(this.playerTeam)}
&#10687;
</span>
</div>
`
: null}
<div
class=${`block lg:flex flex-wrap overflow-x-auto min-w-0 w-full ${this.isLeaderboardShow && this.isTeamLeaderboardShow ? "gap-2" : ""}`}
>
<leader-board .visible=${this.isLeaderboardShow}></leader-board>
<team-stats
class="flex-1"
.visible=${this.isTeamLeaderboardShow && this.isTeamGame}
></team-stats>
</div>
<slot></slot>
</aside>
`;
}
}
+291
View File
@@ -0,0 +1,291 @@
import { html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
import { assetUrl } from "../../../core/AssetUrls";
import { EventBus } from "../../../core/EventBus";
import { GameType } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import { Controller } from "../../Controller";
import { crazyGamesSDK } from "../../CrazyGamesSDK";
import { TogglePauseIntentEvent } from "../../InputHandler";
import { PauseGameIntentEvent, SendWinnerEvent } from "../../Transport";
import { translateText } from "../../Utils";
import { ImmunityBarVisibleEvent } from "./ImmunityTimer";
import { ShowReplayPanelEvent } from "./ReplayPanel";
import { ShowSettingsModalEvent } from "./SettingsModal";
import { SpawnBarVisibleEvent } from "./SpawnTimer";
const exitIcon = assetUrl("images/ExitIconWhite.svg");
const FastForwardIconSolid = assetUrl("images/FastForwardIconSolidWhite.svg");
const pauseIcon = assetUrl("images/PauseIconWhite.svg");
const playIcon = assetUrl("images/PlayIconWhite.svg");
const settingsIcon = assetUrl("images/SettingIconWhite.svg");
const fullscreenIcon = assetUrl("images/FullscreenIconWhite.svg");
const exitFullscreenIcon = assetUrl("images/ExitFullscreenIconWhite.svg");
@customElement("game-right-sidebar")
export class GameRightSidebar extends LitElement implements Controller {
public game: GameView;
public eventBus: EventBus;
@state()
private _isSinglePlayer: boolean = false;
@state()
private _isReplayVisible: boolean = false;
@state()
private _isVisible: boolean = true;
@state()
private isPaused: boolean = false;
@state()
private isFullscreen: boolean = false;
@state()
private timer: number = 0;
private hasWinner = false;
private isLobbyCreator = false;
private spawnBarVisible = false;
private immunityBarVisible = false;
createRenderRoot() {
return this;
}
init() {
this._isSinglePlayer =
this.game?.config()?.gameConfig()?.gameType === GameType.Singleplayer ||
this.game.config().isReplay();
this._isVisible = true;
this.eventBus.on(SpawnBarVisibleEvent, (e) => {
this.spawnBarVisible = e.visible;
this.updateParentOffset();
});
this.eventBus.on(ImmunityBarVisibleEvent, (e) => {
this.immunityBarVisible = e.visible;
this.updateParentOffset();
});
this.eventBus.on(SendWinnerEvent, () => {
this.hasWinner = true;
this.requestUpdate();
});
this.eventBus.on(TogglePauseIntentEvent, () => {
const isReplayOrSingleplayer =
this._isSinglePlayer || this.game?.config()?.isReplay();
if (isReplayOrSingleplayer || this.isLobbyCreator) {
this.onPauseButtonClick();
}
});
this.requestUpdate();
}
private onFullscreenChange = () => {
this.isFullscreen = !!document.fullscreenElement;
};
connectedCallback() {
super.connectedCallback();
document.addEventListener("fullscreenchange", this.onFullscreenChange);
this.onFullscreenChange();
}
disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener("fullscreenchange", this.onFullscreenChange);
}
getTickIntervalMs() {
return 250;
}
tick() {
// Timer logic
// Check if the player is the lobby creator
if (!this.isLobbyCreator && this.game.myPlayer()?.isLobbyCreator()) {
this.isLobbyCreator = true;
this.requestUpdate();
}
const maxTimerValue = this.game.config().gameConfig().maxTimerValue;
if (this.game.inSpawnPhase()) {
this.timer =
maxTimerValue !== null && maxTimerValue !== undefined
? maxTimerValue * 60
: 0;
return;
}
const elapsedSeconds = Math.floor(this.game.elapsedGameSeconds());
if (this.hasWinner) {
return;
}
if (maxTimerValue !== null && maxTimerValue !== undefined) {
this.timer = Math.max(0, maxTimerValue * 60 - elapsedSeconds);
} else {
this.timer = elapsedSeconds;
}
}
private updateParentOffset(): void {
const offset =
(this.spawnBarVisible ? 7 : 0) + (this.immunityBarVisible ? 7 : 0);
const parent = this.parentElement as HTMLElement;
if (parent) {
parent.style.marginTop = `${offset}px`;
}
}
private secondsToHms = (d: number): string => {
const pad = (n: number) => (n < 10 ? `0${n}` : n);
const h = Math.floor(d / 3600);
const m = Math.floor((d % 3600) / 60);
const s = Math.floor((d % 3600) % 60);
if (h !== 0) {
return `${pad(h)}:${pad(m)}:${pad(s)}`;
} else {
return `${pad(m)}:${pad(s)}`;
}
};
private toggleReplayPanel(): void {
this._isReplayVisible = !this._isReplayVisible;
this.eventBus.emit(
new ShowReplayPanelEvent(this._isReplayVisible, this._isSinglePlayer),
);
}
private onPauseButtonClick() {
this.isPaused = !this.isPaused;
if (this.isPaused) {
crazyGamesSDK.gameplayStop();
} else {
crazyGamesSDK.gameplayStart();
}
this.eventBus.emit(new PauseGameIntentEvent(this.isPaused));
}
private async onExitButtonClick() {
const isAlive = this.game.myPlayer()?.isAlive();
if (isAlive) {
const isConfirmed = confirm(
translateText("help_modal.exit_confirmation"),
);
if (!isConfirmed) return;
}
await crazyGamesSDK.requestMidgameAd();
await crazyGamesSDK.gameplayStop();
// redirect to the home page
window.location.href = "/";
}
private onSettingsButtonClick() {
this.eventBus.emit(
new ShowSettingsModalEvent(true, this._isSinglePlayer, this.isPaused),
);
}
private onFullscreenButtonClick() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch((err) => {
console.warn("Failed to enter fullscreen:", err);
});
} else {
document.exitFullscreen().catch((err) => {
console.warn("Failed to exit fullscreen:", err);
});
}
}
render() {
if (this.game === undefined) return html``;
const timerColor =
this.game.config().gameConfig().maxTimerValue !== undefined &&
this.game.config().gameConfig().maxTimerValue !== null &&
this.timer < 60
? "text-red-400"
: "";
return html`
<aside
class=${`w-fit flex flex-row items-center gap-3 py-2 px-3 bg-gray-800/92 backdrop-blur-sm shadow-xs min-[1200px]:rounded-lg rounded-bl-lg transition-transform duration-300 ease-out transform text-white ${
this._isVisible ? "translate-x-0" : "translate-x-full"
}`}
@contextmenu=${(e: Event) => e.preventDefault()}
>
<!-- In-game time -->
<div class=${timerColor}>${this.secondsToHms(this.timer)}</div>
<!-- Buttons -->
${this.maybeRenderReplayButtons()}
<div class="cursor-pointer" @click=${this.onSettingsButtonClick}>
<img src=${settingsIcon} alt="settings" width="20" height="20" />
</div>
${document.fullscreenEnabled
? html`<div
class="cursor-pointer"
@click=${this.onFullscreenButtonClick}
>
<img
src=${this.isFullscreen ? exitFullscreenIcon : fullscreenIcon}
alt=${this.isFullscreen
? translateText("fullscreen.exit")
: translateText("fullscreen.enter")}
width="20"
height="20"
/>
</div>`
: ""}
<div class="cursor-pointer" @click=${this.onExitButtonClick}>
<img src=${exitIcon} alt="exit" width="20" height="20" />
</div>
</aside>
`;
}
maybeRenderReplayButtons() {
const isReplayOrSingleplayer =
this._isSinglePlayer || this.game?.config()?.isReplay();
const showPauseButton = isReplayOrSingleplayer || this.isLobbyCreator;
return html`
${isReplayOrSingleplayer
? html`
<div class="cursor-pointer" @click=${this.toggleReplayPanel}>
<img
src=${FastForwardIconSolid}
alt="replay"
width="20"
height="20"
/>
</div>
`
: ""}
${showPauseButton
? html`
<div class="cursor-pointer" @click=${this.onPauseButtonClick}>
<img
src=${this.isPaused ? playIcon : pauseIcon}
alt="play/pause"
width="20"
height="20"
/>
</div>
`
: ""}
`;
}
}
+188
View File
@@ -0,0 +1,188 @@
import { LitElement, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { GameType } from "../../../core/game/Game";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView } from "../../../core/game/GameView";
import { Controller } from "../../Controller";
import { translateText } from "../../Utils";
@customElement("heads-up-message")
export class HeadsUpMessage extends LitElement implements Controller {
public game: GameView;
@state()
private isVisible = false;
@state()
private isPaused = false;
@state()
private isImmunityActive = false;
@state()
private isCatchingUp = false;
private catchingUpTicks = 0;
private static readonly CATCHING_UP_SHOW_THRESHOLD = 10;
@state()
private toastMessage: string | import("lit").TemplateResult | null = null;
@state()
private toastColor: "green" | "red" = "green";
private toastTimeout: number | null = null;
createRenderRoot() {
return this;
}
connectedCallback() {
super.connectedCallback();
window.addEventListener(
"show-message",
this.handleShowMessage as EventListener,
);
}
disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener(
"show-message",
this.handleShowMessage as EventListener,
);
if (this.toastTimeout) {
clearTimeout(this.toastTimeout);
}
}
private handleShowMessage = (event: CustomEvent) => {
const { message, duration, color } = event.detail ?? {};
if (
typeof message === "string" ||
(message && typeof message.values === "object")
) {
this.toastMessage = message;
this.toastColor = color === "red" ? "red" : "green";
this.requestUpdate();
if (this.toastTimeout) {
clearTimeout(this.toastTimeout);
}
this.toastTimeout = window.setTimeout(
() => {
this.toastMessage = null;
this.requestUpdate();
},
typeof duration === "number" ? (duration ?? 2000) : 2000,
);
}
};
init() {
this.isVisible = true;
this.requestUpdate();
}
tick() {
const updates = this.game.updatesSinceLastTick();
if (updates && updates[GameUpdateType.GamePaused].length > 0) {
const pauseUpdate = updates[GameUpdateType.GamePaused][0];
this.isPaused = pauseUpdate.paused;
}
const showImmunityHudDuration = 10 * 10;
const spawnEnd = this.game.config().numSpawnPhaseTurns();
const ticksSinceSpawnEnd = this.game.ticks() - spawnEnd;
this.isImmunityActive =
this.game.config().hasExtendedSpawnImmunity() &&
!this.game.inSpawnPhase() &&
this.game.isSpawnImmunityActive() &&
ticksSinceSpawnEnd < showImmunityHudDuration;
const currentlyCatchingUp =
!this.game.config().isReplay() && this.game.isCatchingUp();
if (currentlyCatchingUp) {
this.catchingUpTicks++;
} else {
this.catchingUpTicks = 0;
}
this.isCatchingUp =
this.catchingUpTicks >= HeadsUpMessage.CATCHING_UP_SHOW_THRESHOLD;
this.isVisible =
this.game.inSpawnPhase() ||
this.isPaused ||
this.isImmunityActive ||
this.isCatchingUp;
this.requestUpdate();
}
private getMessage(): string {
if (this.isCatchingUp) {
return translateText("heads_up_message.catching_up");
}
if (this.isPaused) {
if (this.game.config().gameConfig().gameType === GameType.Singleplayer) {
return translateText("heads_up_message.singleplayer_game_paused");
} else {
return translateText("heads_up_message.multiplayer_game_paused");
}
}
if (this.isImmunityActive) {
return translateText("heads_up_message.pvp_immunity_active", {
seconds: Math.round(this.game.config().spawnImmunityDuration() / 10),
});
}
return this.game.config().isRandomSpawn()
? translateText("heads_up_message.random_spawn")
: translateText("heads_up_message.choose_spawn");
}
render() {
return html`
<div style="pointer-events: none;">
${this.toastMessage
? html`
<div
class="fixed top-6 left-1/2 -translate-x-1/2 z-[800] px-6 py-4 rounded-xl transition-all duration-300 animate-fade-in-out"
style="max-width: 90vw; min-width: 200px; text-align: center;
background: ${this.toastColor === "red"
? "rgba(239,68,68,0.1)"
: "rgba(34,197,94,0.1)"};
border: 1px solid ${this.toastColor === "red"
? "rgba(239,68,68,0.5)"
: "rgba(34,197,94,0.5)"};
color: white;
box-shadow: 0 0 30px 0 ${this.toastColor === "red"
? "rgba(239,68,68,0.3)"
: "rgba(34,197,94,0.3)"};
backdrop-filter: blur(12px);"
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
>
${typeof this.toastMessage === "string"
? html`<span class="font-medium">${this.toastMessage}</span>`
: this.toastMessage}
</div>
`
: null}
${this.isVisible
? html`
<div
class="fixed top-[15%] left-1/2 -translate-x-1/2 z-[799]
inline-flex items-center justify-center min-h-8 lg:min-h-10
w-fit max-w-[90vw]
bg-gray-800/70 rounded-md lg:rounded-lg
backdrop-blur-xs text-white text-md lg:text-xl px-3 lg:px-4 py-1
text-center break-words"
style="word-wrap: break-word; hyphens: auto;"
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
>
${this.getMessage()}
</div>
`
: null}
</div>
`;
}
}
+107
View File
@@ -0,0 +1,107 @@
import { LitElement, html } from "lit";
import { customElement } from "lit/decorators.js";
import { EventBus, GameEvent } from "../../../core/EventBus";
import { GameMode } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import { Controller } from "../../Controller";
export class ImmunityBarVisibleEvent implements GameEvent {
constructor(public readonly visible: boolean) {}
}
@customElement("immunity-timer")
export class ImmunityTimer extends LitElement implements Controller {
public game: GameView;
public eventBus: EventBus;
private isVisible = false;
private _barVisible = false;
private isActive = false;
private progressRatio = 0;
createRenderRoot() {
this.style.position = "fixed";
this.style.top = "0";
this.style.left = "0";
this.style.width = "100%";
this.style.height = "7px";
this.style.zIndex = "1000";
this.style.pointerEvents = "none";
return this;
}
init() {
this.isVisible = true;
}
tick() {
if (!this.game || !this.isVisible) {
return;
}
const showTeamOwnershipBar =
this.game.config().gameConfig().gameMode === GameMode.Team &&
!this.game.inSpawnPhase();
this.style.top = showTeamOwnershipBar ? "7px" : "0px";
const immunityDuration = this.game.config().spawnImmunityDuration();
const spawnPhaseTurns = this.game.config().numSpawnPhaseTurns();
if (
!this.game.config().hasExtendedSpawnImmunity() ||
this.game.inSpawnPhase()
) {
this.setInactive();
} else {
const immunityEnd = spawnPhaseTurns + immunityDuration;
const ticks = this.game.ticks();
if (ticks >= immunityEnd || ticks < spawnPhaseTurns) {
this.setInactive();
} else {
const elapsedTicks = Math.max(0, ticks - spawnPhaseTurns);
this.progressRatio = Math.min(
1,
Math.max(0, elapsedTicks / immunityDuration),
);
this.isActive = true;
this.requestUpdate();
}
}
this.emitBarVisibility();
}
private setInactive() {
if (this.isActive) {
this.isActive = false;
this.requestUpdate();
}
}
private emitBarVisibility() {
const nowVisible = this.isVisible && this.isActive;
if (nowVisible !== this._barVisible) {
this._barVisible = nowVisible;
this.eventBus?.emit(new ImmunityBarVisibleEvent(this._barVisible));
}
}
render() {
if (!this.isVisible || !this.isActive) {
return html``;
}
const widthPercent = this.progressRatio * 100;
return html`
<div class="w-full h-full flex z-999">
<div
class="h-full transition-all duration-100 ease-in-out"
style="width: ${widthPercent}%; background-color: rgba(255, 165, 0, 0.9);"
></div>
</div>
`;
}
}
+193
View File
@@ -0,0 +1,193 @@
import { LitElement, html } from "lit";
import { customElement } from "lit/decorators.js";
import { GameView } from "../../../core/game/GameView";
import { Controller } from "../../Controller";
import { crazyGamesSDK } from "../../CrazyGamesSDK";
const AD_TYPES = [
{ type: "standard_iab_left1", selectorId: "in-game-bottom-left-ad" },
{ type: "standard_iab_left3", selectorId: "in-game-bottom-left-ad3" },
{ type: "standard_iab_left4", selectorId: "in-game-bottom-left-ad4" },
];
@customElement("in-game-promo")
export class InGamePromo extends LitElement implements Controller {
public game: GameView;
private shouldShow: boolean = false;
private adsVisible: boolean = false;
private bottomRailDestroyed: boolean = false;
private cornerAdShown: boolean = false;
private adCheckInterval: ReturnType<typeof setTimeout> | null = null;
createRenderRoot() {
return this;
}
init() {}
tick() {
if (!this.game.inSpawnPhase()) {
if (!this.bottomRailDestroyed) {
this.bottomRailDestroyed = true;
this.destroyBottomRail();
}
if (!this.cornerAdShown) {
this.cornerAdShown = true;
console.log("[InGamePromo] Spawn phase ended, triggering showAd");
this.showAd();
}
}
}
private destroyBottomRail(): void {
if (!window.ramp) return;
try {
window.ramp.destroyUnits("pw-oop-bottom_rail");
console.log("Bottom rail ad destroyed after spawn phase");
} catch (e) {
console.error("Error destroying bottom_rail ad:", e);
}
}
private showAd(): void {
console.log(
`[InGamePromo] showAd called, isOnCrazyGames=${crazyGamesSDK.isOnCrazyGames()}`,
);
if (window.innerWidth < 1100) return;
if (window.innerHeight < 750) return;
if (crazyGamesSDK.isOnCrazyGames()) {
this.showCrazyGamesAd();
return;
}
if (!window.adsEnabled) return;
this.shouldShow = true;
this.requestUpdate();
this.updateComplete.then(() => {
this.loadAd();
this.checkForAds();
});
}
private showCrazyGamesAd(): void {
console.log(
`[InGamePromo] showCrazyGamesAd called, isReady=${crazyGamesSDK.isReady()}, width=${window.innerWidth}, height=${window.innerHeight}`,
);
if (!crazyGamesSDK.isReady()) {
console.log(
"[InGamePromo] CrazyGames SDK not ready, skipping in-game ad",
);
return;
}
this.requestUpdate();
this.updateComplete.then(() => {
console.log("[InGamePromo] DOM updated, calling createBottomLeftAd");
crazyGamesSDK.createBottomLeftAd();
});
}
private checkForAds(): void {
if (this.adCheckInterval) {
clearInterval(this.adCheckInterval);
}
this.adCheckInterval = setInterval(() => {
const hasAds = AD_TYPES.some(({ selectorId }) => {
const el = document.getElementById(selectorId);
return el && el.clientHeight > 50;
});
if (hasAds) {
this.adsVisible = true;
this.requestUpdate();
if (this.adCheckInterval) {
clearInterval(this.adCheckInterval);
this.adCheckInterval = null;
}
}
}, 1000);
}
private loadAd(): void {
if (!window.ramp) {
console.warn("Playwire RAMP not available for in-game ad");
return;
}
try {
window.ramp.que.push(() => {
try {
window.ramp.spaAddAds(
AD_TYPES.map(({ type, selectorId }) => ({ type, selectorId })),
);
console.log(
"In-game bottom-left ads loaded:",
AD_TYPES.map((a) => a.type),
);
} catch (e) {
console.error("Failed to add in-game ads:", e);
}
});
} catch (error) {
console.error("Failed to load in-game ads:", error);
}
}
public hideAd(): void {
if (this.adCheckInterval) {
clearInterval(this.adCheckInterval);
this.adCheckInterval = null;
}
this.adsVisible = false;
this.destroyBottomRail();
if (crazyGamesSDK.isOnCrazyGames()) {
crazyGamesSDK.clearBottomLeftAd();
this.shouldShow = false;
this.requestUpdate();
return;
}
if (!window.ramp) {
console.warn("Playwire RAMP not available for in-game ad");
return;
}
this.shouldShow = false;
try {
for (const { type } of AD_TYPES) {
window.ramp.destroyUnits(type);
}
console.log("successfully destroyed in-game bottom-left ads");
} catch (e) {
console.error("error destroying in-game ads:", e);
}
this.requestUpdate();
}
render() {
if (!this.shouldShow) {
return html``;
}
return html`
<div
id="in-game-promo-container"
class="fixed left-0 z-[100] pointer-events-auto flex flex-col-reverse ${this
.adsVisible
? "bg-gray-800 rounded-tr-lg p-1"
: ""}"
style="bottom: -0.7cm"
>
${AD_TYPES.map(
({ selectorId }) =>
html`<div id="${selectorId}" style="margin:0;padding:0"></div>`,
)}
</div>
`;
}
}
+289
View File
@@ -0,0 +1,289 @@
import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { repeat } from "lit/directives/repeat.js";
import { renderTroops, translateText } from "../../../client/Utils";
import { EventBus } from "../../../core/EventBus";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { Controller } from "../../Controller";
import { GoToPlayerEvent } from "../../TransformHandler";
import { formatPercentage, renderNumber } from "../../Utils";
interface Entry {
name: string;
position: number;
score: string;
gold: string;
maxTroops: string;
isMyPlayer: boolean;
isOnSameTeam: boolean;
player: PlayerView;
}
@customElement("leader-board")
export class Leaderboard extends LitElement implements Controller {
public game: GameView | null = null;
public eventBus: EventBus | null = null;
players: Entry[] = [];
@property({ type: Boolean }) visible = false;
private showTopFive = true;
@state()
private _sortKey: "tiles" | "gold" | "maxtroops" = "tiles";
@state()
private _sortOrder: "asc" | "desc" = "desc";
createRenderRoot() {
return this; // use light DOM for Tailwind support
}
init() {}
willUpdate(changed: Map<string, unknown>) {
if (changed.has("visible") && this.visible) {
this.updateLeaderboard();
}
}
getTickIntervalMs() {
return 1000;
}
tick() {
if (this.game === null) throw new Error("Not initialized");
if (!this.visible) return;
this.updateLeaderboard();
}
private setSort(key: "tiles" | "gold" | "maxtroops") {
if (this._sortKey === key) {
this._sortOrder = this._sortOrder === "asc" ? "desc" : "asc";
} else {
this._sortKey = key;
this._sortOrder = "desc";
}
this.updateLeaderboard();
}
private updateLeaderboard() {
if (this.game === null) throw new Error("Not initialized");
const myPlayer = this.game.myPlayer();
let sorted = this.game.playerViews();
const compare = (a: number, b: number) =>
this._sortOrder === "asc" ? a - b : b - a;
const maxTroops = (p: PlayerView) => this.game!.config().maxTroops(p);
switch (this._sortKey) {
case "gold":
sorted = sorted.sort((a, b) =>
compare(Number(a.gold()), Number(b.gold())),
);
break;
case "maxtroops":
sorted = sorted.sort((a, b) => compare(maxTroops(a), maxTroops(b)));
break;
default:
sorted = sorted.sort((a, b) =>
compare(a.numTilesOwned(), b.numTilesOwned()),
);
}
const numTilesWithoutFallout =
this.game.numLandTiles() - this.game.numTilesWithFallout();
const alivePlayers = sorted.filter((player) => player.isAlive());
const playersToShow = this.showTopFive
? alivePlayers.slice(0, 5)
: alivePlayers;
this.players = playersToShow.map((player, index) => {
const maxTroops = this.game!.config().maxTroops(player);
return {
name: player.displayName(),
position: index + 1,
score: formatPercentage(
player.numTilesOwned() / numTilesWithoutFallout,
),
gold: renderNumber(player.gold()),
maxTroops: renderTroops(maxTroops),
isMyPlayer: player === myPlayer,
isOnSameTeam:
myPlayer !== null &&
(player === myPlayer || player.isOnSameTeam(myPlayer)),
player: player,
};
});
if (
myPlayer !== null &&
this.players.find((p) => p.isMyPlayer) === undefined
) {
let place = 0;
for (const p of sorted) {
place++;
if (p === myPlayer) {
break;
}
}
if (myPlayer.isAlive()) {
const myPlayerMaxTroops = this.game!.config().maxTroops(myPlayer);
this.players.pop();
this.players.push({
name: myPlayer.displayName(),
position: place,
score: formatPercentage(
myPlayer.numTilesOwned() / this.game.numLandTiles(),
),
gold: renderNumber(myPlayer.gold()),
maxTroops: renderTroops(myPlayerMaxTroops),
isMyPlayer: true,
isOnSameTeam: true,
player: myPlayer,
});
}
}
this.requestUpdate();
}
private handleRowClickPlayer(player: PlayerView) {
if (this.eventBus === null) return;
this.eventBus.emit(new GoToPlayerEvent(player));
}
render() {
if (!this.visible) {
return html``;
}
return html`
<div
class="max-h-[35vh] overflow-y-auto text-white text-xs md:text-xs lg:text-sm md:max-h-[50vh] mt-2 ${this
.visible
? ""
: "hidden"}"
@contextmenu=${(e: Event) => e.preventDefault()}
>
<div
class="grid bg-gray-800/85 w-full text-xs md:text-xs lg:text-sm rounded-lg overflow-hidden"
style="grid-template-columns: minmax(24px, 30px) minmax(60px, 100px) minmax(45px, 70px) minmax(40px, 55px) minmax(55px, 105px);"
>
<div class="contents font-bold bg-gray-700/60">
<div class="py-1 md:py-2 text-center border-b border-slate-500">
#
</div>
<div
class="py-1 md:py-2 text-center border-b border-slate-500 truncate"
>
${translateText("leaderboard.player")}
</div>
<div
class="py-1 md:py-2 text-center border-b border-slate-500 cursor-pointer whitespace-nowrap truncate"
@click=${() => this.setSort("tiles")}
>
${translateText("leaderboard.owned")}
${this._sortKey === "tiles"
? this._sortOrder === "asc"
? "⬆️"
: "⬇️"
: ""}
</div>
<div
class="py-1 md:py-2 text-center border-b border-slate-500 cursor-pointer whitespace-nowrap truncate"
@click=${() => this.setSort("gold")}
>
${translateText("leaderboard.gold")}
${this._sortKey === "gold"
? this._sortOrder === "asc"
? "⬆️"
: "⬇️"
: ""}
</div>
<div
class="py-1 md:py-2 text-center border-b border-slate-500 cursor-pointer whitespace-nowrap truncate"
@click=${() => this.setSort("maxtroops")}
>
${translateText("leaderboard.maxtroops")}
${this._sortKey === "maxtroops"
? this._sortOrder === "asc"
? "⬆️"
: "⬇️"
: ""}
</div>
</div>
${repeat(
this.players,
(p) => p.player.id(),
(player, index) => html`
<div
class="contents hover:bg-slate-600/60 ${player.isOnSameTeam
? "font-bold"
: ""} cursor-pointer"
@click=${() => this.handleRowClickPlayer(player.player)}
>
<div
class="py-1 md:py-2 text-center ${index <
this.players.length - 1
? "border-b border-slate-500"
: ""}"
>
${player.position}
</div>
<div
class="py-1 md:py-2 text-center ${index <
this.players.length - 1
? "border-b border-slate-500"
: ""} truncate"
>
${player.name}
</div>
<div
class="py-1 md:py-2 text-center ${index <
this.players.length - 1
? "border-b border-slate-500"
: ""}"
>
${player.score}
</div>
<div
class="py-1 md:py-2 text-center ${index <
this.players.length - 1
? "border-b border-slate-500"
: ""}"
>
${player.gold}
</div>
<div
class="py-1 md:py-2 text-center ${index <
this.players.length - 1
? "border-b border-slate-500"
: ""}"
>
${player.maxTroops}
</div>
</div>
`,
)}
</div>
</div>
<button
class="mt-2 p-0.5 px-1.5 md:px-2 text-xs md:text-xs lg:text-sm
border rounded-md border-slate-500 transition-colors
text-white mx-auto block hover:bg-white/10 bg-gray-700/50"
@click=${() => {
this.showTopFive = !this.showTopFive;
this.updateLeaderboard();
}}
>
${this.showTopFive ? "+" : "-"}
</button>
`;
}
}
+193
View File
@@ -0,0 +1,193 @@
import { LitElement } from "lit";
import { customElement } from "lit/decorators.js";
import { assetUrl } from "../../../core/AssetUrls";
import { EventBus } from "../../../core/EventBus";
import { PlayerActions } from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { Controller } from "../../Controller";
import { TransformHandler } from "../../TransformHandler";
import { UIState } from "../../UIState";
import { BuildMenu } from "./BuildMenu";
import { ChatIntegration } from "./ChatIntegration";
import { EmojiTable } from "./EmojiTable";
import { PlayerActionHandler } from "./PlayerActionHandler";
import { PlayerPanel } from "./PlayerPanel";
import { RadialMenu, RadialMenuConfig } from "./RadialMenu";
import {
centerButtonElement,
COLORS,
MenuElementParams,
rootMenuElement,
} from "./RadialMenuElements";
const donateTroopIcon = assetUrl("images/DonateTroopIconWhite.svg");
const swordIcon = assetUrl("images/SwordIconWhite.svg");
import { ContextMenuEvent } from "../../InputHandler";
@customElement("main-radial-menu")
export class MainRadialMenu extends LitElement implements Controller {
private radialMenu: RadialMenu;
private playerActionHandler: PlayerActionHandler;
private chatIntegration: ChatIntegration;
private clickedTile: TileRef | null = null;
getTickIntervalMs() {
return 500;
}
constructor(
private eventBus: EventBus,
private game: GameView,
private transformHandler: TransformHandler,
private emojiTable: EmojiTable,
private buildMenu: BuildMenu,
private uiState: UIState,
private playerPanel: PlayerPanel,
) {
super();
const menuConfig: RadialMenuConfig = {
centerButtonIcon: swordIcon,
tooltipStyle: `
.radial-tooltip .cost {
margin-top: 4px;
color: ${COLORS.tooltip.cost};
}
.radial-tooltip .count {
color: ${COLORS.tooltip.count};
}
`,
};
this.radialMenu = new RadialMenu(
this.eventBus,
rootMenuElement,
centerButtonElement,
menuConfig,
);
this.playerActionHandler = new PlayerActionHandler(
this.eventBus,
this.uiState,
);
this.chatIntegration = new ChatIntegration(this.game, this.eventBus);
}
init() {
this.radialMenu.init();
this.eventBus.on(ContextMenuEvent, (event) => {
const worldCoords = this.transformHandler.screenToWorldCoordinates(
event.x,
event.y,
);
if (!this.game.isValidCoord(worldCoords.x, worldCoords.y)) {
return;
}
if (this.game.myPlayer() === null) {
return;
}
this.clickedTile = this.game.ref(worldCoords.x, worldCoords.y);
this.game
.myPlayer()!
.actions(this.clickedTile)
.then((actions) => {
this.updatePlayerActions(
this.game.myPlayer()!,
actions,
this.clickedTile!,
event.x,
event.y,
);
});
});
}
private async updatePlayerActions(
myPlayer: PlayerView,
actions: PlayerActions,
tile: TileRef,
screenX: number | null = null,
screenY: number | null = null,
) {
this.buildMenu.playerBuildables = actions.buildableUnits;
const tileOwner = this.game.owner(tile);
const recipient = tileOwner.isPlayer() ? (tileOwner as PlayerView) : null;
if (myPlayer && recipient) {
this.chatIntegration.setupChatModal(myPlayer, recipient);
}
const params: MenuElementParams = {
myPlayer,
selected: recipient,
tile,
playerActions: actions,
game: this.game,
buildMenu: this.buildMenu,
emojiTable: this.emojiTable,
playerActionHandler: this.playerActionHandler,
playerPanel: this.playerPanel,
chatIntegration: this.chatIntegration,
uiState: this.uiState,
closeMenu: () => this.closeMenu(),
eventBus: this.eventBus,
};
const isFriendlyTarget =
recipient !== null &&
recipient.isFriendly(myPlayer) &&
!recipient.isDisconnected();
this.radialMenu.setCenterButtonAppearance(
isFriendlyTarget ? donateTroopIcon : swordIcon,
isFriendlyTarget ? "#22d3ee" : "#0f2744",
isFriendlyTarget
? this.radialMenu.getDefaultCenterIconSize() * 0.75
: this.radialMenu.getDefaultCenterIconSize(),
);
this.radialMenu.setParams(params);
if (screenX !== null && screenY !== null) {
this.radialMenu.showRadialMenu(screenX, screenY);
} else {
this.radialMenu.refresh();
}
}
async tick() {
if (!this.radialMenu.isMenuVisible() || this.clickedTile === null) return;
this.game
.myPlayer()!
.actions(this.clickedTile)
.then((actions) => {
this.updatePlayerActions(
this.game.myPlayer()!,
actions,
this.clickedTile!,
);
});
}
closeMenu() {
if (this.radialMenu.isMenuVisible()) {
this.radialMenu.hideRadialMenu();
}
if (this.buildMenu.isVisible) {
this.buildMenu.hideMenu();
}
if (this.emojiTable.isVisible) {
this.emojiTable.hideTable();
}
if (this.playerPanel.isVisible) {
this.playerPanel.hide();
}
}
}
+193
View File
@@ -0,0 +1,193 @@
import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { ClientEnv } from "src/client/ClientEnv";
import { GameEnv } from "../../../core/configuration/Config";
import { GameType } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import { Controller } from "../../Controller";
import { MultiTabDetector } from "../../MultiTabDetector";
import { translateText } from "../../Utils";
@customElement("multi-tab-modal")
export class MultiTabModal extends LitElement implements Controller {
public game: GameView;
private detector: MultiTabDetector;
@property({ type: Number }) duration: number = 5000;
@state() private countdown: number = 5;
@state() private isVisible: boolean = false;
@state() private fakeIp: string = "";
@state() private deviceFingerprint: string = "";
@state() private reported: boolean = true;
private intervalId?: number;
// Disable shadow DOM to allow Tailwind classes to work
createRenderRoot() {
return this;
}
tick() {
if (
this.game.inSpawnPhase() ||
this.game.config().gameConfig().gameType === GameType.Singleplayer ||
ClientEnv.env() === GameEnv.Dev ||
this.game.config().isReplay()
) {
return;
}
if (!this.detector) {
this.detector = new MultiTabDetector();
this.detector.startMonitoring((duration: number) => {
this.show(duration);
});
}
}
init() {
this.fakeIp = this.generateFakeIp();
this.deviceFingerprint = this.generateDeviceFingerprint();
this.reported = true;
}
// Generate fake IP in format xxx.xxx.xxx.xxx
private generateFakeIp(): string {
return Array.from({ length: 4 }, () =>
Math.floor(Math.random() * 255),
).join(".");
}
// Generate fake device fingerprint (32 character hex)
private generateDeviceFingerprint(): string {
return Array.from({ length: 32 }, () =>
Math.floor(Math.random() * 16).toString(16),
).join("");
}
// Show the modal with penalty information
public show(duration: number): void {
if (!this.game.myPlayer()?.isAlive()) {
return;
}
this.duration = duration;
this.countdown = Math.ceil(duration / 1000);
this.isVisible = true;
// Start countdown timer
this.intervalId = window.setInterval(() => {
this.countdown--;
if (this.countdown <= 0) {
this.hide();
}
}, 1000);
this.requestUpdate();
}
// Hide the modal
public hide(): void {
this.isVisible = false;
if (this.intervalId) {
window.clearInterval(this.intervalId);
this.intervalId = undefined;
}
// Dispatch event when modal is closed
this.dispatchEvent(
new CustomEvent("penalty-complete", {
bubbles: true,
composed: true,
}),
);
this.requestUpdate();
}
disconnectedCallback() {
super.disconnectedCallback();
if (this.intervalId) {
window.clearInterval(this.intervalId);
}
}
render() {
if (!this.isVisible) {
return html``;
}
return html`
<div
class="fixed inset-0 z-50 overflow-auto bg-red-500/20 flex items-center justify-center"
>
<div
class="relative p-6 bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-md w-full m-4 transition-all transform"
>
<div class="flex items-center justify-between mb-4">
<h2 class="text-2xl font-bold text-red-600 dark:text-red-400">
${translateText("multi_tab.warning")}
</h2>
<div
class="px-2 py-1 bg-red-600 text-white text-xs font-bold rounded-full animate-pulse"
>
RECORDING
</div>
</div>
<p class="mb-4 text-gray-800 dark:text-gray-200">
${translateText("multi_tab.detected")}
</p>
<div
class="mb-4 p-3 bg-gray-100 dark:bg-gray-900 rounded-md text-sm font-mono"
>
<div class="flex justify-between mb-1">
<span class="text-gray-500 dark:text-gray-400">IP:</span>
<span class="text-red-600 dark:text-red-400">${this.fakeIp}</span>
</div>
<div class="flex justify-between mb-1">
<span class="text-gray-500 dark:text-gray-400"
>Device Fingerprint:</span
>
<span class="text-red-600 dark:text-red-400"
>${this.deviceFingerprint}</span
>
</div>
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">Reported:</span>
<span class="text-red-600 dark:text-red-400"
>${this.reported ? "TRUE" : "FALSE"}</span
>
</div>
</div>
<p class="mb-4 text-gray-800 dark:text-gray-200">
${translateText("multi_tab.please_wait")}
<span class="font-bold text-xl">${this.countdown}</span>
${translateText("multi_tab.seconds")}
</p>
<div
class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2.5 mb-4"
>
<div
class="bg-red-600 dark:bg-red-500 h-2.5 rounded-full transition-all duration-1000 ease-linear w-(--width)"
style="--width: ${(this.countdown / (this.duration / 1000)) *
100}%"
></div>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400">
${translateText("multi_tab.explanation")}
</p>
<p class="mt-3 text-xs text-red-500 font-semibold">
Repeated violations may result in permanent account suspension.
</p>
</div>
</div>
`;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,96 @@
import { EventBus } from "../../../core/EventBus";
import { TileRef } from "../../../core/game/GameMap";
import { PlayerView } from "../../../core/game/GameView";
import {
SendAllianceExtensionIntentEvent,
SendAllianceRequestIntentEvent,
SendAttackIntentEvent,
SendBoatAttackIntentEvent,
SendBreakAllianceIntentEvent,
SendDeleteUnitIntentEvent,
SendDonateGoldIntentEvent,
SendDonateTroopsIntentEvent,
SendEmbargoIntentEvent,
SendEmojiIntentEvent,
SendSpawnIntentEvent,
SendTargetPlayerIntentEvent,
} from "../../Transport";
import { UIState } from "../../UIState";
export class PlayerActionHandler {
constructor(
private eventBus: EventBus,
private uiState: UIState,
) {}
handleAttack(player: PlayerView, targetId: string | null) {
this.eventBus.emit(
new SendAttackIntentEvent(
targetId,
this.uiState.attackRatio * player.troops(),
),
);
}
handleBoatAttack(player: PlayerView, targetTile: TileRef) {
this.eventBus.emit(
new SendBoatAttackIntentEvent(
targetTile,
this.uiState.attackRatio * player.troops(),
),
);
}
async findBestTransportShipSpawn(
player: PlayerView,
tile: TileRef,
): Promise<TileRef | false> {
return await player.bestTransportShipSpawn(tile);
}
handleSpawn(tile: TileRef) {
this.eventBus.emit(new SendSpawnIntentEvent(tile));
}
handleAllianceRequest(player: PlayerView, recipient: PlayerView) {
this.eventBus.emit(new SendAllianceRequestIntentEvent(player, recipient));
}
handleExtendAlliance(recipient: PlayerView) {
this.eventBus.emit(new SendAllianceExtensionIntentEvent(recipient));
}
handleBreakAlliance(player: PlayerView, recipient: PlayerView) {
this.eventBus.emit(new SendBreakAllianceIntentEvent(player, recipient));
}
handleTargetPlayer(targetId: string | null) {
if (!targetId) return;
this.eventBus.emit(new SendTargetPlayerIntentEvent(targetId));
}
handleDonateGold(recipient: PlayerView) {
this.eventBus.emit(new SendDonateGoldIntentEvent(recipient, null));
}
handleDonateTroops(recipient: PlayerView, troops?: number) {
const amount = troops ?? null;
if (amount !== null && amount <= 0) {
return;
}
this.eventBus.emit(new SendDonateTroopsIntentEvent(recipient, amount));
}
handleEmbargo(recipient: PlayerView, action: "start" | "stop") {
this.eventBus.emit(new SendEmbargoIntentEvent(recipient, action));
}
handleEmoji(targetPlayer: PlayerView | "AllPlayers", emojiIndex: number) {
this.eventBus.emit(new SendEmojiIntentEvent(targetPlayer, emojiIndex));
}
handleDeleteUnit(unitId: number) {
this.eventBus.emit(new SendDeleteUnitIntentEvent(unitId));
}
}
+511
View File
@@ -0,0 +1,511 @@
import { html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { assetUrl } from "../../../core/AssetUrls";
import { EventBus } from "../../../core/EventBus";
import {
PlayerProfile,
PlayerType,
Relation,
Unit,
UnitType,
} from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import { AllianceView } from "../../../core/game/GameUpdates";
import { GameView, PlayerView, UnitView } from "../../../core/game/GameView";
import { Controller } from "../../Controller";
import {
ContextMenuEvent,
MouseMoveEvent,
TouchEvent,
} from "../../InputHandler";
import { TransformHandler } from "../../TransformHandler";
import {
getTranslatedPlayerTeamLabel,
renderDuration,
renderNumber,
renderTroops,
translateText,
} from "../../Utils";
import {
EMOJI_ICON_KIND,
getFirstPlacePlayer,
getPlayerIcons,
IMAGE_ICON_KIND,
} from "../PlayerIcons";
import { ImmunityBarVisibleEvent } from "./ImmunityTimer";
import { CloseRadialMenuEvent } from "./RadialMenu";
import "./RelationSmiley";
import { SpawnBarVisibleEvent } from "./SpawnTimer";
const soldierIconAquarius = assetUrl("images/SoldierIconAquarius.svg");
const allianceIcon = assetUrl("images/AllianceIcon.svg");
const warshipIcon = assetUrl("images/BattleshipIconWhite.svg");
const cityIcon = assetUrl("images/CityIconWhite.svg");
const factoryIcon = assetUrl("images/FactoryIconWhite.svg");
const goldCoinIcon = assetUrl("images/GoldCoinIcon.svg");
const missileSiloIcon = assetUrl("images/MissileSiloIconWhite.svg");
const portIcon = assetUrl("images/PortIcon.svg");
const samLauncherIcon = assetUrl("images/SamLauncherIconWhite.svg");
const soldierIcon = assetUrl("images/SoldierIcon.svg");
function euclideanDistWorld(
coord: { x: number; y: number },
tileRef: TileRef,
game: GameView,
): number {
const x = game.x(tileRef);
const y = game.y(tileRef);
const dx = coord.x - x;
const dy = coord.y - y;
return Math.sqrt(dx * dx + dy * dy);
}
function distSortUnitWorld(coord: { x: number; y: number }, game: GameView) {
return (a: Unit | UnitView, b: Unit | UnitView) => {
const distA = euclideanDistWorld(coord, a.tile(), game);
const distB = euclideanDistWorld(coord, b.tile(), game);
return distA - distB;
};
}
@customElement("player-info-overlay")
export class PlayerInfoOverlay extends LitElement implements Controller {
@property({ type: Object })
public game!: GameView;
@property({ type: Object })
public eventBus!: EventBus;
@property({ type: Object })
public transform!: TransformHandler;
@state()
private player: PlayerView | null = null;
@state()
private playerProfile: PlayerProfile | null = null;
@state()
private unit: UnitView | null = null;
@state()
private _isInfoVisible: boolean = false;
@state()
private spawnBarVisible = false;
@state()
private immunityBarVisible = false;
private _isActive = false;
private get barOffset(): number {
return (this.spawnBarVisible ? 7 : 0) + (this.immunityBarVisible ? 7 : 0);
}
private lastMouseUpdate = 0;
init() {
this.eventBus.on(MouseMoveEvent, (e: MouseMoveEvent) =>
this.onMouseEvent(e),
);
this.eventBus.on(ContextMenuEvent, (e: ContextMenuEvent) =>
this.maybeShow(e.x, e.y),
);
this.eventBus.on(TouchEvent, (e: TouchEvent) => this.maybeShow(e.x, e.y));
this.eventBus.on(CloseRadialMenuEvent, () => this.hide());
this.eventBus.on(SpawnBarVisibleEvent, (e) => {
this.spawnBarVisible = e.visible;
});
this.eventBus.on(ImmunityBarVisibleEvent, (e) => {
this.immunityBarVisible = e.visible;
});
this._isActive = true;
}
private onMouseEvent(event: MouseMoveEvent) {
const now = Date.now();
if (now - this.lastMouseUpdate < 100) {
return;
}
this.lastMouseUpdate = now;
this.maybeShow(event.x, event.y);
}
public hide() {
this.setVisible(false);
this.unit = null;
this.player = null;
}
public maybeShow(x: number, y: number) {
this.hide();
const worldCoord = this.transform.screenToWorldCoordinates(x, y);
if (!this.game.isValidCoord(worldCoord.x, worldCoord.y)) {
return;
}
const tile = this.game.ref(worldCoord.x, worldCoord.y);
if (!tile) return;
const owner = this.game.owner(tile);
if (owner && owner.isPlayer()) {
this.player = owner as PlayerView;
this.player.profile().then((p) => {
this.playerProfile = p;
});
this.setVisible(true);
} else if (!this.game.isLand(tile)) {
const units = this.game
.units(UnitType.Warship, UnitType.TradeShip, UnitType.TransportShip)
.filter((u) => euclideanDistWorld(worldCoord, u.tile(), this.game) < 50)
.sort(distSortUnitWorld(worldCoord, this.game));
if (units.length > 0) {
this.unit = units[0];
this.setVisible(true);
}
}
}
tick() {
this.requestUpdate();
}
setVisible(visible: boolean) {
this._isInfoVisible = visible;
this.requestUpdate();
}
private getPlayerNameColor(isFriendly: boolean): string {
if (isFriendly) return "text-green-500";
return "text-white";
}
private getRelationSmiley(
player: PlayerView,
myPlayer: PlayerView | null | undefined,
): TemplateResult | string {
if (!myPlayer || myPlayer === player || player.type() !== PlayerType.Nation)
return "";
const relation =
this.playerProfile?.relations[myPlayer.smallID()] ?? Relation.Neutral;
if (relation === Relation.Neutral) return "";
return html`<relation-smiley .relation=${relation}></relation-smiley>`;
}
private getRelationName(relation: Relation): string {
switch (relation) {
case Relation.Hostile:
return translateText("relation.hostile");
case Relation.Distrustful:
return translateText("relation.distrustful");
case Relation.Neutral:
return translateText("relation.neutral");
case Relation.Friendly:
return translateText("relation.friendly");
default:
return translateText("relation.default");
}
}
private displayUnitCount(player: PlayerView, type: UnitType, icon: string) {
return !this.game.config().isUnitDisabled(type)
? html`<div
class="flex items-center justify-center gap-0.5 lg:gap-1 p-0.5 lg:p-1 border rounded-md border-gray-500 text-[10px] lg:text-xs w-9 lg:w-12 h-6 lg:h-7"
translate="no"
>
<img
src=${icon}
class="w-3 h-3 lg:w-4 lg:h-4 object-contain shrink-0"
/>
<span>${player.totalUnitLevels(type)}</span>
</div>`
: "";
}
private allianceExpirationText(alliance: AllianceView) {
const { expiresAt } = alliance;
const remainingTicks = expiresAt - this.game.ticks();
let remainingSeconds = 0;
if (remainingTicks > 0) {
remainingSeconds = Math.max(0, Math.floor(remainingTicks / 10)); // 10 ticks per second
}
return renderDuration(remainingSeconds);
}
private renderPlayerNameIcons(player: PlayerView) {
const firstPlace = getFirstPlacePlayer(this.game);
const icons = getPlayerIcons({
game: this.game,
player,
// Because we already show the alliance icon next to the alliance expiration timer, we don't need to show it a second time in this render
includeAllianceIcon: false,
firstPlace,
alliancesDisabled: this.game.config().disableAlliances(),
});
if (icons.length === 0) {
return html``;
}
return html`<span class="flex items-center gap-1 ml-1 shrink-0">
${icons.map((icon) =>
icon.kind === EMOJI_ICON_KIND && icon.text
? html`<span class="text-sm shrink-0" translate="no"
>${icon.text}</span
>`
: icon.kind === IMAGE_ICON_KIND && icon.src
? html`<img src=${icon.src} alt="" class="w-4 h-4 shrink-0" />`
: html``,
)}
</span>`;
}
private renderPlayerInfo(player: PlayerView) {
const myPlayer = this.game.myPlayer();
const isFriendly = myPlayer?.isFriendly(player);
const isAllied = myPlayer?.isAlliedWith(player);
let allianceHtml: TemplateResult | null = null;
const maxTroops = this.game.config().maxTroops(player);
const attackingTroops = player
.outgoingAttacks()
.map((a) => a.troops)
.reduce((a, b) => a + b, 0);
const totalTroops = player.troops();
if (isAllied) {
const alliance = myPlayer
?.alliances()
.find((alliance) => alliance.other === player.id());
if (alliance !== undefined) {
allianceHtml = html` <div
class="flex items-center ml-auto mr-0 gap-1 text-sm font-bold leading-tight"
>
<img src=${allianceIcon} width="20" height="20" />
${this.allianceExpirationText(alliance)}
</div>`;
}
}
let playerType = "";
switch (player.type()) {
case PlayerType.Bot:
playerType = translateText("player_type.bot");
break;
case PlayerType.Nation:
playerType = translateText("player_type.nation");
break;
case PlayerType.Human:
playerType = translateText("player_type.player");
break;
}
const playerTeam = getTranslatedPlayerTeamLabel(player.team());
return html`
<div class="flex items-start gap-1 lg:gap-2 p-1 lg:p-1.5">
<!-- Left: Gold & Troop bar -->
<div class="flex flex-col gap-1 shrink-0 w-28 md:w-36">
<div class="flex items-center gap-1">
<div
class="flex flex-1 items-center justify-center px-1 py-0.5 border rounded-md border-yellow-400 font-bold text-yellow-400 text-sm lg:gap-1"
translate="no"
>
<img src=${goldCoinIcon} width="13" height="13" />
<span class="px-0.5">${renderNumber(player.gold())}</span>
</div>
<div
class="flex flex-1 flex-col items-center justify-center text-xs font-bold ${attackingTroops >
0
? "text-aquarius"
: "text-white/40"} drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
translate="no"
>
<span class="flex items-center gap-px leading-none text-xs"
><img
class="w-2.5 h-2.5 inline-block ${attackingTroops > 0
? ""
: "brightness-0 invert opacity-40"}"
src=${attackingTroops > 0 ? soldierIconAquarius : soldierIcon}
alt=""
aria-hidden="true"
/>↑</span
>
<span class="tabular-nums leading-none text-sm mt-0.5"
>${renderTroops(attackingTroops)}</span
>
</div>
</div>
<div class="w-28 md:w-36" translate="no">
${this.renderTroopBar(totalTroops, attackingTroops, maxTroops)}
</div>
</div>
<!-- Right: Player identity + Units below -->
<div class="flex flex-col justify-between self-stretch">
<div
class="flex items-center gap-2 font-bold text-sm lg:text-lg ${this.getPlayerNameColor(
isFriendly ?? false,
)}"
>
${player.cosmetics.flag
? html`<img
class="h-6 object-contain"
src=${assetUrl(player.cosmetics.flag!)}
/>`
: html``}
<span>${player.displayName()}</span>
${this.getRelationSmiley(player, myPlayer)}
${playerTeam !== "" && player.type() !== PlayerType.Bot
? html`<div class="flex flex-col leading-tight">
<span class="text-gray-400 text-xs font-normal"
>${playerType}</span
>
<span class="text-xs font-normal text-gray-400"
>[<span
style="color: ${this.game
.config()
.theme()
.teamColor(player.team()!)
.toHex()}"
>${playerTeam}</span
>]</span
>
</div>`
: html`<span class="text-gray-400 text-xs font-normal"
>${playerType}</span
>`}
${this.renderPlayerNameIcons(player)} ${allianceHtml ?? ""}
</div>
<div class="flex gap-0.5 lg:gap-1 items-center mt-0.5">
${this.displayUnitCount(player, UnitType.City, cityIcon)}
${this.displayUnitCount(player, UnitType.Factory, factoryIcon)}
${this.displayUnitCount(player, UnitType.Port, portIcon)}
${this.displayUnitCount(
player,
UnitType.MissileSilo,
missileSiloIcon,
)}
${this.displayUnitCount(
player,
UnitType.SAMLauncher,
samLauncherIcon,
)}
${this.displayUnitCount(player, UnitType.Warship, warshipIcon)}
</div>
</div>
</div>
`;
}
private renderTroopBar(
totalTroops: number,
attackingTroops: number,
maxTroops: number,
) {
const base = Math.max(maxTroops, 1);
const greenPercentRaw = (totalTroops / base) * 100;
const orangePercentRaw = (attackingTroops / base) * 100;
const greenPercent = Math.max(0, Math.min(100, greenPercentRaw));
const orangePercent = Math.max(
0,
Math.min(100 - greenPercent, orangePercentRaw),
);
return html`
<div
class="w-full h-5 lg:h-6 border border-gray-600 rounded-md bg-gray-900/60 overflow-hidden relative"
>
<div class="h-full flex">
${greenPercent > 0
? html`<div
class="h-full bg-sky-700 transition-[width] duration-200"
style="width: ${greenPercent}%;"
></div>`
: ""}
${orangePercent > 0
? html`<div
class="h-full bg-malibu-blue transition-[width] duration-200"
style="width: ${orangePercent}%;"
></div>`
: ""}
</div>
<div
class="absolute inset-0 flex items-center justify-between px-1.5 text-sm font-bold leading-none pointer-events-none"
translate="no"
>
<span class="text-white drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
>${renderTroops(totalTroops)}</span
>
<span class="text-white drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
>${renderTroops(maxTroops)}</span
>
</div>
<img
src=${soldierIcon}
alt=""
aria-hidden="true"
width="14"
height="14"
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 brightness-0 invert drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)] pointer-events-none"
/>
</div>
`;
}
private renderUnitInfo(unit: UnitView) {
const isAlly =
(unit.owner() === this.game.myPlayer() ||
this.game.myPlayer()?.isFriendly(unit.owner())) ??
false;
return html`
<div class="p-2">
<div class="font-bold mb-1 ${isAlly ? "text-green-500" : "text-white"}">
${unit.owner().displayName()}
</div>
<div class="mt-1">
<div class="text-sm opacity-80">${unit.type()}</div>
${unit.hasHealth()
? html` <div class="text-sm">Health: ${unit.health()}</div> `
: ""}
${unit.type() === UnitType.TransportShip
? html`
<div class="text-sm">
Troops: ${renderTroops(unit.troops())}
</div>
`
: ""}
</div>
</div>
`;
}
render() {
if (!this._isActive) {
return html``;
}
const containerClasses = this._isInfoVisible
? "opacity-100 visible"
: "opacity-0 invisible pointer-events-none";
return html`
<div
class="fixed top-0 left-0 right-0 sm:left-1/2 sm:right-auto sm:-translate-x-1/2 z-[1001]"
style="margin-top: ${this.barOffset}px;"
@click=${() => this.hide()}
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
>
<div
class="bg-gray-800/92 backdrop-blur-sm shadow-xs min-[1200px]:rounded-lg sm:rounded-b-lg shadow-lg text-white text-lg lg:text-base w-full sm:w-[500px] overflow-hidden ${containerClasses}"
>
${this.player !== null ? this.renderPlayerInfo(this.player) : ""}
${this.unit !== null ? this.renderUnitInfo(this.unit) : ""}
</div>
</div>
`;
}
createRenderRoot() {
return this; // Disable shadow DOM to allow Tailwind styles
}
}
@@ -0,0 +1,169 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import { assetUrl } from "../../../core/AssetUrls";
import { EventBus } from "../../../core/EventBus";
import { PlayerType } from "../../../core/game/Game";
import { PlayerView } from "../../../core/game/GameView";
import { actionButton } from "../../components/ui/ActionButton";
import { SendKickPlayerIntentEvent } from "../../Transport";
import { translateText } from "../../Utils";
const kickIcon = assetUrl("images/ExitIconWhite.svg");
const shieldIcon = assetUrl("images/ShieldIconWhite.svg");
@customElement("player-moderation-modal")
export class PlayerModerationModal extends LitElement {
@property({ attribute: false }) eventBus: EventBus | null = null;
@property({ attribute: false }) myPlayer: PlayerView | null = null;
@property({ attribute: false }) target: PlayerView | null = null;
@property({ type: Boolean }) open: boolean = false;
@property({ type: Boolean }) alreadyKicked: boolean = false;
@property({ type: Boolean }) isAdmin: boolean = false;
createRenderRoot() {
return this;
}
updated(changed: Map<string, unknown>) {
if (changed.has("open") && this.open) {
queueMicrotask(() =>
(this.querySelector('[role="dialog"]') as HTMLElement | null)?.focus(),
);
}
}
private closeModal() {
this.dispatchEvent(new CustomEvent("close"));
}
private handleKeydown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
this.closeModal();
}
};
private canKick(my: PlayerView, other: PlayerView): boolean {
return (
(my.isLobbyCreator() || this.isAdmin) &&
other !== my &&
other.type() === PlayerType.Human &&
!!other.clientID()
);
}
private handleKickClick = (e: MouseEvent) => {
e.stopPropagation();
const my = this.myPlayer;
const other = this.target;
const eventBus = this.eventBus;
if (!my || !other) return;
if (!this.canKick(my, other) || this.alreadyKicked) return;
if (!eventBus) return;
const targetClientID = other.clientID();
if (!targetClientID || targetClientID.length === 0) return;
const confirmed = confirm(
translateText("player_panel.kick_confirm", { name: other.displayName() }),
);
if (!confirmed) return;
eventBus.emit(new SendKickPlayerIntentEvent(targetClientID));
this.dispatchEvent(
new CustomEvent("kicked", { detail: { playerId: String(other.id()) } }),
);
this.closeModal();
};
render() {
if (!this.open) return html``;
const my = this.myPlayer;
const other = this.target;
if (!my || !other) return html``;
const canKick = this.canKick(my, other);
const alreadyKicked = this.alreadyKicked;
const moderationTitle = translateText("player_panel.moderation");
const kickTitle = alreadyKicked
? translateText("player_panel.kicked")
: translateText("player_panel.kick");
return html`
<div class="absolute inset-0 z-1200 flex items-center justify-center p-4">
<div
class="absolute inset-0 bg-black/60 rounded-2xl"
@click=${() => this.closeModal()}
></div>
<div
role="dialog"
aria-modal="true"
aria-labelledby="moderation-title"
class="relative z-10 w-full max-w-120 focus:outline-hidden"
tabindex="0"
@keydown=${this.handleKeydown}
>
<div
class="rounded-2xl bg-zinc-900 p-5 shadow-2xl ring-1 ring-zinc-800 max-h-[90vh] text-zinc-200"
@click=${(e: MouseEvent) => e.stopPropagation()}
>
<div class="mb-3 flex items-center justify-between relative">
<div class="flex items-center gap-2">
<img
src=${shieldIcon}
alt=""
aria-hidden="true"
class="h-5 w-5"
/>
<h2
id="moderation-title"
class="text-lg font-semibold tracking-tight text-zinc-100"
>
${moderationTitle}
</h2>
</div>
<button
type="button"
@click=${() => this.closeModal()}
class="absolute -top-3 -right-3 flex h-7 w-7 items-center justify-center rounded-full bg-zinc-700 text-white shadow-sm hover:bg-red-500 transition-colors focus-visible:ring-2 focus-visible:ring-white/30 focus:outline-hidden"
aria-label=${translateText("common.close")}
title=${translateText("common.close")}
>
</button>
</div>
<div
class="mb-4 rounded-xl border border-white/10 bg-white/5 px-3 py-2"
>
<div
class="text-sm font-semibold text-zinc-100 truncate"
title=${other.displayName()}
>
${other.displayName()}
</div>
</div>
<div class="grid auto-cols-fr grid-flow-col gap-1">
${actionButton({
onClick: this.handleKickClick,
icon: kickIcon,
iconAlt: "Kick",
title: kickTitle,
label: kickTitle,
type: "red",
disabled: alreadyKicked || !canKick,
})}
</div>
</div>
</div>
</div>
`;
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+681
View File
@@ -0,0 +1,681 @@
import { assetUrl } from "../../../core/AssetUrls";
import { Config } from "../../../core/configuration/Config";
import {
AllPlayers,
BuildableAttacks,
PlayerActions,
PlayerBuildableUnitType,
Structures,
UnitType,
} from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { Emoji, findClosestBy, flattenedEmojiTable } from "../../../core/Util";
import { UIState } from "../../UIState";
import { renderNumber, translateText } from "../../Utils";
import { BuildItemDisplay, BuildMenu, flattenedBuildTable } from "./BuildMenu";
import { ChatIntegration } from "./ChatIntegration";
import { EmojiTable } from "./EmojiTable";
import { PlayerActionHandler } from "./PlayerActionHandler";
import { PlayerPanel } from "./PlayerPanel";
import { TooltipItem } from "./RadialMenu";
import { EventBus } from "../../../core/EventBus";
const allianceIcon = assetUrl("images/AllianceIconWhite.svg");
const boatIcon = assetUrl("images/BoatIconWhite.svg");
const buildIcon = assetUrl("images/BuildIconWhite.svg");
const chatIcon = assetUrl("images/ChatIconWhite.svg");
const donateGoldIcon = assetUrl("images/DonateGoldIconWhite.svg");
const donateTroopIcon = assetUrl("images/DonateTroopIconWhite.svg");
const emojiIcon = assetUrl("images/EmojiIconWhite.svg");
const infoIcon = assetUrl("images/InfoIcon.svg");
const swordIcon = assetUrl("images/SwordIconWhite.svg");
const targetIcon = assetUrl("images/TargetIconWhite.svg");
const traitorIcon = assetUrl("images/TraitorIconWhite.svg");
const xIcon = assetUrl("images/XIcon.svg");
export interface MenuElementParams {
myPlayer: PlayerView;
selected: PlayerView | null;
tile: TileRef;
playerActions: PlayerActions;
game: GameView;
buildMenu: BuildMenu;
emojiTable: EmojiTable;
playerActionHandler: PlayerActionHandler;
playerPanel: PlayerPanel;
chatIntegration: ChatIntegration;
eventBus: EventBus;
uiState?: UIState;
closeMenu: () => void;
}
export interface MenuElement {
id: string;
name: string;
displayed?: boolean | ((params: MenuElementParams) => boolean);
color?: string | ((params: MenuElementParams) => string);
icon?: string;
text?: string;
fontSize?: string;
tooltipItems?: TooltipItem[];
tooltipKeys?: TooltipKey[];
cooldown?: (params: MenuElementParams) => number;
disabled: (params: MenuElementParams) => boolean;
action?: (params: MenuElementParams) => void; // For leaf items that perform actions
subMenu?: (params: MenuElementParams) => MenuElement[]; // For non-leaf items that open submenus
renderType?: string;
timerFraction?: (params: MenuElementParams) => number; // 0..1, for arc timer overlay
}
export interface TooltipKey {
key: string;
className: string;
params?: Record<string, string | number>;
}
export interface CenterButtonElement {
disabled: (params: MenuElementParams) => boolean;
action: (params: MenuElementParams) => void;
}
export const COLORS = {
build: "#e6c74a",
building: "#1e3a5f",
boat: "#2a82c9",
ally: "#4ade80",
breakAlly: "#dc2626",
breakAllyNoDebuff: "#d97706",
delete: "#ef4444",
info: "#475569",
target: "#ef4444",
attack: "#ef4444",
infoDetails: "#7f8c8d",
infoEmoji: "#fbbf24",
trade: "#0891b2",
embargo: "#7c3aed",
tooltip: {
cost: "#f59e0b",
count: "#94a3b8",
},
chat: {
default: "#6366f1",
help: "#22c55e",
attack: "#ef4444",
defend: "#3b82f6",
greet: "#f97316",
misc: "#a855f7",
warnings: "#fbbf24",
},
};
export enum Slot {
Info = "info",
Boat = "boat",
Build = "build",
Attack = "attack",
Ally = "ally",
Back = "back",
Delete = "delete",
}
function isFriendlyTarget(params: MenuElementParams): boolean {
const selectedPlayer = params.selected;
if (selectedPlayer === null) return false;
const isFriendly = (selectedPlayer as PlayerView).isFriendly;
if (typeof isFriendly !== "function") return false;
return isFriendly.call(selectedPlayer, params.myPlayer);
}
function isDisconnectedTarget(params: MenuElementParams): boolean {
const selectedPlayer = params.selected;
if (selectedPlayer === null) return false;
const isDisconnected = (selectedPlayer as PlayerView).isDisconnected;
if (typeof isDisconnected !== "function") return false;
return isDisconnected.call(selectedPlayer);
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const infoChatElement: MenuElement = {
id: "info_chat",
name: "chat",
disabled: () => false,
color: COLORS.chat.default,
icon: chatIcon,
subMenu: (params: MenuElementParams) =>
params.chatIntegration
.createQuickChatMenu(params.selected!)
.map((item) => ({
...item,
action: item.action
? (_params: MenuElementParams) => item.action!(params)
: undefined,
})),
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const allyTargetElement: MenuElement = {
id: "ally_target",
name: "target",
disabled: (params: MenuElementParams): boolean => {
if (params.selected === null) return true;
return !params.playerActions.interaction?.canTarget;
},
color: COLORS.target,
icon: targetIcon,
action: (params: MenuElementParams) => {
params.playerActionHandler.handleTargetPlayer(params.selected!.id());
params.closeMenu();
},
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const allyTradeElement: MenuElement = {
id: "ally_trade",
name: "trade",
disabled: (params: MenuElementParams) =>
!!params.playerActions?.interaction?.canEmbargo,
displayed: (params: MenuElementParams) =>
!params.playerActions?.interaction?.canEmbargo,
color: COLORS.trade,
text: translateText("player_panel.start_trade"),
action: (params: MenuElementParams) => {
params.playerActionHandler.handleEmbargo(params.selected!, "stop");
params.closeMenu();
},
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const allyEmbargoElement: MenuElement = {
id: "ally_embargo",
name: "embargo",
disabled: (params: MenuElementParams) =>
!params.playerActions?.interaction?.canEmbargo,
displayed: (params: MenuElementParams) =>
!!params.playerActions?.interaction?.canEmbargo,
color: COLORS.embargo,
text: translateText("player_panel.stop_trade"),
action: (params: MenuElementParams) => {
params.playerActionHandler.handleEmbargo(params.selected!, "start");
params.closeMenu();
},
};
const allyRequestElement: MenuElement = {
id: "ally_request",
name: "request",
disabled: (params: MenuElementParams) =>
!params.playerActions?.interaction?.canSendAllianceRequest,
displayed: (params: MenuElementParams) =>
!params.playerActions?.interaction?.canBreakAlliance,
color: COLORS.ally,
icon: allianceIcon,
action: (params: MenuElementParams) => {
params.playerActionHandler.handleAllianceRequest(
params.myPlayer,
params.selected!,
);
params.closeMenu();
},
};
const allyExtendElement: MenuElement = {
id: "ally_extend",
name: "extend",
displayed: (params: MenuElementParams) =>
!!params.playerActions?.interaction?.allianceInfo?.inExtensionWindow,
disabled: (params: MenuElementParams) =>
!params.playerActions?.interaction?.allianceInfo?.canExtend,
color: COLORS.ally,
icon: allianceIcon,
action: (params: MenuElementParams) => {
if (!params.playerActions?.interaction?.allianceInfo?.canExtend) return;
params.playerActionHandler.handleExtendAlliance(params.selected!);
params.closeMenu();
},
timerFraction: (params: MenuElementParams): number => {
const interaction = params.playerActions?.interaction;
if (!interaction?.allianceInfo) return 1;
const remaining = Math.max(
0,
interaction.allianceInfo.expiresAt - params.game.ticks(),
);
const extensionWindow = Math.max(
1,
params.game.config().allianceExtensionPromptOffset(),
);
return Math.max(0, Math.min(1, remaining / extensionWindow));
},
renderType: "allyExtend",
};
const allyBreakElement: MenuElement = {
id: "ally_break",
name: "break",
disabled: (params: MenuElementParams) =>
!params.playerActions?.interaction?.canBreakAlliance,
displayed: (params: MenuElementParams) =>
!!params.playerActions?.interaction?.canBreakAlliance,
color: (params: MenuElementParams) =>
params.selected?.isTraitor() || params.selected?.isDisconnected()
? COLORS.breakAllyNoDebuff
: COLORS.breakAlly,
icon: traitorIcon,
action: (params: MenuElementParams) => {
params.playerActionHandler.handleBreakAlliance(
params.myPlayer,
params.selected!,
);
params.closeMenu();
},
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const allyDonateGoldElement: MenuElement = {
id: "ally_donate_gold",
name: "donate gold",
disabled: (params: MenuElementParams) =>
!params.playerActions?.interaction?.canDonateGold,
color: COLORS.ally,
icon: donateGoldIcon,
action: (params: MenuElementParams) => {
params.playerActionHandler.handleDonateGold(params.selected!);
params.closeMenu();
},
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const allyDonateTroopsElement: MenuElement = {
id: "ally_donate_troops",
name: "donate troops",
disabled: (params: MenuElementParams) =>
!params.playerActions?.interaction?.canDonateTroops,
color: COLORS.ally,
icon: donateTroopIcon,
action: (params: MenuElementParams) => {
params.playerActionHandler.handleDonateTroops(params.selected!);
params.closeMenu();
},
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const infoPlayerElement: MenuElement = {
id: "info_player",
name: "player",
disabled: () => false,
color: COLORS.info,
icon: infoIcon,
action: (params: MenuElementParams) => {
params.playerPanel.show(params.playerActions, params.tile);
},
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const infoEmojiElement: MenuElement = {
id: "info_emoji",
name: "emoji",
disabled: () => false,
color: COLORS.infoEmoji,
icon: emojiIcon,
subMenu: (params: MenuElementParams) => {
const emojiElements: MenuElement[] = [
{
id: "emoji_more",
name: "more",
disabled: () => false,
color: COLORS.infoEmoji,
icon: emojiIcon,
action: (params: MenuElementParams) => {
params.emojiTable.showTable((emoji) => {
const targetPlayer =
params.selected === params.game.myPlayer()
? AllPlayers
: params.selected;
params.playerActionHandler.handleEmoji(
targetPlayer!,
flattenedEmojiTable.indexOf(emoji as Emoji),
);
params.emojiTable.hideTable();
});
},
},
];
const emojiCount = 8;
for (let i = 0; i < emojiCount; i++) {
emojiElements.push({
id: `emoji_${i}`,
name: flattenedEmojiTable[i],
text: flattenedEmojiTable[i],
disabled: () => false,
fontSize: "25px",
action: (params: MenuElementParams) => {
const targetPlayer =
params.selected === params.game.myPlayer()
? AllPlayers
: params.selected;
params.playerActionHandler.handleEmoji(targetPlayer!, i);
params.closeMenu();
},
});
}
return emojiElements;
},
};
export const infoMenuElement: MenuElement = {
id: Slot.Info,
name: "info",
disabled: (params: MenuElementParams) =>
!params.selected || params.game.inSpawnPhase(),
icon: infoIcon,
color: COLORS.info,
action: (params: MenuElementParams) => {
params.playerPanel.show(params.playerActions, params.tile);
},
};
function getAllEnabledUnits(
myPlayer: boolean,
config: Config,
): Set<PlayerBuildableUnitType> {
const units: Set<PlayerBuildableUnitType> =
new Set<PlayerBuildableUnitType>();
const addIfEnabled = (unitType: PlayerBuildableUnitType) => {
if (!config.isUnitDisabled(unitType)) {
units.add(unitType);
}
};
if (myPlayer) {
Structures.types.forEach(addIfEnabled);
} else {
BuildableAttacks.types.forEach(addIfEnabled);
}
return units;
}
function createMenuElements(
params: MenuElementParams,
filterType: "attack" | "build",
elementIdPrefix: string,
): MenuElement[] {
const unitTypes: Set<PlayerBuildableUnitType> = getAllEnabledUnits(
params.selected === params.myPlayer,
params.game.config(),
);
return flattenedBuildTable
.filter(
(item) =>
unitTypes.has(item.unitType) &&
(filterType === "attack"
? BuildableAttacks.has(item.unitType)
: !BuildableAttacks.has(item.unitType)),
)
.map((item: BuildItemDisplay) => {
return {
id: `${elementIdPrefix}_${item.unitType}`,
name: item.key
? item.key.replace("unit_type.", "")
: item.unitType.toString(),
disabled: (p: MenuElementParams) =>
!p.buildMenu.canBuildOrUpgrade(item),
color: (p: MenuElementParams) =>
p.buildMenu.canBuildOrUpgrade(item)
? filterType === "attack"
? COLORS.attack
: COLORS.building
: COLORS.building,
icon: item.icon,
tooltipItems: [
{ text: translateText(item.key ?? ""), className: "title" },
{
text: translateText(item.description ?? ""),
className: "description",
},
{
text: `${renderNumber(params.buildMenu.cost(item))} ${translateText("player_panel.gold")}`,
className: "cost",
},
item.countable
? { text: `${params.buildMenu.count(item)}x`, className: "count" }
: null,
].filter(
(tooltipItem): tooltipItem is TooltipItem => tooltipItem !== null,
),
action: (params: MenuElementParams) => {
const buildableUnit = params.playerActions.buildableUnits.find(
(bu) => bu.type === item.unitType,
);
if (buildableUnit === undefined) {
return;
}
if (params.buildMenu.canBuildOrUpgrade(item)) {
params.buildMenu.sendBuildOrUpgrade(buildableUnit, params.tile);
}
params.closeMenu();
},
};
});
}
export const attackMenuElement: MenuElement = {
id: Slot.Attack,
name: "radial_attack",
disabled: (params: MenuElementParams) => params.game.inSpawnPhase(),
icon: swordIcon,
color: COLORS.attack,
subMenu: (params: MenuElementParams) => {
if (params === undefined) return [];
return createMenuElements(params, "attack", "attack");
},
};
const donateGoldRadialElement: MenuElement = {
id: Slot.Attack,
name: "radial_donate_gold",
disabled: (params: MenuElementParams) =>
params.game.inSpawnPhase() ||
!params.playerActions?.interaction?.canDonateGold,
icon: donateGoldIcon,
color: "#f59e0b",
action: (params: MenuElementParams) => {
if (!params.selected) return;
params.playerPanel.openSendGoldModal(
params.playerActions,
params.tile,
params.selected,
);
},
};
export const deleteUnitElement: MenuElement = {
id: Slot.Delete,
name: "delete",
cooldown: (params: MenuElementParams) => params.myPlayer.deleteUnitCooldown(),
disabled: (params: MenuElementParams) => {
const tileOwner = params.game.owner(params.tile);
const isLand = params.game.isLand(params.tile);
if (!tileOwner.isPlayer() || tileOwner.id() !== params.myPlayer.id()) {
return true;
}
if (!isLand) {
return true;
}
if (params.game.inSpawnPhase()) {
return true;
}
if (params.myPlayer.deleteUnitCooldown() > 0) {
return true;
}
const DELETE_SELECTION_RADIUS = 5;
const myUnits = params.myPlayer
.units()
.filter(
(unit) =>
!unit.isUnderConstruction() &&
unit.markedForDeletion() === false &&
params.game.manhattanDist(unit.tile(), params.tile) <=
DELETE_SELECTION_RADIUS,
);
return myUnits.length === 0;
},
icon: xIcon,
color: COLORS.delete,
tooltipKeys: [
{
key: "radial_menu.delete_unit_title",
className: "title",
},
{
key: "radial_menu.delete_unit_description",
className: "description",
},
],
action: (params: MenuElementParams) => {
const DELETE_SELECTION_RADIUS = 5;
const myUnits = params.myPlayer
.units()
.filter(
(unit) =>
params.game.manhattanDist(unit.tile(), params.tile) <=
DELETE_SELECTION_RADIUS,
);
const closestUnit = findClosestBy(myUnits, (unit) =>
params.game.manhattanDist(unit.tile(), params.tile),
);
if (closestUnit) {
params.playerActionHandler.handleDeleteUnit(closestUnit.id());
}
params.closeMenu();
},
};
export const buildMenuElement: MenuElement = {
id: Slot.Build,
name: "build",
disabled: (params: MenuElementParams) => params.game.inSpawnPhase(),
icon: buildIcon,
color: COLORS.build,
subMenu: (params: MenuElementParams) => {
if (params === undefined) return [];
return createMenuElements(params, "build", "build");
},
};
export const boatMenuElement: MenuElement = {
id: Slot.Boat,
name: "boat",
disabled: (params: MenuElementParams) =>
!params.playerActions.buildableUnits.some(
(unit) => unit.type === UnitType.TransportShip && unit.canBuild,
),
icon: boatIcon,
color: COLORS.boat,
action: async (params: MenuElementParams) => {
params.playerActionHandler.handleBoatAttack(params.myPlayer, params.tile);
params.closeMenu();
},
};
export const centerButtonElement: CenterButtonElement = {
disabled: (params: MenuElementParams): boolean => {
const tileOwner = params.game.owner(params.tile);
const isLand = params.game.isLand(params.tile);
if (!isLand) {
return true;
}
if (params.game.inSpawnPhase()) {
if (params.game.config().isRandomSpawn()) {
return true;
}
if (tileOwner.isPlayer()) {
return true;
}
return false;
}
if (isFriendlyTarget(params) && !isDisconnectedTarget(params)) {
return !params.playerActions.interaction?.canDonateTroops;
}
return !params.playerActions.canAttack;
},
action: (params: MenuElementParams) => {
if (params.game.inSpawnPhase()) {
params.playerActionHandler.handleSpawn(params.tile);
} else {
if (isFriendlyTarget(params) && !isDisconnectedTarget(params)) {
const selectedPlayer = params.selected as PlayerView;
const ratio = params.uiState?.attackRatio ?? 1;
const troopsToDonate = Math.floor(ratio * params.myPlayer.troops());
if (troopsToDonate > 0) {
params.playerActionHandler.handleDonateTroops(
selectedPlayer,
troopsToDonate,
);
}
} else {
params.playerActionHandler.handleAttack(
params.myPlayer,
params.selected?.id() ?? null,
);
}
}
params.closeMenu();
},
};
export const rootMenuElement: MenuElement = {
id: "root",
name: "root",
disabled: () => false,
icon: infoIcon,
color: COLORS.info,
subMenu: (params: MenuElementParams) => {
const isAllied = params.selected?.isAlliedWith(params.myPlayer);
const isDisconnected = isDisconnectedTarget(params);
const tileOwner = params.game.owner(params.tile);
const isOwnTerritory =
tileOwner.isPlayer() &&
(tileOwner as PlayerView).id() === params.myPlayer.id();
const inExtensionWindow =
params.playerActions.interaction?.allianceInfo?.inExtensionWindow;
const menuItems: (MenuElement | null)[] = [
infoMenuElement,
...(isOwnTerritory
? [deleteUnitElement, allyRequestElement, buildMenuElement]
: [
isAllied && !isDisconnected ? allyBreakElement : boatMenuElement,
inExtensionWindow ? allyExtendElement : allyRequestElement,
isFriendlyTarget(params) && !isDisconnected
? donateGoldRadialElement
: attackMenuElement,
]),
];
return menuItems.filter((item): item is MenuElement => item !== null);
},
};
+79
View File
@@ -0,0 +1,79 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Relation } from "../../../core/game/Game";
type FaceData = {
color: string;
eyeCy: number;
mouth: string;
brows?: string[];
};
const RELATION_FACES: Partial<Record<Relation, FaceData>> = {
[Relation.Hostile]: {
color: "#ef4444",
eyeCy: 7.5,
mouth: "M5 12 Q8 9 11 12",
brows: ["M4 5.5 L6.5 7", "M12 5.5 L9.5 7"],
},
[Relation.Distrustful]: {
color: "#f97316",
eyeCy: 6.8,
mouth: "M5.5 11 Q8 9.2 10.5 11",
},
[Relation.Friendly]: {
color: "#22c55e",
eyeCy: 6.5,
mouth: "M5 10 Q8 13 11 10",
},
};
@customElement("relation-smiley")
export class RelationSmiley extends LitElement {
@property({ type: Number })
relation: Relation = Relation.Neutral;
createRenderRoot() {
return this;
}
render() {
const face = RELATION_FACES[this.relation];
if (!face) return html``;
const { color, eyeCy, mouth, brows } = face;
return html`<svg
width="16"
height="16"
viewBox="0 0 16 16"
xmlns="http://www.w3.org/2000/svg"
style="flex-shrink:0"
aria-hidden="true"
>
<circle
cx="8"
cy="8"
r="6.5"
stroke="${color}"
stroke-width="1.4"
fill="none"
/>
${brows?.map(
(d) =>
html`<path
d="${d}"
stroke="${color}"
stroke-width="1.4"
stroke-linecap="round"
/>`,
)}
<circle cx="5.8" cy="${eyeCy}" r="0.9" fill="${color}" />
<circle cx="10.2" cy="${eyeCy}" r="0.9" fill="${color}" />
<path
d="${mouth}"
stroke="${color}"
stroke-width="1.4"
fill="none"
stroke-linecap="round"
/>
</svg>`;
}
}
+107
View File
@@ -0,0 +1,107 @@
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { EventBus } from "../../../core/EventBus";
import { GameView } from "../../../core/game/GameView";
import { Controller } from "../../Controller";
import { ReplaySpeedChangeEvent } from "../../InputHandler";
import {
defaultReplaySpeedMultiplier,
ReplaySpeedMultiplier,
} from "../../utilities/ReplaySpeedMultiplier";
import { translateText } from "../../Utils";
export class ShowReplayPanelEvent {
constructor(
public visible: boolean = true,
public isSingleplayer: boolean = false,
) {}
}
@customElement("replay-panel")
export class ReplayPanel extends LitElement implements Controller {
public game: GameView | undefined;
public eventBus: EventBus | undefined;
@property({ type: Boolean })
visible: boolean = false;
@state()
private _replaySpeedMultiplier: number = defaultReplaySpeedMultiplier;
@property({ type: Boolean })
isSingleplayer = false;
createRenderRoot() {
return this; // Enable Tailwind CSS
}
init() {
if (this.eventBus) {
this.eventBus.on(ShowReplayPanelEvent, (event: ShowReplayPanelEvent) => {
this.visible = event.visible;
this.isSingleplayer = event.isSingleplayer;
});
this.eventBus.on(
ReplaySpeedChangeEvent,
(event: ReplaySpeedChangeEvent) => {
this._replaySpeedMultiplier = event.replaySpeedMultiplier;
this.requestUpdate();
},
);
}
}
getTickIntervalMs() {
return 1000;
}
tick() {
if (!this.visible) return;
this.requestUpdate();
}
onReplaySpeedChange(value: ReplaySpeedMultiplier) {
this._replaySpeedMultiplier = value;
this.eventBus?.emit(new ReplaySpeedChangeEvent(value));
}
render() {
if (!this.visible) return html``;
return html`
<div
class="p-2 bg-gray-800/92 backdrop-blur-sm shadow-xs min-[1200px]:rounded-lg rounded-l-lg"
@contextmenu=${(e: Event) => e.preventDefault()}
>
<label class="block mb-2 text-white" translate="no">
${this.game?.config()?.isReplay()
? translateText("replay_panel.replay_speed")
: translateText("replay_panel.game_speed")}
</label>
<div class="grid grid-cols-4 gap-2">
${this.renderSpeedButton(ReplaySpeedMultiplier.slow, "×0.5")}
${this.renderSpeedButton(ReplaySpeedMultiplier.normal, "×1")}
${this.renderSpeedButton(ReplaySpeedMultiplier.fast, "×2")}
${this.renderSpeedButton(
ReplaySpeedMultiplier.fastest,
translateText("replay_panel.fastest_game_speed"),
)}
</div>
</div>
`;
}
private renderSpeedButton(value: ReplaySpeedMultiplier, label: string) {
const backgroundColor =
this._replaySpeedMultiplier === value ? "bg-malibu-blue" : "";
return html`
<button
class="py-0.5 px-1 text-sm text-white rounded-sm border transition border-gray-500 ${backgroundColor} hover:border-gray-200"
@click=${() => this.onReplaySpeedChange(value)}
>
${label}
</button>
`;
}
}
+575
View File
@@ -0,0 +1,575 @@
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { EventBus } from "../../../core/EventBus";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { within } from "../../../core/Util";
import {
SendDonateGoldIntentEvent,
SendDonateTroopsIntentEvent,
} from "../../Transport";
import { UIState } from "../../UIState";
import { renderTroops, translateText } from "../../Utils";
@customElement("send-resource-modal")
export class SendResourceModal extends LitElement {
@property({ attribute: false }) eventBus: EventBus | null = null;
@property({ type: Boolean }) open: boolean = false;
@property({ type: String }) mode: "troops" | "gold" = "troops";
@property({ type: Object }) total: number | bigint = 0;
@property({ type: Object }) uiState: UIState | null = null; // to seed initial %
@property({ attribute: false }) format: (n: number) => string = renderTroops;
@property({ attribute: false }) myPlayer: PlayerView | null = null;
@property({ attribute: false }) target: PlayerView | null = null;
@property({ attribute: false }) gameView: GameView | null = null;
@property({ type: String }) heading: string | null = null;
@state() private sendAmount: number = 0;
@state() private selectedPercent: number | null = null;
private PRESETS = [10, 25, 50, 75, 100] as const;
createRenderRoot() {
return this;
}
connectedCallback() {
super.connectedCallback();
const initPct =
this.uiState && typeof this.uiState.attackRatio === "number"
? Math.round(this.uiState.attackRatio * 100)
: 100;
this.selectedPercent = this.sanitizePercent(initPct);
const basis = this.getPercentBasis();
this.sendAmount = this.clampSend(
Math.floor((basis * this.selectedPercent) / 100),
);
}
updated(changed: Map<string, unknown>) {
if (changed.has("open") && this.open) {
// If either side is dead, just close and do nothing
if (!this.isSenderAlive() || !this.isTargetAlive()) {
this.closeModal();
return;
}
queueMicrotask(() =>
(this.querySelector('[role="dialog"]') as HTMLElement | null)?.focus(),
);
}
if (
changed.has("total") ||
changed.has("mode") ||
changed.has("target") ||
changed.has("gameView")
) {
const basis = this.getPercentBasis();
if (this.selectedPercent !== null) {
const pct = this.sanitizePercent(this.selectedPercent);
const raw = Math.floor((basis * pct) / 100);
this.sendAmount = this.clampSend(raw);
} else {
this.sendAmount = this.clampSend(this.sendAmount);
}
}
}
private closeModal() {
this.dispatchEvent(new CustomEvent("close"));
}
private confirm() {
if (!this.isSenderAlive() || !this.isTargetAlive() || !this.eventBus) {
return;
}
const myPlayer = this.myPlayer;
const target = this.target;
const amount = this.limitAmount(this.sendAmount);
if (!myPlayer || !target || amount <= 0) return;
if (this.mode === "troops") {
const myTroops = Number(myPlayer.troops());
if (amount > myTroops) return;
this.eventBus.emit(new SendDonateTroopsIntentEvent(target, amount));
} else {
const myGold = Number(myPlayer.gold());
if (amount > myGold) return;
this.eventBus.emit(new SendDonateGoldIntentEvent(target, BigInt(amount)));
}
this.dispatchEvent(
new CustomEvent("confirm", {
detail: { amount, closePanel: true, success: true },
}),
);
this.closeModal();
}
private handleKeydown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
this.closeModal();
}
if (e.key === "Enter") {
e.preventDefault();
this.confirm();
}
};
private toNum(x: unknown): number {
if (typeof x === "bigint") return Number(x);
return Number(x ?? 0);
}
private getTotalNumber(): number {
const base = this.toNum(this.total);
return this.isSenderAlive() ? base : 0;
}
private sanitizePercent(p: number) {
return within(p, 0, 100);
}
/** Internal capacity only for troops; gold is unlimited. */
private getCapacityLeft(): number | null {
if (!this.isTargetAlive()) return 0;
if (this.mode !== "troops") return null;
if (!this.gameView || !this.target) return null;
const current = this.toNum(this.target.troops());
const max = this.toNum(this.gameView.config().maxTroops(this.target));
return Math.max(0, max - current);
}
private getPercentBasis(): number {
return this.getTotalNumber();
}
private limitAmount(proposed: number): number {
const cap = this.getCapacityLeft();
const total = this.getTotalNumber();
const hardMax = cap === null ? total : Math.min(total, cap);
return within(proposed, 0, hardMax);
}
private clampSend(n: number) {
const total = this.getTotalNumber();
const byTotal = within(n, 0, total);
return this.limitAmount(byTotal);
}
private percentOfBasis(n: number): number {
const basis = this.getPercentBasis();
return basis ? Math.round((n / basis) * 100) : 0;
}
private keepAfter(allowed: number): number {
const total = this.getTotalNumber();
return Math.max(0, total - allowed);
}
private getFillColor(): string {
return this.mode === "troops"
? "rgb(168 85 247)" /* purple */
: "rgb(234 179 8)" /* amber */;
}
private getMinKeepRatio(): number {
return this.mode === "troops" ? 0.3 : 0;
}
private isTargetAlive(): boolean {
return this.target?.isAlive() ?? false;
}
private isSenderAlive(): boolean {
return this.myPlayer?.isAlive() ?? false;
}
private i18n = {
title: (name: string) =>
this.mode === "troops"
? translateText("send_troops_modal.title_with_name", { name })
: translateText("send_gold_modal.title_with_name", { name }),
availableChip: () => translateText("common.available"),
availableTooltip: () =>
this.mode === "troops"
? translateText("send_troops_modal.available_tooltip")
: translateText("send_gold_modal.available_tooltip"),
max: () => translateText("common.preset_max"),
ariaSlider: () =>
this.mode === "troops"
? translateText("send_troops_modal.aria_slider")
: translateText("send_gold_modal.aria_slider"),
summarySend: () => translateText("common.summary_send"),
summaryKeep: () => translateText("common.summary_keep"),
closeLabel: () => translateText("common.close"),
cancel: () => translateText("common.cancel"),
send: () => translateText("common.send"),
cap: () => translateText("common.cap_label"),
capTooltip: () => translateText("common.cap_tooltip"),
sliderTooltip: (percent: number, amountStr: string) =>
this.mode === "troops"
? translateText("send_troops_modal.slider_tooltip", {
percent,
amount: amountStr,
})
: translateText("send_gold_modal.slider_tooltip", {
percent,
amount: amountStr,
}),
capacityNote: (amountStr: string) =>
translateText("send_troops_modal.capacity_note", { amount: amountStr }),
targetDeadTitle: () => translateText("common.target_dead"),
targetDeadNote: () => translateText("common.target_dead_note"),
};
private renderHeader() {
const name = this.target?.name?.() ?? "";
return html`
<div class="mb-3 flex items-center justify-between relative">
<h2
id="send-title"
class="text-lg font-semibold tracking-tight text-zinc-100"
>
${this.heading ?? this.i18n.title(name)}
</h2>
<!-- Close button -->
<button
type="button"
@click=${() => this.closeModal()}
class="absolute -top-3 -right-3 flex h-7 w-7 items-center justify-center rounded-full bg-zinc-700 text-white shadow-sm hover:bg-red-500 transition-colors focus-visible:ring-2 focus-visible:ring-white/30 focus:outline-hidden"
aria-label=${this.i18n.closeLabel()}
title=${this.i18n.closeLabel()}
>
</button>
</div>
`;
}
private renderAvailable() {
const total = this.getTotalNumber();
return html`
<div class="mb-4 pb-3 border-b border-zinc-800">
<div class="flex items-center gap-2 text-[13px]">
<!-- Available -->
<span
class="inline-flex items-center gap-1 rounded-full bg-indigo-600/15 px-2 py-0.5 ring-1 ring-indigo-400/40 text-indigo-100"
title=${this.i18n.availableTooltip()}
>
<span class="opacity-90">${this.i18n.availableChip()}</span>
<span class="font-mono tabular-nums">${this.format(total)}</span>
</span>
</div>
</div>
`;
}
private renderPresets(percentNow: number) {
const basis = this.getTotalNumber();
const dead = !this.isSenderAlive() || !this.isTargetAlive();
return html`
<div class="mb-8 grid grid-cols-5 gap-2">
${this.PRESETS.map((p) => {
const pct = this.sanitizePercent(p);
const active = (this.selectedPercent ?? percentNow) === pct;
const label = pct === 100 ? this.i18n.max() : `${pct}%`;
return html`
<button
?disabled=${dead}
class="rounded-lg px-3 py-2 text-sm ring-1 transition
${dead
? "bg-zinc-800/70 text-zinc-400 ring-zinc-700 cursor-not-allowed"
: active
? "bg-indigo-600 text-white ring-indigo-300/60"
: "bg-zinc-800 text-zinc-200 ring-zinc-700 hover:bg-zinc-700 hover:text-zinc-50"}"
@click=${() => {
if (dead) return;
this.selectedPercent = pct;
const raw = Math.floor((basis * pct) / 100);
this.sendAmount = this.clampSend(raw);
}}
?aria-pressed=${active}
title="${pct}%"
>
${label}
</button>
`;
})}
</div>
`;
}
private renderSlider(percentNow: number) {
const basis = this.getTotalNumber();
const cap = this.getCapacityLeft();
const hardMax = cap === null ? basis : Math.min(basis, cap);
const dead = !this.isSenderAlive() || !this.isTargetAlive();
// Where to draw the cap marker (as % of Available)
const capPercent =
cap === null
? null
: Math.max(
0,
Math.min(
100,
Math.round((Math.min(cap, basis) / (basis || 1)) * 100),
),
);
const fill = this.getFillColor();
const disabled = basis <= 0 || dead;
const sliderOuterMb = capPercent !== null ? "mb-8" : "mb-2";
return html`
<div class="${sliderOuterMb}">
<div
class="relative px-1 rounded-lg overflow-visible focus-within:ring-2 focus-within:ring-indigo-500/30"
>
<input
type="range"
min="0"
.max=${basis}
.value=${this.sendAmount}
?disabled=${disabled}
@input=${(e: Event) => {
if (dead) return;
const raw = Number((e.target as HTMLInputElement).value);
const pctRaw = basis ? Math.round((raw / basis) * 100) : 0;
this.selectedPercent = this.sanitizePercent(pctRaw);
const clamped = Math.min(raw, hardMax);
this.sendAmount = this.clampSend(clamped);
}}
class="w-full appearance-none bg-transparent range-x focus:outline-hidden"
aria-label=${this.i18n.ariaSlider()}
aria-valuemin="0"
aria-valuemax=${hardMax}
aria-valuetext=${this.i18n.sliderTooltip(
percentNow,
this.format(this.sendAmount),
)}
style="--percent:${percentNow}%; --fill:${fill}; --track: rgba(255,255,255,.28); --thumb-ring: rgb(24 24 27);"
/>
<!-- Tooltip -->
<div
class="pointer-events-none absolute -top-6 -translate-x-1/2 select-none left-(--pos)"
style="--pos: ${percentNow}%"
>
<div
class="rounded-sm bg-[#0f1116] ring-1 ring-zinc-700 text-zinc-100 px-1.5 py-0.5 text-[12px] shadow-sm whitespace-nowrap w-max z-50"
>
${percentNow}% ${this.format(this.sendAmount)}
</div>
</div>
<!-- Cap marker -->
${capPercent !== null
? html`
<div
class="pointer-events-none absolute top-1/2 -translate-y-1/2 h-3 w-0.5 bg-amber-400/80 shadow-sm left-(--pos)"
style="--pos:${capPercent}%;"
title=${this.i18n.capTooltip()}
></div>
<div
class="pointer-events-none absolute top-full mt-1.5 -translate-x-1/2 select-none left-(--pos)"
style="--pos:${capPercent}%"
>
<div
class="rounded-sm bg-[#0f1116] ring-1 ring-amber-400/40 text-amber-200 px-1 py-0.5 text-[11px] shadow-sm whitespace-nowrap"
>
${this.i18n.cap()}
</div>
</div>
`
: html``}
</div>
</div>
`;
}
private renderCapacityNote(allowed: number) {
const capped = allowed !== this.sendAmount;
if (!capped) return html``;
return html`<p class="mt-1 text-xs text-amber-300">
${this.i18n.capacityNote(this.format(allowed))}
</p>`;
}
private renderSummary(allowed: number) {
const total = this.getTotalNumber();
const keep = this.keepAfter(allowed);
const belowMinKeep =
this.getMinKeepRatio() > 0 &&
keep < Math.floor(total * this.getMinKeepRatio());
return html`
<div class="mt-3 text-center text-sm text-zinc-200">
${this.i18n.summarySend()}
<span class="font-semibold text-indigo-400 font-mono"
>${this.format(allowed)}</span
>
· ${this.i18n.summaryKeep()}
<span
class="font-semibold font-mono ${belowMinKeep
? "text-amber-400"
: "text-emerald-400"}"
>
${this.format(keep)}
</span>
</div>
`;
}
private renderActions() {
const total = this.getTotalNumber();
const dead = !this.isSenderAlive() || !this.isTargetAlive();
const disabled = total <= 0 || this.clampSend(this.sendAmount) <= 0 || dead;
return html`
<div class="mt-5 flex justify-end gap-2">
<button
class="h-10 min-w-24 rounded-lg px-3 text-sm font-semibold
text-zinc-100 bg-zinc-800 ring-1 ring-zinc-700
hover:bg-zinc-700 focus:outline-hidden
focus-visible:ring-2 focus-visible:ring-white/20"
@click=${() => this.closeModal()}
>
${this.i18n.cancel()}
</button>
<button
class="h-10 min-w-24 rounded-lg px-3 text-sm font-semibold text-white
bg-indigo-600 enabled:hover:bg-indigo-500
focus:outline-hidden focus-visible:ring-2 focus-visible:ring-indigo-400/50
disabled:cursor-not-allowed disabled:opacity-50"
?disabled=${disabled}
@click=${() => this.confirm()}
>
${this.i18n.send()}
</button>
</div>
`;
}
private renderDeadNote() {
return html`
<div
class="mb-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-amber-200 text-sm"
>
<div class="font-semibold">${this.i18n.targetDeadTitle()}</div>
<div>${this.i18n.targetDeadNote()}</div>
</div>
`;
}
private renderSliderStyles() {
return html`
<style>
.range-x {
-webkit-appearance: none;
appearance: none;
height: 8px;
outline: none;
background: transparent;
}
.range-x::-webkit-slider-runnable-track {
height: 8px;
border-radius: 9999px;
background: linear-gradient(
90deg,
var(--fill) 0,
var(--fill) var(--percent),
/* allowed (clamped) fill */ rgba(255, 255, 255, 0.22)
var(--percent),
rgba(255, 255, 255, 0.22) 100%
);
}
.range-x::-webkit-slider-thumb {
-webkit-appearance: none;
height: 18px;
width: 18px;
border-radius: 9999px;
background: var(--fill);
border: 3px solid var(--thumb-ring);
margin-top: -5px;
}
.range-x::-moz-range-track {
height: 8px;
border-radius: 9999px;
background: rgba(255, 255, 255, 0.22);
}
.range-x::-moz-range-progress {
height: 8px;
border-radius: 9999px;
background: var(--fill);
}
.range-x::-moz-range-thumb {
height: 18px;
width: 18px;
border-radius: 9999px;
background: var(--fill);
border: 3px solid var(--thumb-ring);
}
</style>
`;
}
render() {
if (!this.open) return html``;
const percent = this.percentOfBasis(this.sendAmount);
const allowed = this.limitAmount(this.sendAmount);
return html`
<div class="absolute inset-0 z-1100 flex items-center justify-center p-4">
<div
class="absolute inset-0 bg-black/60 rounded-2xl"
@click=${() => this.closeModal()}
></div>
<div
role="dialog"
aria-modal="true"
aria-labelledby="send-title"
class="relative z-10 w-full max-w-135 focus:outline-hidden"
tabindex="0"
@keydown=${this.handleKeydown}
>
<div
class="rounded-2xl bg-zinc-900 p-5 shadow-2xl ring-1 ring-zinc-800 max-h-[90vh] text-zinc-200"
@click=${(e: MouseEvent) => e.stopPropagation()}
>
${this.renderHeader()} ${this.renderAvailable()}
${!this.isTargetAlive() ? this.renderDeadNote() : html``}
${this.renderPresets(percent)} ${this.renderSlider(percent)}
${this.mode === "troops"
? this.renderCapacityNote(allowed)
: html``}
${this.renderSummary(allowed)} ${this.renderActions()}
${this.renderSliderStyles()}
</div>
</div>
</div>
`;
}
}
+550
View File
@@ -0,0 +1,550 @@
import { html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators.js";
import { crazyGamesSDK } from "src/client/CrazyGamesSDK";
import { PauseGameIntentEvent } from "src/client/Transport";
import { assetUrl } from "../../../core/AssetUrls";
import { EventBus } from "../../../core/EventBus";
import { UserSettings } from "../../../core/game/UserSettings";
import { Controller } from "../../Controller";
import { AlternateViewEvent, RefreshGraphicsEvent } from "../../InputHandler";
import { translateText } from "../../Utils";
import {
SetBackgroundMusicVolumeEvent,
SetSoundEffectsVolumeEvent,
} from "../../sound/Sounds";
const structureIcon = assetUrl("images/CityIconWhite.svg");
const cursorPriceIcon = assetUrl("images/CursorPriceIconWhite.svg");
const darkModeIcon = assetUrl("images/DarkModeIconWhite.svg");
const emojiIcon = assetUrl("images/EmojiIconWhite.svg");
const exitIcon = assetUrl("images/ExitIconWhite.svg");
const explosionIcon = assetUrl("images/ExplosionIconWhite.svg");
const mouseIcon = assetUrl("images/MouseIconWhite.svg");
const ninjaIcon = assetUrl("images/NinjaIconWhite.svg");
const settingsIcon = assetUrl("images/SettingIconWhite.svg");
const sirenIcon = assetUrl("images/SirenIconWhite.svg");
const swordIcon = assetUrl("images/SwordIconWhite.svg");
const treeIcon = assetUrl("images/TreeIconWhite.svg");
const musicIcon = assetUrl("images/music.svg");
export class ShowSettingsModalEvent {
constructor(
public readonly isVisible: boolean = true,
public readonly shouldPause: boolean = false,
public readonly isPaused: boolean = false,
) {}
}
@customElement("settings-modal")
export class SettingsModal extends LitElement implements Controller {
public eventBus: EventBus;
public userSettings: UserSettings;
@state()
private isVisible: boolean = false;
@state()
private alternateView: boolean = false;
@query(".modal-overlay")
private modalOverlay!: HTMLElement;
@property({ type: Boolean })
shouldPause = false;
@property({ type: Boolean })
wasPausedWhenOpened = false;
init() {
this.eventBus.on(ShowSettingsModalEvent, (event) => {
this.isVisible = event.isVisible;
this.shouldPause = event.shouldPause;
this.wasPausedWhenOpened = event.isPaused;
this.pauseGame(true);
});
}
createRenderRoot() {
return this;
}
connectedCallback() {
super.connectedCallback();
window.addEventListener("click", this.handleOutsideClick, true);
window.addEventListener("keydown", this.handleKeyDown);
}
disconnectedCallback() {
window.removeEventListener("click", this.handleOutsideClick, true);
window.removeEventListener("keydown", this.handleKeyDown);
super.disconnectedCallback();
}
private handleOutsideClick = (event: MouseEvent) => {
if (
this.isVisible &&
this.modalOverlay &&
event.target === this.modalOverlay
) {
this.closeModal();
}
};
private handleKeyDown = (event: KeyboardEvent) => {
if (this.isVisible && event.key === "Escape") {
this.closeModal();
}
};
public openModal() {
this.isVisible = true;
this.requestUpdate();
}
public closeModal() {
this.isVisible = false;
this.requestUpdate();
this.pauseGame(false);
}
private pauseGame(pause: boolean) {
if (this.shouldPause && !this.wasPausedWhenOpened) {
if (pause) {
crazyGamesSDK.gameplayStop();
} else {
crazyGamesSDK.gameplayStart();
}
this.eventBus.emit(new PauseGameIntentEvent(pause));
}
}
private onTerrainButtonClick() {
this.alternateView = !this.alternateView;
this.eventBus.emit(new AlternateViewEvent(this.alternateView));
this.requestUpdate();
}
private onToggleEmojisButtonClick() {
this.userSettings.toggleEmojis();
this.requestUpdate();
}
private onToggleStructureSpritesButtonClick() {
this.userSettings.toggleStructureSprites();
this.requestUpdate();
}
private onToggleSpecialEffectsButtonClick() {
this.userSettings.toggleFxLayer();
this.requestUpdate();
}
private onToggleAlertFrameButtonClick() {
this.userSettings.toggleAlertFrame();
this.requestUpdate();
}
private onToggleDarkModeButtonClick() {
this.userSettings.toggleDarkMode();
this.eventBus.emit(new RefreshGraphicsEvent());
this.requestUpdate();
}
private onToggleRandomNameModeButtonClick() {
this.userSettings.toggleRandomName();
this.requestUpdate();
}
private onToggleLeftClickOpensMenu() {
this.userSettings.toggleLeftClickOpenMenu();
this.requestUpdate();
}
private onToggleCursorCostLabelButtonClick() {
this.userSettings.toggleCursorCostLabel();
this.requestUpdate();
}
private onToggleAttackingTroopsOverlayButtonClick() {
this.userSettings.toggleAttackingTroopsOverlay();
this.requestUpdate();
}
private onTogglePerformanceOverlayButtonClick() {
this.userSettings.togglePerformanceOverlay();
this.requestUpdate();
}
private onExitButtonClick() {
// redirect to the home page
window.location.href = "/";
}
private onVolumeChange(event: Event) {
const volume = parseFloat((event.target as HTMLInputElement).value) / 100;
this.userSettings.setBackgroundMusicVolume(volume);
this.eventBus.emit(new SetBackgroundMusicVolumeEvent(volume));
this.requestUpdate();
}
private onSoundEffectsVolumeChange(event: Event) {
const volume = parseFloat((event.target as HTMLInputElement).value) / 100;
this.userSettings.setSoundEffectsVolume(volume);
this.eventBus.emit(new SetSoundEffectsVolumeEvent(volume));
this.requestUpdate();
}
render() {
if (!this.isVisible) {
return null;
}
return html`
<div
class="modal-overlay fixed inset-0 bg-black/60 backdrop-blur-xs z-2000 flex items-center justify-center p-4"
@contextmenu=${(e: Event) => e.preventDefault()}
>
<div
class="bg-slate-800 border border-slate-600 rounded-lg max-w-md w-full max-h-[80vh] overflow-y-auto"
>
<div
class="flex items-center justify-between p-4 border-b border-slate-600"
>
<div class="flex items-center gap-2">
<img
src=${settingsIcon}
alt="settings"
width="24"
height="24"
class="align-middle"
/>
<h2 class="text-xl font-semibold text-white">
${translateText("user_setting.tab_basic")}
</h2>
</div>
<button
class="text-slate-400 hover:text-white text-2xl font-bold leading-none"
@click=${this.closeModal}
>
×
</button>
</div>
<div class="p-4 flex flex-col gap-3">
<div
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
>
<img src=${musicIcon} alt="musicIcon" width="20" height="20" />
<div class="flex-1">
<div class="font-medium">
${translateText("user_setting.background_music_volume")}
</div>
<input
type="range"
min="0"
max="100"
.value=${this.userSettings.backgroundMusicVolume() * 100}
@input=${this.onVolumeChange}
class="w-full border border-slate-500 rounded-lg"
/>
</div>
<div class="text-sm text-slate-400">
${Math.round(this.userSettings.backgroundMusicVolume() * 100)}%
</div>
</div>
<div
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
>
<img
src=${musicIcon}
alt="soundEffectsIcon"
width="20"
height="20"
/>
<div class="flex-1">
<div class="font-medium">
${translateText("user_setting.sound_effects_volume")}
</div>
<input
type="range"
min="0"
max="100"
.value=${this.userSettings.soundEffectsVolume() * 100}
@input=${this.onSoundEffectsVolumeChange}
class="w-full border border-slate-500 rounded-lg"
/>
</div>
<div class="text-sm text-slate-400">
${Math.round(this.userSettings.soundEffectsVolume() * 100)}%
</div>
</div>
<button
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
@click="${this.onTerrainButtonClick}"
>
<img src=${treeIcon} alt="treeIcon" width="20" height="20" />
<div class="flex-1">
<div class="font-medium">
${translateText("user_setting.toggle_terrain")}
</div>
<div class="text-sm text-slate-400">
${translateText("user_setting.toggle_view_desc")}
</div>
</div>
<div class="text-sm text-slate-400">
${this.alternateView
? translateText("user_setting.on")
: translateText("user_setting.off")}
</div>
</button>
<button
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
@click="${this.onToggleEmojisButtonClick}"
>
<img src=${emojiIcon} alt="emojiIcon" width="20" height="20" />
<div class="flex-1">
<div class="font-medium">
${translateText("user_setting.emojis_label")}
</div>
<div class="text-sm text-slate-400">
${translateText("user_setting.emojis_desc")}
</div>
</div>
<div class="text-sm text-slate-400">
${this.userSettings.emojis()
? translateText("user_setting.on")
: translateText("user_setting.off")}
</div>
</button>
<button
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
@click="${this.onToggleDarkModeButtonClick}"
>
<img
src=${darkModeIcon}
alt="darkModeIcon"
width="20"
height="20"
/>
<div class="flex-1">
<div class="font-medium">
${translateText("user_setting.dark_mode_label")}
</div>
<div class="text-sm text-slate-400">
${translateText("user_setting.dark_mode_desc")}
</div>
</div>
<div class="text-sm text-slate-400">
${this.userSettings.darkMode()
? translateText("user_setting.on")
: translateText("user_setting.off")}
</div>
</button>
<button
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
@click="${this.onToggleSpecialEffectsButtonClick}"
>
<img
src=${explosionIcon}
alt="specialEffects"
width="20"
height="20"
/>
<div class="flex-1">
<div class="font-medium">
${translateText("user_setting.special_effects_label")}
</div>
<div class="text-sm text-slate-400">
${translateText("user_setting.special_effects_desc")}
</div>
</div>
<div class="text-sm text-slate-400">
${this.userSettings.fxLayer()
? translateText("user_setting.on")
: translateText("user_setting.off")}
</div>
</button>
<button
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
@click="${this.onToggleAlertFrameButtonClick}"
>
<img src=${sirenIcon} alt="alertFrame" width="20" height="20" />
<div class="flex-1">
<div class="font-medium">
${translateText("user_setting.alert_frame_label")}
</div>
<div class="text-sm text-slate-400">
${translateText("user_setting.alert_frame_desc")}
</div>
</div>
<div class="text-sm text-slate-400">
${this.userSettings.alertFrame()
? translateText("user_setting.on")
: translateText("user_setting.off")}
</div>
</button>
<button
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
@click="${this.onToggleStructureSpritesButtonClick}"
>
<img
src=${structureIcon}
alt="structureSprites"
width="20"
height="20"
/>
<div class="flex-1">
<div class="font-medium">
${translateText("user_setting.structure_sprites_label")}
</div>
<div class="text-sm text-slate-400">
${translateText("user_setting.structure_sprites_desc")}
</div>
</div>
<div class="text-sm text-slate-400">
${this.userSettings.structureSprites()
? translateText("user_setting.on")
: translateText("user_setting.off")}
</div>
</button>
<button
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
@click="${this.onToggleAttackingTroopsOverlayButtonClick}"
>
<img src=${swordIcon} alt="swordIcon" width="20" height="20" />
<div class="flex-1">
<div class="font-medium">
${translateText(
"user_setting.attacking_troops_overlay_label",
)}
</div>
<div class="text-sm text-slate-400">
${translateText("user_setting.attacking_troops_overlay_desc")}
</div>
</div>
<div class="text-sm text-slate-400">
${this.userSettings.attackingTroopsOverlay()
? translateText("user_setting.on")
: translateText("user_setting.off")}
</div>
</button>
<button
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
@click="${this.onToggleCursorCostLabelButtonClick}"
>
<img
src=${cursorPriceIcon}
alt="cursorCostLabel"
width="20"
height="20"
/>
<div class="flex-1">
<div class="font-medium">
${translateText("user_setting.cursor_cost_label_label")}
</div>
<div class="text-sm text-slate-400">
${translateText("user_setting.cursor_cost_label_desc")}
</div>
</div>
<div class="text-sm text-slate-400">
${this.userSettings.cursorCostLabel()
? translateText("user_setting.on")
: translateText("user_setting.off")}
</div>
</button>
<button
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
@click="${this.onToggleRandomNameModeButtonClick}"
>
<img src=${ninjaIcon} alt="ninjaIcon" width="20" height="20" />
<div class="flex-1">
<div class="font-medium">
${translateText("user_setting.anonymous_names_label")}
</div>
<div class="text-sm text-slate-400">
${translateText("user_setting.anonymous_names_desc")}
</div>
</div>
<div class="text-sm text-slate-400">
${this.userSettings.anonymousNames()
? translateText("user_setting.on")
: translateText("user_setting.off")}
</div>
</button>
<button
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
@click="${this.onToggleLeftClickOpensMenu}"
>
<img src=${mouseIcon} alt="mouseIcon" width="20" height="20" />
<div class="flex-1">
<div class="font-medium">
${translateText("user_setting.left_click_menu")}
</div>
<div class="text-sm text-slate-400">
${translateText("user_setting.left_click_desc")}
</div>
</div>
<div class="text-sm text-slate-400">
${this.userSettings.leftClickOpensMenu()
? translateText("user_setting.on")
: translateText("user_setting.off")}
</div>
</button>
<button
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
@click="${this.onTogglePerformanceOverlayButtonClick}"
>
<img
src=${settingsIcon}
alt="performanceIcon"
width="20"
height="20"
/>
<div class="flex-1">
<div class="font-medium">
${translateText("user_setting.performance_overlay_label")}
</div>
<div class="text-sm text-slate-400">
${translateText("user_setting.performance_overlay_desc")}
</div>
</div>
<div class="text-sm text-slate-400">
${this.userSettings.performanceOverlay()
? translateText("user_setting.on")
: translateText("user_setting.off")}
</div>
</button>
<div class="border-t border-slate-600 pt-3 mt-4">
<button
class="flex gap-3 items-center w-full text-left p-3 hover:bg-red-600/20 rounded-sm text-red-400 transition-colors"
@click="${this.onExitButtonClick}"
>
<img src=${exitIcon} alt="exitIcon" width="20" height="20" />
<div class="flex-1">
<div class="font-medium">
${translateText("user_setting.exit_game_label")}
</div>
<div class="text-sm text-slate-400">
${translateText("user_setting.exit_game_info")}
</div>
</div>
</button>
</div>
</div>
</div>
</div>
`;
}
}
+136
View File
@@ -0,0 +1,136 @@
import { LitElement, html } from "lit";
import { customElement } from "lit/decorators.js";
import { EventBus, GameEvent } from "../../../core/EventBus";
import { GameMode, GameType, Team } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import { Controller } from "../../Controller";
import { TransformHandler } from "../../TransformHandler";
export class SpawnBarVisibleEvent implements GameEvent {
constructor(public readonly visible: boolean) {}
}
@customElement("spawn-timer")
export class SpawnTimer extends LitElement implements Controller {
public game: GameView;
public eventBus: EventBus;
public transformHandler: TransformHandler;
private ratios = [0];
private _barVisible = false;
private colors = [
"rgb(from var(--color-malibu-blue) r g b / 0.7)",
"rgba(0, 0, 0, 0.5)",
];
private isVisible = false;
createRenderRoot() {
this.style.position = "fixed";
this.style.top = "0";
this.style.left = "0";
this.style.width = "100%";
this.style.height = "7px";
this.style.zIndex = "1000";
this.style.pointerEvents = "none";
return this;
}
init() {
this.isVisible = true;
}
tick() {
if (
this.game.config().gameConfig().gameType === GameType.Singleplayer &&
this.game.inSpawnPhase()
) {
// Singleplayer has no spawn countdown.
this.ratios = [];
this.colors = [];
this.requestUpdate();
return;
}
if (this.game.inSpawnPhase()) {
// During spawn phase, only one segment filling full width
this.ratios = [
this.game.ticks() / this.game.config().numSpawnPhaseTurns(),
];
this.colors = ["rgb(from var(--color-malibu-blue) r g b / 0.7)"];
} else {
this.ratios = [];
this.colors = [];
if (this.game.config().gameConfig().gameMode === GameMode.Team) {
const teamTiles: Map<Team, number> = new Map();
for (const player of this.game.players()) {
const team = player.team();
if (team === null) continue;
const tiles = teamTiles.get(team) ?? 0;
teamTiles.set(team, tiles + player.numTilesOwned());
}
const theme = this.game.config().theme();
const total = sumIterator(teamTiles.values());
if (total > 0) {
for (const [team, count] of teamTiles) {
const ratio = count / total;
this.ratios.push(ratio);
this.colors.push(theme.teamColor(team).toRgbString());
}
}
}
}
this.requestUpdate();
this.emitBarVisibility();
}
private emitBarVisibility() {
const nowVisible = this.isVisible && this.ratios.length > 0;
if (nowVisible !== this._barVisible) {
this._barVisible = nowVisible;
this.eventBus?.emit(new SpawnBarVisibleEvent(this._barVisible));
}
}
render() {
if (!this.isVisible) {
return html``;
}
if (this.ratios.length === 0 || this.colors.length === 0) {
return html``;
}
if (
!this.game.inSpawnPhase() &&
this.game.config().gameConfig().gameMode !== GameMode.Team
) {
return html``;
}
return html`
<div class="w-full h-full flex z-999">
${this.ratios.map((ratio, i) => {
const color = this.colors[i] || "rgba(0, 0, 0, 0.5)";
return html`
<div
class="h-full transition-all duration-100 ease-in-out w-(--width) bg-(--bg)"
style="--width: ${ratio * 100}%; --bg: ${color};"
></div>
`;
})}
</div>
`;
}
}
function sumIterator(values: MapIterator<number>) {
let total = 0;
for (const value of values) {
total += value;
}
return total;
}
+250
View File
@@ -0,0 +1,250 @@
import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { EventBus } from "../../../core/EventBus";
import { GameMode, Team, UnitType } from "../../../core/game/Game";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { Controller } from "../../Controller";
import {
formatPercentage,
renderNumber,
renderTroops,
translateText,
} from "../../Utils";
interface TeamEntry {
teamName: string;
isMyTeam: boolean;
totalScoreStr: string;
totalGold: string;
totalMaxTroops: string;
totalSAMs: string;
totalLaunchers: string;
totalWarShips: string;
totalCities: string;
totalScoreSort: number;
players: PlayerView[];
}
@customElement("team-stats")
export class TeamStats extends LitElement implements Controller {
public game: GameView;
public eventBus: EventBus;
@property({ type: Boolean }) visible = false;
teams: TeamEntry[] = [];
private _shownOnInit = false;
private showUnits = false;
private _myTeam: Team | null = null;
createRenderRoot() {
return this; // use light DOM for Tailwind
}
init() {}
getTickIntervalMs() {
return 1000;
}
tick() {
if (this.game.config().gameConfig().gameMode !== GameMode.Team) return;
if (!this._shownOnInit && !this.game.inSpawnPhase()) {
this._shownOnInit = true;
this.updateTeamStats();
}
if (!this.visible) return;
this.updateTeamStats();
}
private updateTeamStats() {
const players = this.game.playerViews();
const grouped: Record<Team, PlayerView[]> = {};
if (this._myTeam === null) {
const myPlayer = this.game.myPlayer();
this._myTeam = myPlayer?.team() ?? null;
}
for (const player of players) {
const rawTeam = player.team();
if (rawTeam === null) continue;
grouped[rawTeam] ??= [];
grouped[rawTeam].push(player);
}
this.teams = Object.entries(grouped)
.map(([rawTeam, teamPlayers]) => {
const key = `team_colors.${rawTeam.toLowerCase()}`;
const translated = translateText(key);
const teamName = translated !== key ? translated : rawTeam;
let totalGold = 0n;
let totalMaxTroops = 0;
let totalScoreSort = 0;
let totalSAMs = 0;
let totalLaunchers = 0;
let totalWarShips = 0;
let totalCities = 0;
for (const p of teamPlayers) {
if (p.isAlive()) {
totalMaxTroops += this.game.config().maxTroops(p);
totalGold += p.gold();
totalScoreSort += p.numTilesOwned();
totalLaunchers += p.totalUnitLevels(UnitType.MissileSilo);
totalSAMs += p.totalUnitLevels(UnitType.SAMLauncher);
totalWarShips += p.totalUnitLevels(UnitType.Warship);
totalCities += p.totalUnitLevels(UnitType.City);
}
}
const numTilesWithoutFallout =
this.game.numLandTiles() - this.game.numTilesWithFallout();
const totalScorePercent = totalScoreSort / numTilesWithoutFallout;
return {
teamName,
isMyTeam: rawTeam === this._myTeam,
totalScoreStr: formatPercentage(totalScorePercent),
totalScoreSort,
totalGold: renderNumber(totalGold),
totalMaxTroops: renderTroops(totalMaxTroops),
players: teamPlayers,
totalLaunchers: renderNumber(totalLaunchers),
totalSAMs: renderNumber(totalSAMs),
totalWarShips: renderNumber(totalWarShips),
totalCities: renderNumber(totalCities),
};
})
.sort((a, b) => b.totalScoreSort - a.totalScoreSort);
this.requestUpdate();
}
render() {
if (!this.visible) return html``;
return html`
<div
class="max-h-[30vh] overflow-x-hidden overflow-y-auto grid bg-slate-800/85 w-full text-white text-xs md:text-sm mt-2 rounded-lg"
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
>
<div
class="grid w-full grid-cols-[repeat(var(--cols),1fr)]"
style="--cols:${this.showUnits ? 5 : 4};"
>
<!-- Header -->
<div class="contents font-bold bg-slate-700/60">
<div class="p-1.5 md:p-2.5 text-center border-b border-slate-500">
${translateText("leaderboard.team")}
</div>
${this.showUnits
? html`
<div
class="p-1.5 md:p-2.5 text-center border-b border-slate-500"
>
${translateText("leaderboard.launchers")}
</div>
<div
class="p-1.5 md:p-2.5 text-center border-b border-slate-500"
>
${translateText("leaderboard.sams")}
</div>
<div
class="p-1.5 md:p-2.5 text-center border-b border-slate-500"
>
${translateText("leaderboard.warships")}
</div>
<div
class="p-1.5 md:p-2.5 text-center border-b border-slate-500"
>
${translateText("leaderboard.cities")}
</div>
`
: html`
<div
class="p-1.5 md:p-2.5 text-center border-b border-slate-500"
>
${translateText("leaderboard.owned")}
</div>
<div
class="p-1.5 md:p-2.5 text-center border-b border-slate-500"
>
${translateText("leaderboard.gold")}
</div>
<div
class="p-1.5 md:p-2.5 text-center border-b border-slate-500"
>
${translateText("leaderboard.maxtroops")}
</div>
`}
</div>
<!-- Data rows -->
${this.teams.map((team) =>
this.showUnits
? html`
<div
class="contents hover:bg-slate-600/60 text-center cursor-pointer ${team.isMyTeam
? "font-bold"
: ""}"
>
<div class="py-1.5 border-b border-slate-500">
${team.teamName}
</div>
<div class="py-1.5 border-b border-slate-500">
${team.totalLaunchers}
</div>
<div class="py-1.5 border-b border-slate-500">
${team.totalSAMs}
</div>
<div class="py-1.5 border-b border-slate-500">
${team.totalWarShips}
</div>
<div class="py-1.5 border-b border-slate-500">
${team.totalCities}
</div>
</div>
`
: html`
<div
class="contents hover:bg-slate-600/60 text-center cursor-pointer ${team.isMyTeam
? "font-bold"
: ""}"
>
<div class="py-1.5 border-b border-slate-500">
${team.teamName}
</div>
<div class="py-1.5 border-b border-slate-500">
${team.totalScoreStr}
</div>
<div class="py-1.5 border-b border-slate-500">
${team.totalGold}
</div>
<div class="py-1.5 border-b border-slate-500">
${team.totalMaxTroops}
</div>
</div>
`,
)}
</div>
<button
class="team-stats-button"
aria-pressed=${String(this.showUnits)}
@click=${() => {
this.showUnits = !this.showUnits;
this.requestUpdate();
}}
>
${this.showUnits
? translateText("leaderboard.show_control")
: translateText("leaderboard.show_units")}
</button>
</div>
`;
}
}
+307
View File
@@ -0,0 +1,307 @@
import { html, LitElement } from "lit";
import { customElement } from "lit/decorators.js";
import { assetUrl } from "../../../core/AssetUrls";
import { EventBus } from "../../../core/EventBus";
import {
BuildableUnit,
BuildMenus,
Gold,
PlayerBuildableUnitType,
UnitType,
} from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import { UserSettings } from "../../../core/game/UserSettings";
import { Controller } from "../../Controller";
import { ToggleStructureEvent } from "../../InputHandler";
import { UIState } from "../../UIState";
import { renderNumber, translateText } from "../../Utils";
const warshipIcon = assetUrl("images/BattleshipIconWhite.svg");
const cityIcon = assetUrl("images/CityIconWhite.svg");
const factoryIcon = assetUrl("images/FactoryIconWhite.svg");
const goldCoinIcon = assetUrl("images/GoldCoinIcon.svg");
const mirvIcon = assetUrl("images/MIRVIcon.svg");
const missileSiloIcon = assetUrl("images/MissileSiloIconWhite.svg");
const hydrogenBombIcon = assetUrl("images/MushroomCloudIconWhite.svg");
const atomBombIcon = assetUrl("images/NukeIconWhite.svg");
const portIcon = assetUrl("images/PortIcon.svg");
const samLauncherIcon = assetUrl("images/SamLauncherIconWhite.svg");
const defensePostIcon = assetUrl("images/ShieldIconWhite.svg");
@customElement("unit-display")
export class UnitDisplay extends LitElement implements Controller {
public game: GameView;
public eventBus: EventBus;
public uiState: UIState;
private playerBuildables: BuildableUnit[] | null = null;
private keybinds: Record<string, { value: string; key: string }> = {};
private _cities = 0;
private _warships = 0;
private _factories = 0;
private _missileSilo = 0;
private _port = 0;
private _defensePost = 0;
private _samLauncher = 0;
private allDisabled = false;
private _hoveredUnit: PlayerBuildableUnitType | null = null;
createRenderRoot() {
return this;
}
init() {
const config = this.game.config();
const userSettings = new UserSettings();
this.keybinds = userSettings.parsedUserKeybinds();
this.allDisabled = BuildMenus.types.every((u) => config.isUnitDisabled(u));
this.requestUpdate();
}
private cost(item: UnitType): Gold {
for (const bu of this.playerBuildables ?? []) {
if (bu.type === item) {
return bu.cost;
}
}
return 0n;
}
private canBuild(item: UnitType): boolean {
if (this.game?.config().isUnitDisabled(item)) return false;
const player = this.game?.myPlayer();
switch (item) {
case UnitType.AtomBomb:
case UnitType.HydrogenBomb:
case UnitType.MIRV:
return (
this.cost(item) <= (player?.gold() ?? 0n) &&
(player?.units(UnitType.MissileSilo).length ?? 0) > 0
);
case UnitType.Warship:
return (
this.cost(item) <= (player?.gold() ?? 0n) &&
(player?.units(UnitType.Port).length ?? 0) > 0
);
default:
return this.cost(item) <= (player?.gold() ?? 0n);
}
}
tick() {
const player = this.game?.myPlayer();
if (!player) return;
player.buildables(undefined, BuildMenus.types).then((buildables) => {
this.playerBuildables = buildables;
});
this._cities = player.totalUnitLevels(UnitType.City);
this._missileSilo = player.totalUnitLevels(UnitType.MissileSilo);
this._port = player.totalUnitLevels(UnitType.Port);
this._defensePost = player.totalUnitLevels(UnitType.DefensePost);
this._samLauncher = player.totalUnitLevels(UnitType.SAMLauncher);
this._factories = player.totalUnitLevels(UnitType.Factory);
this._warships = player.totalUnitLevels(UnitType.Warship);
this.requestUpdate();
}
render() {
const myPlayer = this.game?.myPlayer();
if (
!this.game ||
!myPlayer ||
this.game.inSpawnPhase() ||
!myPlayer.isAlive()
) {
return null;
}
if (this.allDisabled) {
return null;
}
return html`
<div class="border-t border-white/10 p-0.5 w-full">
<div
class="grid grid-rows-1 auto-cols-max grid-flow-col gap-0.5 w-fit mx-auto"
>
${this.renderUnitItem(
cityIcon,
this._cities,
UnitType.City,
"city",
this.keybinds["buildCity"]?.key ?? "1",
)}
${this.renderUnitItem(
factoryIcon,
this._factories,
UnitType.Factory,
"factory",
this.keybinds["buildFactory"]?.key ?? "2",
)}
${this.renderUnitItem(
portIcon,
this._port,
UnitType.Port,
"port",
this.keybinds["buildPort"]?.key ?? "3",
)}
${this.renderUnitItem(
defensePostIcon,
this._defensePost,
UnitType.DefensePost,
"defense_post",
this.keybinds["buildDefensePost"]?.key ?? "4",
)}
${this.renderUnitItem(
missileSiloIcon,
this._missileSilo,
UnitType.MissileSilo,
"missile_silo",
this.keybinds["buildMissileSilo"]?.key ?? "5",
)}
${this.renderUnitItem(
samLauncherIcon,
this._samLauncher,
UnitType.SAMLauncher,
"sam_launcher",
this.keybinds["buildSamLauncher"]?.key ?? "6",
)}
${this.renderUnitItem(
warshipIcon,
this._warships,
UnitType.Warship,
"warship",
this.keybinds["buildWarship"]?.key ?? "7",
)}
${this.renderUnitItem(
atomBombIcon,
null,
UnitType.AtomBomb,
"atom_bomb",
this.keybinds["buildAtomBomb"]?.key ?? "8",
)}
${this.renderUnitItem(
hydrogenBombIcon,
null,
UnitType.HydrogenBomb,
"hydrogen_bomb",
this.keybinds["buildHydrogenBomb"]?.key ?? "9",
)}
${this.renderUnitItem(
mirvIcon,
null,
UnitType.MIRV,
"mirv",
this.keybinds["buildMIRV"]?.key ?? "0",
)}
</div>
</div>
`;
}
private renderUnitItem(
icon: string,
number: number | null,
unitType: PlayerBuildableUnitType,
structureKey: string,
hotkey: string,
) {
if (this.game.config().isUnitDisabled(unitType)) {
return html``;
}
const selected = this.uiState.ghostStructure === unitType;
const hovered = this._hoveredUnit === unitType;
const displayHotkey = hotkey
.replace("Digit", "")
.replace("Key", "")
.toUpperCase();
return html`
<div
class="flex flex-col items-center relative"
@mouseenter=${() => {
this._hoveredUnit = unitType;
this.requestUpdate();
}}
@mouseleave=${() => {
this._hoveredUnit = null;
this.requestUpdate();
}}
>
${hovered
? html`
<div
class="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 text-gray-200 text-center w-max text-xs bg-gray-800/90 backdrop-blur-xs rounded-sm p-1 z-[100] shadow-lg pointer-events-none"
>
<div class="font-bold text-sm mb-1">
${translateText(
"unit_type." + structureKey,
)}${` [${displayHotkey}]`}
</div>
<div class="p-2">
${translateText("build_menu.desc." + structureKey)}
</div>
${unitType === UnitType.Warship
? html`<div
class="mt-1 px-2 py-1 text-[10px] text-cyan-300 border-t border-white/10"
>
${translateText("build_menu.warship_shift_hint")}
</div>`
: null}
<div class="flex items-center justify-center gap-1">
<img src=${goldCoinIcon} width="13" height="13" />
<span class="text-yellow-300"
>${renderNumber(this.cost(unitType))}</span
>
</div>
</div>
`
: null}
<div
class="${this.canBuild(unitType)
? ""
: "opacity-40"} border border-slate-500 rounded-sm px-0.5 pb-0.5 flex items-center gap-0.5 cursor-pointer
${selected ? "hover:bg-gray-400/10" : "hover:bg-gray-800"}
rounded-sm text-white ${selected ? "bg-slate-400/20" : ""}"
@click=${() => {
if (selected) {
this.uiState.ghostStructure = null;
} else if (this.canBuild(unitType)) {
this.uiState.ghostStructure = unitType;
}
this.requestUpdate();
}}
@mouseenter=${() => {
switch (unitType) {
case UnitType.AtomBomb:
case UnitType.HydrogenBomb:
this.eventBus?.emit(
new ToggleStructureEvent([
UnitType.MissileSilo,
UnitType.SAMLauncher,
]),
);
break;
case UnitType.Warship:
this.eventBus?.emit(new ToggleStructureEvent([UnitType.Port]));
break;
default:
this.eventBus?.emit(new ToggleStructureEvent([unitType]));
}
}}
@mouseleave=${() =>
this.eventBus?.emit(new ToggleStructureEvent(null))}
>
${html`<div class="ml-0.5 text-[10px] relative -top-1 text-gray-400">
${displayHotkey}
</div>`}
<div class="flex items-center gap-0.5 pt-0.5">
<img src=${icon} alt=${structureKey} class="align-middle size-5" />
${number !== null
? html`<span class="text-xs">${renderNumber(number)}</span>`
: null}
</div>
</div>
</div>
`;
}
}
+324
View File
@@ -0,0 +1,324 @@
import { html, LitElement, TemplateResult } from "lit";
import { customElement, state } from "lit/decorators.js";
import {
getGamesPlayed,
isInIframe,
translateText,
TUTORIAL_VIDEO_URL,
} from "../../../client/Utils";
import { EventBus } from "../../../core/EventBus";
import { RankedType } from "../../../core/game/Game";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView } from "../../../core/game/GameView";
import { getUserMe } from "../../Api";
import "../../components/CosmeticButton";
import { Controller } from "../../Controller";
import {
fetchCosmetics,
purchaseCosmetic,
resolveCosmetics,
} from "../../Cosmetics";
import { crazyGamesSDK } from "../../CrazyGamesSDK";
import { Platform } from "../../Platform";
import { SendWinnerEvent } from "../../Transport";
@customElement("win-modal")
export class WinModal extends LitElement implements Controller {
public game: GameView;
public eventBus: EventBus;
private hasShownDeathModal = false;
@state()
isVisible = false;
@state()
showButtons = false;
@state()
private isWin = false;
@state()
private isRankedGame = false;
@state()
private patternContent: TemplateResult | null = null;
private _title: string;
private rand = Math.random();
// Override to prevent shadow DOM creation
createRenderRoot() {
return this;
}
constructor() {
super();
}
render() {
return html`
<div
class="${this.isVisible
? "fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-gray-800/70 p-6 shrink-0 rounded-lg z-[10010] shadow-2xl backdrop-blur-xs text-white w-87.5 max-w-[90%] md:w-175"
: "hidden"}"
>
<h2 class="m-0 mb-4 text-[26px] text-center text-white">
${this._title || ""}
</h2>
${this.innerHtml()}
<div
class="${this.showButtons
? "flex justify-between gap-2.5"
: "hidden"}"
>
<o-button
variant="primary"
width="block"
class="flex-1"
translationKey="win_modal.exit"
@click=${this._handleExit}
></o-button>
${this.isRankedGame
? html`
<o-button
variant="primary"
width="block"
class="flex-1"
translationKey="win_modal.requeue"
@click=${this._handleRequeue}
></o-button>
`
: null}
<o-button
variant="primary"
width="block"
class="flex-1"
.title=${this.game?.myPlayer()?.isAlive()
? translateText("win_modal.keep")
: translateText("win_modal.spectate")}
@click=${this.hide}
></o-button>
</div>
</div>
`;
}
innerHtml() {
if (isInIframe()) {
return this.steamWishlist();
}
if (!this.isWin && getGamesPlayed() < 3) {
return this.renderYoutubeTutorial();
}
if (this.rand < 0.25) {
return this.steamWishlist();
} else if (this.rand < 0.5) {
return this.discordDisplay();
} else {
return this.renderPatternButton();
}
}
renderYoutubeTutorial() {
return html`
<div class="text-center mb-6 bg-black/30 p-2.5 rounded-sm">
<h3 class="text-xl font-semibold text-white mb-3">
${translateText("win_modal.youtube_tutorial")}
</h3>
<!-- 56.25% = 9:16 -->
<div class="relative w-full pb-[56.25%]">
<iframe
class="absolute top-0 left-0 w-full h-full rounded-sm"
src="${this.isVisible ? TUTORIAL_VIDEO_URL : ""}"
title="YouTube video player"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowfullscreen
></iframe>
</div>
</div>
`;
}
renderPatternButton() {
return html`
<div class="text-center mb-6 bg-black/30 p-2.5 rounded-sm">
<h3 class="text-xl font-semibold text-white mb-3">
${translateText("win_modal.support_openfront")}
</h3>
<p class="text-white mb-3">
${translateText("win_modal.territory_pattern")}
</p>
<div class="flex justify-center">${this.patternContent}</div>
</div>
`;
}
async loadPatternContent() {
const me = await getUserMe();
const cosmetics = await fetchCosmetics();
const purchasable = resolveCosmetics(cosmetics, me, null).filter(
(r) => r.type === "pattern" && r.relationship === "purchasable",
);
if (purchasable.length === 0) {
this.patternContent = html``;
return;
}
// Shuffle the array and take patterns based on screen size
const shuffled = [...purchasable].sort(() => Math.random() - 0.5);
const maxPatterns = Platform.isMobileWidth ? 1 : 3;
const selected = shuffled.slice(0, Math.min(maxPatterns, shuffled.length));
this.patternContent = html`
<div class="flex gap-4 flex-wrap justify-start">
${selected.map(
(r) => html`
<cosmetic-button
.resolved=${r}
.onPurchase=${purchaseCosmetic}
></cosmetic-button>
`,
)}
</div>
`;
}
steamWishlist(): TemplateResult {
return html`<p class="m-0 mb-5 text-center bg-black/30 p-2.5 rounded-sm">
<a
href="https://store.steampowered.com/app/3560670"
target="_blank"
rel="noopener noreferrer"
class="text-[#4a9eff] underline font-medium transition-colors duration-200 text-2xl hover:text-[#6db3ff]"
>
${translateText("win_modal.wishlist")}
</a>
</p>`;
}
discordDisplay(): TemplateResult {
return html`
<div class="text-center mb-6 bg-black/30 p-2.5 rounded-sm">
<h3 class="text-xl font-semibold text-white mb-3">
${translateText("win_modal.join_discord")}
</h3>
<p class="text-white mb-3">
${translateText("win_modal.discord_description")}
</p>
<a
href="https://discord.com/invite/openfront"
target="_blank"
rel="noopener noreferrer"
class="inline-block px-6 py-3 bg-indigo-600 text-white rounded-sm font-semibold transition-all duration-200 hover:bg-indigo-700 hover:-translate-y-px no-underline"
>
${translateText("win_modal.join_server")}
</a>
</div>
`;
}
async show() {
crazyGamesSDK.gameplayStop();
await this.loadPatternContent();
// Check if this is a ranked game
this.isRankedGame =
this.game.config().gameConfig().rankedType === RankedType.OneVOne;
this.isVisible = true;
this.requestUpdate();
setTimeout(() => {
this.showButtons = true;
this.requestUpdate();
}, 3000);
}
hide() {
this.isVisible = false;
this.showButtons = false;
this.requestUpdate();
}
private _handleExit() {
this.hide();
window.location.href = "/";
}
private _handleRequeue() {
this.hide();
// Navigate to homepage and open matchmaking modal
window.location.href = "/?requeue";
}
init() {}
tick() {
const myPlayer = this.game.myPlayer();
if (
!this.hasShownDeathModal &&
myPlayer &&
!myPlayer.isAlive() &&
!this.game.inSpawnPhase() &&
myPlayer.hasSpawned()
) {
this.hasShownDeathModal = true;
this._title = translateText("win_modal.died");
this.show();
}
const updates = this.game.updatesSinceLastTick();
const winUpdates = updates !== null ? updates[GameUpdateType.Win] : [];
winUpdates.forEach((wu) => {
if (wu.winner === undefined) {
// ...
} else if (wu.winner[0] === "team") {
this.eventBus.emit(new SendWinnerEvent(wu.winner, wu.allPlayersStats));
if (wu.winner[1] === this.game.myPlayer()?.team()) {
this._title = translateText("win_modal.your_team");
this.isWin = true;
crazyGamesSDK.happytime();
} else {
this._title = translateText("win_modal.other_team", {
team: wu.winner[1],
});
this.isWin = false;
}
history.replaceState(null, "", `${window.location.pathname}?replay`);
this.show();
} else if (wu.winner[0] === "nation") {
this._title = translateText("win_modal.nation_won", {
nation: wu.winner[1],
});
this.isWin = false;
this.show();
} else {
const winner = this.game.playerByClientID(wu.winner[1]);
if (!winner?.isPlayer()) return;
const winnerClient = winner.clientID();
if (winnerClient !== null) {
this.eventBus.emit(
new SendWinnerEvent(["player", winnerClient], wu.allPlayersStats),
);
}
if (
winnerClient !== null &&
winnerClient === this.game.myPlayer()?.clientID()
) {
this._title = translateText("win_modal.you_won");
this.isWin = true;
crazyGamesSDK.happytime();
} else {
this._title = translateText("win_modal.other_won", {
player: winner.displayName(),
});
this.isWin = false;
}
history.replaceState(null, "", `${window.location.pathname}?replay`);
this.show();
}
});
}
}