Merge branch 'v26'

This commit is contained in:
evanpelle
2025-10-23 15:02:46 -07:00
25 changed files with 589 additions and 457 deletions
+28
View File
@@ -0,0 +1,28 @@
import { GameView } from "../../../core/game/GameView";
import { Layer } from "./Layer";
const AD_SHOW_TICKS = 60 * 10; // 1 minute
export class AdTimer implements Layer {
private isHidden: boolean = false;
constructor(private g: GameView) {}
init() {}
public async tick() {
if (this.isHidden) {
return;
}
const gameTicks = this.g.ticks() - this.g.config().numSpawnPhaseTurns();
if (gameTicks > AD_SHOW_TICKS) {
console.log("destroying sticky ads");
window.fusetag?.que?.push(() => {
window.fusetag?.destroySticky?.();
});
this.isHidden = true;
return;
}
}
}
+22
View File
@@ -151,6 +151,14 @@ export class FxLayer implements Layer {
case UnitType.Train:
this.onTrainEvent(unit);
break;
case UnitType.DefensePost:
case UnitType.City:
case UnitType.Port:
case UnitType.MissileSilo:
case UnitType.SAMLauncher:
case UnitType.Factory:
this.onStructureEvent(unit);
break;
}
}
@@ -246,6 +254,20 @@ export class FxLayer implements Layer {
}
}
onStructureEvent(unit: UnitView) {
if (!unit.isActive()) {
const x = this.game.x(unit.lastTile());
const y = this.game.y(unit.lastTile());
const explosion = new SpriteFx(
this.animatedSpriteLoader,
x,
y,
FxType.BuildingExplosion,
);
this.allFx.push(explosion);
}
}
onNukeEvent(unit: UnitView, radius: number) {
if (!unit.isActive()) {
if (!unit.reachedTarget()) {
+128 -117
View File
@@ -193,6 +193,7 @@ export class PlayerPanel extends LitElement implements Layer {
private closeSend = () => {
this.sendTarget = null;
this.sendMode = "none";
};
private confirmSend = (
@@ -418,10 +419,15 @@ export class PlayerPanel extends LitElement implements Layer {
}}
/>`
: ""}
<h1 class="text-2xl font-bold tracking-[-0.01em] truncate text-zinc-50">
${other.name()}
</h1>
<div class="flex-1 min-w-0">
<h2
class="text-xl font-bold tracking-[-0.01em] text-zinc-50 truncate"
title=${other.name()}
>
${other.name()}
</h2>
</div>
${chip
? html`<span
class=${`inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs font-semibold ${chip.classes}`}
@@ -445,28 +451,28 @@ export class PlayerPanel extends LitElement implements Layer {
return html`
<div class="mb-1 flex justify-between gap-2">
<div
class="inline-flex items-center gap-1.5 rounded-full bg-white/[0.04] px-2.5 py-1
text-base font-semibold text-zinc-200"
class="inline-flex items-center gap-1.5 rounded-lg bg-white/[0.04] px-3 py-1.5
text-white w-[140px] min-w-[140px] flex-shrink-0"
>
<span class="mr-0.5">💰</span>
<span translate="no" class="inline-block w-[45px] text-right">
<span translate="no" class="tabular-nums w-[5ch]font-semibold">
${renderNumber(other.gold() || 0)}
</span>
<span class="opacity-95 whitespace-nowrap"
>${translateText("player_panel.gold")}</span
<span class="text-zinc-200 whitespace-nowrap">
${translateText("player_panel.gold")}</span
>
</div>
<div
class="inline-flex items-center gap-1.5 rounded-full bg-white/[0.04] px-2.5 py-1
text-base font-semibold text-zinc-200"
class="inline-flex items-center gap-1.5 rounded-lg bg-white/[0.04] px-3 py-1.5
text-white w-[140px] min-w-[140px] flex-shrink-0"
>
<span class="mr-0.5">🛡️</span>
<span translate="no" class="inline-block w-[45px] text-right">
<span translate="no" class="tabular-nums w-[5ch] font-semibold">
${renderTroops(other.troops() || 0)}
</span>
<span class="opacity-95 whitespace-nowrap"
>${translateText("player_panel.troops")}</span
<span class="text-zinc-200 whitespace-nowrap">
${translateText("player_panel.troops")}</span
>
</div>
</div>
@@ -476,32 +482,34 @@ export class PlayerPanel extends LitElement implements Layer {
private renderStats(other: PlayerView, my: PlayerView) {
return html`
<!-- Betrayals -->
<div class="grid grid-cols-[auto,1fr] gap-x-6 gap-y-2 text-base">
<div class="grid grid-cols-[auto,1fr] gap-x-6 gap-y-2">
<div
class="flex items-center gap-2 font-semibold text-zinc-300 leading-snug"
class="flex items-center gap-2 text-[15px] font-medium text-zinc-100 leading-snug"
>
<span aria-hidden="true">⚠️</span>
<span>${translateText("player_panel.betrayals")}</span>
</div>
<div class="text-right font-semibold text-zinc-200">
<div class="text-right text-[14px] font-semibold text-zinc-200">
${other.data.betrayals ?? 0}
</div>
</div>
<!-- Trading / Embargo -->
<div class="grid grid-cols-[auto,1fr] gap-x-6 gap-y-2 text-base">
<div class="grid grid-cols-[auto,1fr] gap-x-6 gap-y-2">
<div
class="flex items-center gap-2 font-semibold text-zinc-300 leading-snug"
class="flex items-center gap-2 text-[15px] font-medium text-zinc-100 leading-snug"
>
<span aria-hidden="true">⚓</span>
<span>${translateText("player_panel.trading")}</span>
</div>
<div class="flex items-center justify-end gap-2 font-semibold">
<div
class="flex items-center justify-end gap-2 text-[14px] font-semibold"
>
${other.hasEmbargoAgainst(my)
? html`<span class="text-[#f59e0b]"
? html`<span class="text-amber-400"
>${translateText("player_panel.stopped")}</span
>`
: html`<span class="text-emerald-400"
: html`<span class="text-blue-400"
>${translateText("player_panel.active")}</span
>`}
</div>
@@ -512,60 +520,57 @@ export class PlayerPanel extends LitElement implements Layer {
private renderAlliances(other: PlayerView) {
const allies = other.allies();
const nameCollator = new Intl.Collator(undefined, { sensitivity: "base" });
const alliesSorted = [...allies].sort((a, b) =>
nameCollator.compare(a.name(), b.name()),
);
return html`
<div class="text-base select-none">
<!-- Header -->
<div class="select-none">
<div class="flex items-center justify-between mb-2">
<div class="font-semibold text-zinc-300 text-base">
<div
id="alliances-title"
class="text-[15px] font-medium text-zinc-200"
>
${translateText("player_panel.alliances")}
</div>
<span
aria-label="Alliance count"
class="inline-flex items-center justify-center min-w-[20px] h-5 px-[6px] rounded-[10px]
text-[12px] text-zinc-100 bg-white/10 border border-white/20"
aria-labelledby="alliances-title"
class="inline-flex items-center justify-center min-w-[20px] h-5 px-[6px] rounded-[10px]
text-[12px] text-zinc-100 bg-white/10 border border-white/20"
>
${allies.length}
</span>
</div>
<div class="mt-1 rounded-lg border border-zinc-600 bg-zinc-800/80">
<div
class="max-h-[72px] overflow-y-auto p-2 text-zinc-200 text-[12.5px] leading-relaxed"
<div
class="rounded-lg bg-zinc-800/70 ring-1 ring-zinc-700/60 w-full min-w-0"
>
<ul
class="max-h-[120px] overflow-y-auto p-2
flex flex-wrap gap-1.5
scrollbar-thin scrollbar-thumb-zinc-600 hover:scrollbar-thumb-zinc-500 scrollbar-track-zinc-800"
role="list"
aria-label="Alliance list"
aria-labelledby="alliances-title"
translate="no"
>
${allies.length > 0
? allies.map((p) => {
const color = p.territoryColor().toHex();
return html`
<div
role="listitem"
class="grid grid-cols-[16px_1fr] items-center gap-2 w-full h-[30px]
px-2 rounded-lg border border-transparent text-left
hover:bg-[#141821] hover:border-white/30 transition-colors"
${alliesSorted.length === 0
? html`<li class="text-zinc-400 text-[14px] px-1">
${translateText("common.none")}
</li>`
: alliesSorted.map(
(p) =>
html`<li
class="max-w-full inline-flex items-center gap-1.5
rounded-md border border-white/10 bg-white/[0.05]
px-2.5 py-1 text-[14px] text-zinc-100
hover:bg-white/[0.08] active:scale-[0.99] transition"
title=${p.name()}
>
<span
class="inline-block w-3 h-3 rounded-full mr-2"
style="background-color: ${color}"
>
</span>
<span
class="truncate select-none pointer-events-none font-medium"
>
${p.name()}
</span>
</div>
`;
})
: html`
<div class="py-2 text-zinc-300">
${translateText("common.none")}
</div>
`}
</div>
<span class="truncate">${p.name()}</span>
</li>`,
)}
</ul>
</div>
</div>
`;
@@ -580,7 +585,7 @@ export class PlayerPanel extends LitElement implements Layer {
</div>
<div class="text-right font-semibold">
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-base font-bold ${this.getExpiryColorClass(
class="inline-flex items-center rounded-full px-2 py-0.5 text-[14px] font-bold ${this.getExpiryColorClass(
this.allianceExpirySeconds,
)}"
>${this.allianceExpiryText}</span
@@ -605,7 +610,7 @@ export class PlayerPanel extends LitElement implements Layer {
const canEmbargo = this.actions?.interaction?.canEmbargo;
return html`
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-2.5">
<div class="grid auto-cols-fr grid-flow-col gap-1">
${actionButton({
onClick: (e: MouseEvent) => this.handleChat(e, my, other),
@@ -657,6 +662,7 @@ export class PlayerPanel extends LitElement implements Layer {
})
: ""}
</div>
<ui-divider></ui-divider>
<div class="grid auto-cols-fr grid-flow-col gap-1">
${other !== my
@@ -754,80 +760,85 @@ export class PlayerPanel extends LitElement implements Layer {
<div
class="fixed inset-0 z-[1001] flex items-center justify-center overflow-auto
bg-black/15 backdrop-blur-sm backdrop-brightness-110 pointer-events-auto"
bg-black/15 backdrop-brightness-110 pointer-events-auto"
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
@wheel=${(e: MouseEvent) => e.stopPropagation()}
@click=${() => this.hide()}
>
<div
class="pointer-events-auto max-h-[90vh] overflow-y-auto min-w-[240px] w-auto px-4 py-2"
class="pointer-events-auto max-h-[90vh] min-w-[300px] max-w-[400px] px-4 py-2"
@click=${(e: MouseEvent) => e.stopPropagation()}
>
<div
class=${`relative mt-2 w-full bg-zinc-900/90 backdrop-blur-sm p-5 shadow-2xl rounded-xl text-zinc-200
${other.isTraitor() ? "traitor-ring" : "ring-1 ring-zinc-700"}`}
>
<!-- Close button -->
<button
@click=${this.handleClose}
class="absolute -top-3 -right-3 flex h-7 w-7 items-center justify-center
rounded-full bg-zinc-700 text-white shadow hover:bg-red-500 transition-colors"
aria-label=${translateText("common.close") || "Close"}
title=${translateText("common.close") || "Close"}
>
</button>
<div class="relative">
<div
class="flex flex-col gap-2 font-sans antialiased text-[14px] leading-relaxed"
class="absolute inset-2 -z-10 rounded-2xl bg-black/25 backdrop-blur-[2px]"
></div>
<div
class=${`relative w-full bg-zinc-900/95 p-6 rounded-2xl text-zinc-100 overflow-visible shadow-2xl shadow-black/50
${other.isTraitor() ? "traitor-ring" : "ring-1 ring-white/5"}`}
>
<!-- Identity (flag, name, type, traitor, relation) -->
<div class="mb-1">${this.renderIdentityRow(other, my)}</div>
<!-- Close button -->
<button
@click=${this.handleClose}
class="absolute -top-3 -right-3 flex h-7 w-7 items-center justify-center
rounded-full bg-zinc-700 text-white shadow hover:bg-red-500 transition-colors"
aria-label=${translateText("common.close") || "Close"}
title=${translateText("common.close") || "Close"}
>
</button>
${this.sendTarget
? html`
<send-resource-modal
.open=${this.sendMode !== "none"}
.mode=${this.sendMode}
.total=${this.sendMode === "troops"
? myTroopsNum
: myGoldNum}
.uiState=${this.uiState}
.myPlayer=${my}
.target=${this.sendTarget}
.gameView=${this.g}
.eventBus=${this.eventBus}
.format=${this.sendMode === "troops"
? renderTroops
: renderNumber}
@confirm=${this.confirmSend}
@close=${this.closeSend}
></send-resource-modal>
`
: ""}
<div
class="flex flex-col gap-2 font-sans antialiased text-[14.5px] leading-relaxed"
>
<!-- Identity (flag, name, type, traitor, relation) -->
<div class="mb-1">${this.renderIdentityRow(other, my)}</div>
<ui-divider></ui-divider>
${this.sendTarget
? html`
<send-resource-modal
.open=${this.sendMode !== "none"}
.mode=${this.sendMode}
.total=${this.sendMode === "troops"
? myTroopsNum
: myGoldNum}
.uiState=${this.uiState}
.myPlayer=${my}
.target=${this.sendTarget}
.gameView=${this.g}
.eventBus=${this.eventBus}
.format=${this.sendMode === "troops"
? renderTroops
: renderNumber}
@confirm=${this.confirmSend}
@close=${this.closeSend}
></send-resource-modal>
`
: ""}
<!-- Resources -->
${this.renderResources(other)}
<ui-divider></ui-divider>
<ui-divider></ui-divider>
<!-- Resources -->
${this.renderResources(other)}
<!-- Stats: betrayals / trading -->
${this.renderStats(other, my)}
<ui-divider></ui-divider>
<ui-divider></ui-divider>
<!-- Stats: betrayals / trading -->
${this.renderStats(other, my)}
<!-- Alliances list -->
${this.renderAlliances(other)}
<ui-divider></ui-divider>
<!-- Alliance time remaining -->
${this.renderAllianceExpiry()}
<!-- Alliances list -->
${this.renderAlliances(other)}
<ui-divider></ui-divider>
<!-- Alliance time remaining -->
${this.renderAllianceExpiry()}
<!-- Actions -->
${this.renderActions(my, other)}
<ui-divider class="mt-1"></ui-divider>
<!-- Actions -->
${this.renderActions(my, other)}
</div>
</div>
</div>
</div>
+10 -21
View File
@@ -244,19 +244,22 @@ export class SendResourceModal extends LitElement {
private renderHeader() {
const name = this.target?.name?.() ?? "";
return html`
<div class="mb-3 flex items-center justify-between">
<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
class="rounded-md px-2 text-2xl leading-none text-zinc-300 hover:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-white/30"
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 hover:bg-red-500 transition-colors focus-visible:ring-2 focus-visible:ring-white/30 focus:outline-none"
aria-label=${this.i18n.closeLabel()}
title=${this.i18n.closeLabel()}
>
×
</button>
</div>
`;
@@ -264,7 +267,6 @@ export class SendResourceModal extends LitElement {
private renderAvailable() {
const total = this.getTotalNumber();
const cap = this.getCapacityLeft();
return html`
<div class="mb-4 pb-3 border-b border-zinc-800">
@@ -277,21 +279,6 @@ export class SendResourceModal extends LitElement {
<span class="opacity-90">${this.i18n.availableChip()}</span>
<span class="font-mono tabular-nums">${this.format(total)}</span>
</span>
${cap !== null
? html`
<!-- Cap -->
<span
class="inline-flex items-center gap-1 rounded-full bg-amber-500/10 px-2 py-0.5 ring-1 ring-amber-400/40 text-amber-200"
title=${this.i18n.capTooltip()}
>
<span class="opacity-90">${this.i18n.cap()}</span>
<span class="font-mono tabular-nums"
>${this.format(cap)}</span
>
</span>
`
: html``}
</div>
</div>
`;
@@ -554,9 +541,11 @@ export class SendResourceModal extends LitElement {
const allowed = this.limitAmount(this.sendAmount);
return html`
<div class="fixed inset-0 z-[1100] flex items-center justify-center p-4">
<div
class="absolute inset-0 z-[1100] flex items-center justify-center p-4"
>
<div
class="absolute inset-0 bg-black/60 backdrop-blur-sm rounded-2xl"
class="absolute inset-0 bg-black/60 rounded-2xl"
@click=${() => this.closeModal()}
></div>
-135
View File
@@ -1,135 +0,0 @@
import { LitElement, css, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { translateText } from "../../../client/Utils";
import { GameView } from "../../../core/game/GameView";
import { getGamesPlayed } from "../../Utils";
import { Layer } from "./Layer";
const AD_TYPE = "bottom_rail";
const AD_CONTAINER_ID = "bottom-rail-ad-container";
@customElement("spawn-ad")
export class SpawnAd extends LitElement implements Layer {
public g: GameView;
@state()
private isVisible: boolean = false;
@state()
private adLoaded: boolean = false;
private gamesPlayed: number = 0;
// Override createRenderRoot to disable shadow DOM
createRenderRoot() {
return this;
}
static styles = css``;
constructor() {
super();
}
init() {
this.gamesPlayed = getGamesPlayed();
}
public show(): void {
this.isVisible = true;
this.loadAd();
this.requestUpdate();
}
public hide(): void {
// Destroy the ad when hiding
this.destroyAd();
this.isVisible = false;
this.adLoaded = false;
this.requestUpdate();
}
public async tick() {
if (
!this.isVisible &&
this.g.inSpawnPhase() &&
this.g.ticks() > 10 &&
this.gamesPlayed > 5
) {
console.log("not showing spawn ad");
// this.show();
}
if (this.isVisible && !this.g.inSpawnPhase()) {
console.log("hiding bottom left ad");
this.hide();
}
}
private loadAd(): void {
if (!window.ramp) {
console.warn("Playwire RAMP not available");
return;
}
if (this.adLoaded) {
console.log("Ad already loaded, skipping");
return;
}
try {
window.ramp.que.push(() => {
window.ramp.spaAddAds([
{
type: AD_TYPE,
selectorId: AD_CONTAINER_ID,
},
]);
this.adLoaded = true;
console.log("Playwire ad loaded:", AD_TYPE);
});
} catch (error) {
console.error("Failed to load Playwire ad:", error);
}
}
private destroyAd(): void {
if (!window.ramp || !this.adLoaded) {
return;
}
try {
window.ramp.que.push(() => {
window.ramp.destroyUnits("all");
console.log("Playwire spawn ad destroyed");
});
} catch (error) {
console.error("Failed to destroy Playwire ad:", error);
}
}
disconnectedCallback() {
super.disconnectedCallback();
// Clean up ad when component is removed
this.destroyAd();
}
render() {
if (!this.isVisible) {
return html``;
}
return html`
<div
class="fixed bottom-0 left-0 w-full min-h-[100px] bg-gray-900 border border-gray-600 flex items-center justify-center z-50"
>
<div
id="${AD_CONTAINER_ID}"
class="w-full h-full flex items-center justify-center"
>
${!this.adLoaded
? html`<span class="text-white text-sm"
>${translateText("spawn_ad.loading")}</span
>`
: ""}
</div>
</div>
`;
}
}
+50 -24
View File
@@ -1,18 +1,34 @@
import { LitElement, html } from "lit";
import { customElement } from "lit/decorators.js";
import { GameMode, Team } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
export class SpawnTimer implements Layer {
@customElement("spawn-timer")
export class SpawnTimer extends LitElement implements Layer {
public game: GameView;
public transformHandler: TransformHandler;
private ratios = [0];
private colors = ["rgba(0, 128, 255, 0.7)", "rgba(0, 0, 0, 0.5)"];
constructor(
private game: GameView,
private transformHandler: TransformHandler,
) {}
private isVisible = false;
init() {}
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.inSpawnPhase()) {
@@ -21,6 +37,7 @@ export class SpawnTimer implements Layer {
this.game.ticks() / this.game.config().numSpawnPhaseTurns(),
];
this.colors = ["rgba(0, 128, 255, 0.7)"];
this.requestUpdate();
return;
}
@@ -28,6 +45,7 @@ export class SpawnTimer implements Layer {
this.colors = [];
if (this.game.config().gameConfig().gameMode !== GameMode.Team) {
this.requestUpdate();
return;
}
@@ -41,44 +59,52 @@ export class SpawnTimer implements Layer {
const theme = this.game.config().theme();
const total = sumIterator(teamTiles.values());
if (total === 0) return;
if (total === 0) {
this.requestUpdate();
return;
}
for (const [team, count] of teamTiles) {
const ratio = count / total;
this.ratios.push(ratio);
this.colors.push(theme.teamColor(team).toRgbString());
}
this.requestUpdate();
}
shouldTransform(): boolean {
return false;
}
renderLayer(context: CanvasRenderingContext2D) {
if (this.ratios.length === 0 || this.colors.length === 0) return;
render() {
if (!this.isVisible) {
return html``;
}
const barHeight = 10;
const barWidth = this.transformHandler.width();
if (this.ratios.length === 0 || this.colors.length === 0) {
return html``;
}
if (
!this.game.inSpawnPhase() &&
this.game.config().gameConfig().gameMode !== GameMode.Team
) {
return;
return html``;
}
let x = 0;
let filledRatio = 0;
for (let i = 0; i < this.ratios.length && i < this.colors.length; i++) {
const ratio = this.ratios[i] ?? 1 - filledRatio;
const segmentWidth = barWidth * ratio;
context.fillStyle = this.colors[i];
context.fillRect(x, 0, segmentWidth, barHeight);
x += segmentWidth;
filledRatio += ratio;
}
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"
style="width: ${ratio * 100}%; background-color: ${color};"
></div>
`;
})}
</div>
`;
}
}
+1
View File
@@ -221,5 +221,6 @@ export class TeamStats extends LitElement implements Layer {
function formatPercentage(value: number): string {
const perc = value * 100;
if (Number.isNaN(perc)) return "0%";
if (perc === 100) return "100%";
return perc.toPrecision(2) + "%";
}
+1 -6
View File
@@ -132,12 +132,7 @@ export class WinModal extends LitElement implements Layer {
for (const pattern of Object.values(patterns?.patterns ?? {})) {
for (const colorPalette of pattern.colorPalettes ?? []) {
if (
patternRelationship(
pattern,
colorPalette,
me !== false ? me : null,
null,
) === "purchasable"
patternRelationship(pattern, colorPalette, me, null) === "purchasable"
) {
const palette = patterns?.colorPalettes?.[colorPalette.name];
if (palette) {