diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index 0a0e8b5cf..6d3206418 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -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, diff --git a/src/client/graphics/layers/DonationLayer.ts b/src/client/graphics/layers/DonationLayer.ts new file mode 100644 index 000000000..a6ef49ace --- /dev/null +++ b/src/client/graphics/layers/DonationLayer.ts @@ -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(); + 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(); + 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}`; + } +} diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index 64cdf70c8..cdb0a5e43 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -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; }