mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-08-04 21:37:11 +00:00
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:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) + "...";
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)"
|
||||
>
|
||||
${getTranslatedPlayerTeamLabel(this.playerTeam)}
|
||||
⦿
|
||||
</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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
`
|
||||
: ""}
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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>`;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user