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.
This commit is contained in:
Restart2008
2025-11-24 16:59:59 -08:00
parent ec9c859add
commit 60e08dc94b
6 changed files with 112 additions and 18 deletions
+6
View File
@@ -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();
+31 -15
View File
@@ -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<PingType, Colord> = {
attack: colord("#ff0000"),
retreat: colord("#ffa600"),
@@ -23,7 +23,7 @@ const PING_COLORS: Record<PingType, Colord> = {
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);
}
+11 -1
View File
@@ -47,7 +47,8 @@ export type Intent =
| EmbargoAllIntent
| UpgradeStructureIntent
| DeleteUnitIntent
| KickPlayerIntent;
| KickPlayerIntent
| PingIntent;
export type AttackIntent = z.infer<typeof AttackIntentSchema>;
export type CancelAttackIntent = z.infer<typeof CancelAttackIntentSchema>;
@@ -79,6 +80,7 @@ export type AllianceExtensionIntent = z.infer<
>;
export type DeleteUnitIntent = z.infer<typeof DeleteUnitIntentSchema>;
export type KickPlayerIntent = z.infer<typeof KickPlayerIntentSchema>;
export type PingIntent = z.infer<typeof PingIntentSchema>;
export type Turn = z.infer<typeof TurnSchema>;
export type GameConfig = z.infer<typeof GameConfigSchema>;
@@ -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,
]);
//
+3 -1
View File
@@ -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`);
}
+46
View File
@@ -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
}
}
+15 -1
View File
@@ -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;
}