This commit is contained in:
scamiv
2026-02-07 23:53:29 +01:00
parent 8cc6c2c2aa
commit cac6ac9e1f
3 changed files with 384 additions and 5 deletions
+2
View File
@@ -11,6 +11,7 @@ import { BuildMenu } from "./layers/BuildMenu";
import { ChatDisplay } from "./layers/ChatDisplay";
import { ChatModal } from "./layers/ChatModal";
import { ControlPanel } from "./layers/ControlPanel";
import { DonationLayer } from "./layers/DonationLayer";
import { DynamicUILayer } from "./layers/DynamicUILayer";
import { EmojiTable } from "./layers/EmojiTable";
import { EventsDisplay } from "./layers/EventsDisplay";
@@ -274,6 +275,7 @@ export function createRenderer(
new NukeTrajectoryPreviewLayer(game, eventBus, transformHandler, uiState),
new StructureIconsLayer(game, eventBus, uiState, transformHandler),
new DynamicUILayer(game, transformHandler, eventBus),
new DonationLayer(game, transformHandler),
new NameLayer(game, transformHandler, eventBus),
eventsDisplay,
chatDisplay,
+342
View File
@@ -0,0 +1,342 @@
import { Cell, MessageType } from "../../../core/game/Game";
import {
DisplayMessageUpdate,
GameUpdateType,
} from "../../../core/game/GameUpdates";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { renderNumber, renderTroops } from "../../Utils";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
import donateGoldIcon from "/images/DonateGoldIconWhite.svg?url";
import donateTroopIcon from "/images/DonateTroopIconWhite.svg?url";
type DonationKind = "troops" | "gold";
type DonationEdge = {
senderSmallID: number;
recipientSmallID: number;
lastDonationTick: number;
lastDonationRatio: number; // 0..1
lastKind: DonationKind;
lastAmountLabel: string;
totalTroopsSent: number;
};
const clamp01 = (n: number): number => Math.max(0, Math.min(1, n));
export class DonationLayer implements Layer {
private edges = new Map<string, DonationEdge>();
private troopIconImage: HTMLImageElement | null = null;
private goldIconImage: HTMLImageElement | null = null;
constructor(
private game: GameView,
private transformHandler: TransformHandler,
) {}
shouldTransform(): boolean {
return false;
}
init() {
this.troopIconImage = new Image();
this.troopIconImage.src = donateTroopIcon;
this.goldIconImage = new Image();
this.goldIconImage.src = donateGoldIcon;
}
tick() {
const updates = this.game.updatesSinceLastTick();
if (!updates) return;
const tick = this.game.ticks();
for (const update of updates[GameUpdateType.DisplayEvent]) {
this.onDisplayMessage(update, tick);
}
}
renderLayer(context: CanvasRenderingContext2D) {
const fadeTicks = Math.max(1, this.game.config().donateCooldown());
const nowTick = this.game.ticks();
const drawable: DonationEdge[] = [];
for (const [key, edge] of this.edges.entries()) {
const age = nowTick - edge.lastDonationTick;
if (age >= fadeTicks) {
this.edges.delete(key);
continue;
}
drawable.push(edge);
}
if (drawable.length === 0) return;
const byPair = new Map<string, DonationEdge[]>();
for (const edge of drawable) {
const a = Math.min(edge.senderSmallID, edge.recipientSmallID);
const b = Math.max(edge.senderSmallID, edge.recipientSmallID);
const pairKey = `${a}-${b}`;
const list = byPair.get(pairKey) ?? [];
list.push(edge);
byPair.set(pairKey, list);
}
for (const [pairKey, edges] of byPair) {
if (edges.length === 1) {
this.drawEdge(context, edges[0], 0);
continue;
}
// Two arrows between the same players: offset them on opposite sides.
// If more somehow exist, just render them without offset after the first two.
const [minStr] = pairKey.split("-");
const minId = Number(minStr);
const offsetPx = 7;
for (let i = 0; i < edges.length; i++) {
const edge = edges[i];
const sign = edge.senderSmallID === minId ? 1 : -1;
const signedOffset = i < 2 ? sign * offsetPx : 0;
this.drawEdge(context, edge, signedOffset);
}
}
}
private onDisplayMessage(event: DisplayMessageUpdate, tick: number) {
const isDonation =
event.messageType === MessageType.SENT_TROOPS_TO_PLAYER ||
event.messageType === MessageType.RECEIVED_TROOPS_FROM_PLAYER ||
event.messageType === MessageType.SENT_GOLD_TO_PLAYER ||
event.messageType === MessageType.RECEIVED_GOLD_FROM_PLAYER;
if (!isDonation) return;
const params = event.params;
if (!params) return;
const senderSmallID = Number(params.donationSenderSmallID);
const recipientSmallID = Number(params.donationRecipientSmallID);
if (!Number.isFinite(senderSmallID) || !Number.isFinite(recipientSmallID)) {
return;
}
if (senderSmallID <= 0 || recipientSmallID <= 0) return;
const kind = params.donationKind === "gold" ? "gold" : "troops";
const ratio = clamp01(Number(params.donationRatio ?? 0));
let amountLabel = "";
if (kind === "troops") {
const troopsLabel = params.troops;
const troopsAmount = Number(params.donationTroopsAmount ?? 0);
amountLabel =
typeof troopsLabel === "string"
? troopsLabel
: renderTroops(troopsAmount);
} else {
const goldLabel = params.gold;
const goldAmount = event.goldAmount ?? 0n;
amountLabel =
typeof goldLabel === "string" ? goldLabel : renderNumber(goldAmount);
}
const key = this.key(senderSmallID, recipientSmallID);
const prev = this.edges.get(key);
const totalTroopsSent =
kind === "troops"
? (prev?.totalTroopsSent ?? 0) +
Number(params.donationTroopsAmount ?? 0)
: (prev?.totalTroopsSent ?? 0);
this.edges.set(key, {
senderSmallID,
recipientSmallID,
lastDonationTick: tick,
lastDonationRatio: ratio,
lastKind: kind,
lastAmountLabel: amountLabel,
totalTroopsSent,
});
}
private drawEdge(
ctx: CanvasRenderingContext2D,
edge: DonationEdge,
perpendicularOffsetPx: number,
) {
const fadeTicks = Math.max(1, this.game.config().donateCooldown());
const ageTicks = this.game.ticks() - edge.lastDonationTick;
const alpha = 1 - ageTicks / fadeTicks;
if (alpha <= 0) return;
let sender: PlayerView;
let recipient: PlayerView;
try {
// Keep this in a try/catch: playerBySmallID can throw during early ticks.
const senderAny = this.game.playerBySmallID(edge.senderSmallID);
const recipientAny = this.game.playerBySmallID(edge.recipientSmallID);
if (
!(senderAny instanceof PlayerView) ||
!(recipientAny instanceof PlayerView)
) {
return;
}
sender = senderAny;
recipient = recipientAny;
} catch {
return;
}
const senderLoc = sender.nameLocation();
const recipientLoc = recipient.nameLocation();
if (!senderLoc || !recipientLoc) return;
const senderPos = this.worldToCanvas(senderLoc.x, senderLoc.y);
const recipientPos = this.worldToCanvas(recipientLoc.x, recipientLoc.y);
if (!senderPos || !recipientPos) return;
const dx0 = recipientPos.x - senderPos.x;
const dy0 = recipientPos.y - senderPos.y;
const dist0 = Math.hypot(dx0, dy0);
if (!Number.isFinite(dist0) || dist0 < 30) return;
const invDist0 = 1 / dist0;
const dir = { x: dx0 * invDist0, y: dy0 * invDist0 };
const perp = { x: -dir.y, y: dir.x };
const from = {
x: senderPos.x + perp.x * perpendicularOffsetPx,
y: senderPos.y + perp.y * perpendicularOffsetPx,
};
const to = {
x: recipientPos.x + perp.x * perpendicularOffsetPx,
y: recipientPos.y + perp.y * perpendicularOffsetPx,
};
const ratio = clamp01(edge.lastDonationRatio);
const width = Math.max(0, Math.min(10, ratio * 10));
if (width <= 0.1) return;
const stroke = sender.territoryColor().toHex();
const tipInset = 18;
const startInset = 14;
const headLen = Math.max(10, 8 + width * 1.4);
const headWidth = Math.max(10, 8 + width * 1.8);
const tip = { x: to.x - dir.x * tipInset, y: to.y - dir.y * tipInset };
const lineStart = {
x: from.x + dir.x * startInset,
y: from.y + dir.y * startInset,
};
const base = { x: tip.x - dir.x * headLen, y: tip.y - dir.y * headLen };
ctx.save();
ctx.globalAlpha = ctx.globalAlpha * alpha;
ctx.strokeStyle = stroke;
ctx.fillStyle = stroke;
ctx.lineWidth = width;
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.beginPath();
ctx.moveTo(lineStart.x, lineStart.y);
ctx.lineTo(base.x, base.y);
ctx.stroke();
const left = {
x: base.x + perp.x * (headWidth / 2),
y: base.y + perp.y * (headWidth / 2),
};
const right = {
x: base.x - perp.x * (headWidth / 2),
y: base.y - perp.y * (headWidth / 2),
};
ctx.beginPath();
ctx.moveTo(tip.x, tip.y);
ctx.lineTo(left.x, left.y);
ctx.lineTo(right.x, right.y);
ctx.closePath();
ctx.fill();
this.drawDonationBadge(ctx, edge, {
x: (lineStart.x + base.x) / 2,
y: (lineStart.y + base.y) / 2,
});
ctx.restore();
}
private drawDonationBadge(
ctx: CanvasRenderingContext2D,
edge: DonationEdge,
pos: { x: number; y: number },
) {
const iconSize = 14;
const paddingX = 6;
const paddingY = 4;
const gap = 5;
const fontSize = 12;
const fontFamily = this.game.config().theme().font();
ctx.font = `600 ${fontSize}px ${fontFamily}`;
ctx.textBaseline = "middle";
const text = edge.lastAmountLabel;
const textWidth = Math.ceil(ctx.measureText(text).width);
const height = iconSize + paddingY * 2;
const width = paddingX * 2 + iconSize + gap + textWidth;
const x = pos.x - width / 2;
const y = pos.y - height / 2;
ctx.save();
ctx.fillStyle = "rgba(0, 0, 0, 0.55)";
ctx.strokeStyle = "rgba(255, 255, 255, 0.18)";
ctx.lineWidth = 1;
this.roundedRect(ctx, x, y, width, height, height / 2);
ctx.fill();
ctx.stroke();
const icon =
edge.lastKind === "gold" ? this.goldIconImage : this.troopIconImage;
if (icon && icon.complete && icon.naturalWidth > 0) {
ctx.drawImage(icon, x + paddingX, y + paddingY, iconSize, iconSize);
}
ctx.fillStyle = "rgba(255, 255, 255, 0.92)";
ctx.fillText(text, x + paddingX + iconSize + gap, y + height / 2);
ctx.restore();
}
private roundedRect(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
w: number,
h: number,
r: number,
) {
const radius = Math.max(0, Math.min(r, Math.min(w, h) / 2));
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.arcTo(x + w, y, x + w, y + h, radius);
ctx.arcTo(x + w, y + h, x, y + h, radius);
ctx.arcTo(x, y + h, x, y, radius);
ctx.arcTo(x, y, x + w, y, radius);
ctx.closePath();
}
private worldToCanvas(x: number, y: number): { x: number; y: number } | null {
const rect = this.transformHandler.boundingRect();
if (!rect) return null;
const screen = this.transformHandler.worldToScreenCoordinates(
new Cell(x, y),
);
return { x: screen.x - rect.left, y: screen.y - rect.top };
}
private key(senderSmallID: number, recipientSmallID: number): string {
return `${senderSmallID}->${recipientSmallID}`;
}
}
+40 -5
View File
@@ -671,6 +671,8 @@ export class PlayerImpl implements Player {
if (troops <= 0) return false;
const removed = this.removeTroops(troops);
if (removed === 0) return false;
const prevTroops = removed + this.troops();
const donationRatio = prevTroops > 0 ? removed / prevTroops : 0;
recipient.addTroops(removed);
this.sentDonations.push(new Donation(recipient, this.mg.ticks()));
@@ -679,14 +681,30 @@ export class PlayerImpl implements Player {
MessageType.SENT_TROOPS_TO_PLAYER,
this.id(),
undefined,
{ troops: renderTroops(troops), name: recipient.name() },
{
troops: renderTroops(removed),
name: recipient.name(),
donationSenderSmallID: this.smallID(),
donationRecipientSmallID: recipient.smallID(),
donationKind: "troops",
donationRatio,
donationTroopsAmount: removed,
},
);
this.mg.displayMessage(
"events_display.received_troops_from_player",
MessageType.RECEIVED_TROOPS_FROM_PLAYER,
recipient.id(),
undefined,
{ troops: renderTroops(troops), name: this.name() },
{
troops: renderTroops(removed),
name: this.name(),
donationSenderSmallID: this.smallID(),
donationRecipientSmallID: recipient.smallID(),
donationKind: "troops",
donationRatio,
donationTroopsAmount: removed,
},
);
return true;
}
@@ -695,6 +713,9 @@ export class PlayerImpl implements Player {
if (gold <= 0n) return false;
const removed = this.removeGold(gold);
if (removed === 0n) return false;
const prevGold = removed + this.gold();
const donationRatio =
prevGold > 0n ? Number((removed * 10000n) / prevGold) / 10000 : 0;
recipient.addGold(removed);
this.sentDonations.push(new Donation(recipient, this.mg.ticks()));
@@ -703,14 +724,28 @@ export class PlayerImpl implements Player {
MessageType.SENT_GOLD_TO_PLAYER,
this.id(),
undefined,
{ gold: renderNumber(gold), name: recipient.name() },
{
gold: renderNumber(removed),
name: recipient.name(),
donationSenderSmallID: this.smallID(),
donationRecipientSmallID: recipient.smallID(),
donationKind: "gold",
donationRatio,
},
);
this.mg.displayMessage(
"events_display.received_gold_from_player",
MessageType.RECEIVED_GOLD_FROM_PLAYER,
recipient.id(),
gold,
{ gold: renderNumber(gold), name: this.name() },
removed,
{
gold: renderNumber(removed),
name: this.name(),
donationSenderSmallID: this.smallID(),
donationRecipientSmallID: recipient.smallID(),
donationKind: "gold",
donationRatio,
},
);
return true;
}