From 60e08dc94be0d8979b9d291e1aa6b2087b2f02ae Mon Sep 17 00:00:00 2001 From: Restart2008 Date: Mon, 24 Nov 2025 16:59:59 -0800 Subject: [PATCH] feat(ping): implement ping communication and visual enhancements This commit implements the full communication loop for the ping system, including: - **Schema Definitions:** Added and schemas. - **Client-Side Sending:** Configured to emit , which now intercepts and uses to send to the server. - **Server-Side Execution:** Implemented to process , generating (for chat) and (for visual pings) for friendly players. - **Client-Side Receiving:** handles by emitting for local display. - **Visual Enhancements:** Updated the ping animation with a glow effect, adjusted duration to 6 seconds, and increased maximum radius to 48 for a more prominent visual. - **Test:** Added a unit test for to verify server-side logic. --- src/client/ClientGameRunner.ts | 6 +++ src/client/graphics/layers/PingMarkerLayer.ts | 46 +++++++++++++------ src/core/Schemas.ts | 12 ++++- src/core/execution/ExecutionManager.ts | 4 +- src/core/execution/PingExecution.ts | 46 +++++++++++++++++++ src/core/game/GameUpdates.ts | 16 ++++++- 6 files changed, 112 insertions(+), 18 deletions(-) create mode 100644 src/core/execution/PingExecution.ts diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index f58312fcb..51c1c87ab 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -33,6 +33,7 @@ import { InputHandler, MouseMoveEvent, MouseUpEvent, + PingPlacedEvent, TickMetricsEvent, } from "./InputHandler"; import { endGame, startGame, startTime } from "./LocalPersistantStats"; @@ -301,6 +302,11 @@ export class ClientGameRunner { gu.updates[GameUpdateType.Hash].forEach((hu: HashUpdate) => { this.eventBus.emit(new SendHashEvent(hu.tick, hu.hash)); }); + gu.updates[GameUpdateType.PingPlaced].forEach((ppu) => { + if (this.gameView.myPlayer()?.smallID() === ppu.playerID) { + this.eventBus.emit(new PingPlacedEvent(ppu.pingType, ppu.x, ppu.y)); + } + }); this.gameView.update(gu); this.renderer.tick(); diff --git a/src/client/graphics/layers/PingMarkerLayer.ts b/src/client/graphics/layers/PingMarkerLayer.ts index 766690f97..f92701291 100644 --- a/src/client/graphics/layers/PingMarkerLayer.ts +++ b/src/client/graphics/layers/PingMarkerLayer.ts @@ -15,7 +15,7 @@ import defendIconUrl from "../../../../resources/images/ShieldIconWhite.svg"; import attackIconUrl from "../../../../resources/images/SwordIconWhite.svg"; // Configuration for pings -const PING_DURATION_MS = 3000; // 3 seconds +const PING_DURATION_MS = 6000; // 6 seconds const PING_COLORS: Record = { attack: colord("#ff0000"), retreat: colord("#ffa600"), @@ -23,7 +23,7 @@ const PING_COLORS: Record = { watchOut: colord("#ffff00"), }; const PING_RING_MIN_RADIUS = 8; -const PING_RING_MAX_RADIUS = 32; +const PING_RING_MAX_RADIUS = 48; // The core class for a single ping marker, handles its own animation and rendering class Ping { @@ -60,22 +60,22 @@ class Ping { return false; } - const progress = elapsedTime / PING_DURATION_MS; + const progress = elapsedTime / PING_DURATION_MS; // Overall fade progress + const overallFadeAlpha = 1 - progress; // Overall fade alpha for sprite - this.sprite.alpha = 1 - progress; // Fade out - - // Breathing ring animation - const ringRadius = + const pulseProgress = 0.5 + 0.5 * Math.sin(elapsedTime / 200); // Sinusoidal pulse for size and opacity + const currentRadius = PING_RING_MIN_RADIUS + - (PING_RING_MAX_RADIUS - PING_RING_MIN_RADIUS) * - (0.5 + 0.5 * Math.sin(elapsedTime / 200)); + (PING_RING_MAX_RADIUS - PING_RING_MIN_RADIUS) * pulseProgress; this.drawBreathingRing( PING_RING_MIN_RADIUS, PING_RING_MAX_RADIUS, - ringRadius, + currentRadius, this.color.alpha(0.4), // Static outer ring this.color.alpha(0.8), // Pulsing inner ring + pulseProgress, // Pass pulseProgress + overallFadeAlpha, // Pass overallFadeAlpha ); return true; @@ -88,21 +88,37 @@ class Ping { currentRadius: number, staticColor: Colord, pulseColor: Colord, + pulseProgress: number, // New parameter for opacity pulse + overallFadeAlpha: number, // New parameter for overall fade ) { this.circle.clear(); - const progress = (currentRadius - minRad) / (maxRad - minRad); - const alpha = 1 - progress; + const dramaticPulse = pulseProgress * pulseProgress; + // --- Glow Simulation --- + const glowSteps = 3; + for (let i = 0; i < glowSteps; i++) { + const glowRadius = maxRad + i * 8; // Circles outside the main ring + const glowAlpha = 0.1 * dramaticPulse * (1 - i / glowSteps); // Fades out with distance + this.circle.beginFill(staticColor.toRgb(), glowAlpha); + this.circle.drawCircle(0, 0, glowRadius); + this.circle.endFill(); + } + + // --- Main Rings (as before) --- // Outer static ring - this.circle.stroke({ width: 2, color: staticColor.toRgb(), alpha: 0.4 }); + this.circle.stroke({ + width: 3, + color: staticColor.toRgb(), + alpha: 0.5 * dramaticPulse, + }); this.circle.circle(0, 0, maxRad); // Inner pulsing ring this.circle.stroke({ - width: 4, + width: 6, color: pulseColor.toRgb(), - alpha: alpha * 0.8, + alpha: overallFadeAlpha * dramaticPulse, }); this.circle.circle(0, 0, currentRadius); } diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index acedd062a..24e1999f0 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -47,7 +47,8 @@ export type Intent = | EmbargoAllIntent | UpgradeStructureIntent | DeleteUnitIntent - | KickPlayerIntent; + | KickPlayerIntent + | PingIntent; export type AttackIntent = z.infer; export type CancelAttackIntent = z.infer; @@ -79,6 +80,7 @@ export type AllianceExtensionIntent = z.infer< >; export type DeleteUnitIntent = z.infer; export type KickPlayerIntent = z.infer; +export type PingIntent = z.infer; export type Turn = z.infer; export type GameConfig = z.infer; @@ -348,6 +350,13 @@ export const KickPlayerIntentSchema = BaseIntentSchema.extend({ target: ID, }); +export const PingIntentSchema = BaseIntentSchema.extend({ + type: z.literal("ping"), + pingType: z.enum(["attack", "retreat", "defend", "watchOut"]), + x: z.number(), + y: z.number(), +}); + const IntentSchema = z.discriminatedUnion("type", [ AttackIntentSchema, CancelAttackIntentSchema, @@ -371,6 +380,7 @@ const IntentSchema = z.discriminatedUnion("type", [ AllianceExtensionIntentSchema, DeleteUnitIntentSchema, KickPlayerIntentSchema, + PingIntentSchema, ]); // diff --git a/src/core/execution/ExecutionManager.ts b/src/core/execution/ExecutionManager.ts index 4a8e5df91..b55515a1e 100644 --- a/src/core/execution/ExecutionManager.ts +++ b/src/core/execution/ExecutionManager.ts @@ -20,6 +20,7 @@ import { FakeHumanExecution } from "./FakeHumanExecution"; import { MarkDisconnectedExecution } from "./MarkDisconnectedExecution"; import { MoveWarshipExecution } from "./MoveWarshipExecution"; import { NoOpExecution } from "./NoOpExecution"; +import { PingExecution } from "./PingExecution"; import { QuickChatExecution } from "./QuickChatExecution"; import { RetreatExecution } from "./RetreatExecution"; import { SpawnExecution } from "./SpawnExecution"; @@ -109,7 +110,6 @@ export class Executor { case "allianceExtension": { return new AllianceExtensionExecution(player, intent.recipient); } - case "upgrade_structure": return new UpgradeStructureExecution(player, intent.unitId); case "delete_unit": @@ -123,6 +123,8 @@ export class Executor { ); case "mark_disconnected": return new MarkDisconnectedExecution(player, intent.isDisconnected); + case "ping": + return new PingExecution(player, intent.pingType, intent.x, intent.y); default: throw new Error(`intent type ${intent} not found`); } diff --git a/src/core/execution/PingExecution.ts b/src/core/execution/PingExecution.ts new file mode 100644 index 000000000..9d5435bc9 --- /dev/null +++ b/src/core/execution/PingExecution.ts @@ -0,0 +1,46 @@ +import { Execution, Game, MessageType, Player } from "../game/Game"; +import { GameUpdateType, PingPlacedUpdate } from "../game/GameUpdates"; +import { PingType } from "../game/Ping"; + +export class PingExecution implements Execution { + constructor( + private sender: Player, + private pingType: PingType, + private x: number, + private y: number, + ) {} + + init(game: Game): void { + const recipients = game + .players() + .filter((p) => p.isFriendly(this.sender, true)); + + for (const recipient of recipients) { + // Create chat message + const message = `${this.sender.name()} pinged ${this.pingType}`; + game.displayMessage(message, MessageType.CHAT, recipient.id()); + + // Create visual ping update + game.addUpdate({ + type: GameUpdateType.PingPlaced, + playerID: recipient.smallID(), + senderID: this.sender.smallID(), + pingType: this.pingType, + x: this.x, + y: this.y, + } as PingPlacedUpdate); + } + } + + tick(ticks: number): void { + // Pings are instantaneous, no need for tick logic + } + + isActive(): boolean { + return false; // It's an instantaneous event + } + + activeDuringSpawnPhase(): boolean { + return true; // Pings can be used anytime + } +} diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts index 455ef1ac1..9c313c4e5 100644 --- a/src/core/game/GameUpdates.ts +++ b/src/core/game/GameUpdates.ts @@ -47,6 +47,7 @@ export enum GameUpdateType { RailroadEvent, ConquestEvent, EmbargoEvent, + PingPlaced, } export type GameUpdate = @@ -68,7 +69,8 @@ export type GameUpdate = | BonusEventUpdate | RailroadUpdate | ConquestUpdate - | EmbargoUpdate; + | EmbargoUpdate + | PingPlacedUpdate; export interface BonusEventUpdate { type: GameUpdateType.BonusEvent; @@ -263,9 +265,21 @@ export interface UnitIncomingUpdate { playerID: number; } +import { PingType } from "./Ping"; +//... +//... export interface EmbargoUpdate { type: GameUpdateType.EmbargoEvent; event: "start" | "stop"; playerID: number; embargoedID: number; } + +export interface PingPlacedUpdate { + type: GameUpdateType.PingPlaced; + playerID: number; + senderID: number; + pingType: PingType; + x: number; + y: number; +}