mirror of
https://github.com/Xevion/smart-rgb.git
synced 2025-12-18 14:13:26 -06:00
255 lines
8.8 KiB
TypeScript
255 lines
8.8 KiB
TypeScript
import type {
|
|
AttacksUpdatePayload,
|
|
GameOutcome,
|
|
LeaderboardSnapshot,
|
|
ShipsUpdatePayload,
|
|
SpawnPhaseUpdate,
|
|
UnsubscribeFn,
|
|
} from "./types";
|
|
import type { Transport } from "@/shared/api/Transport";
|
|
import type { GameRenderer, PaletteData, TerrainData } from "@/shared/render/GameRenderer";
|
|
import { CallbackManager } from "@/shared/utils/CallbackManager";
|
|
import { decodeInitBinary } from "@/shared/utils/binaryDecoding";
|
|
|
|
export interface RenderInitData {
|
|
palette: PaletteData; // Nation palette
|
|
terrain_palette: PaletteData; // Terrain palette
|
|
terrain: TerrainData;
|
|
initial_territories: {
|
|
turn: number;
|
|
territories: { indices: Uint32Array; ownerIds: Uint16Array };
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Game API bridge that connects the UI to the game backend.
|
|
*
|
|
* This class provides:
|
|
* - Callback management per message type
|
|
* - Renderer integration & binary decoding
|
|
* - Message buffering & initialization sequencing
|
|
* - Typed API methods (startGame, onLeaderboardSnapshot, etc.)
|
|
*/
|
|
export class GameBridge {
|
|
private transport: Transport;
|
|
private renderer: GameRenderer | null = null;
|
|
private snapshotCallbacks = new CallbackManager<LeaderboardSnapshot>();
|
|
private gameEndCallbacks = new CallbackManager<GameOutcome>();
|
|
private attacksUpdateCallbacks = new CallbackManager<AttacksUpdatePayload>();
|
|
private shipsUpdateCallbacks = new CallbackManager<ShipsUpdatePayload>();
|
|
private spawnPhaseUpdateCallbacks = new CallbackManager<SpawnPhaseUpdate>();
|
|
private spawnPhaseEndedCallbacks = new CallbackManager<void>();
|
|
private renderInitCallbacks = new CallbackManager<RenderInitData>();
|
|
private queuedRenderMessages: Array<any> = [];
|
|
|
|
constructor(transport: Transport) {
|
|
this.transport = transport;
|
|
|
|
// Subscribe to JSON messages from backend
|
|
this.transport.onJson((message) => this.handleBackendMessage(message));
|
|
|
|
// Subscribe to binary data from backend
|
|
this.transport.onBinary((data, type) => this.handleBinaryData(data, type));
|
|
}
|
|
|
|
private handleBackendMessage(message: any): void {
|
|
// Messages use msg_type tag from serde
|
|
switch (message.msg_type) {
|
|
case "LeaderboardSnapshot":
|
|
this.snapshotCallbacks.notify(message as LeaderboardSnapshot);
|
|
break;
|
|
|
|
case "AttacksUpdate":
|
|
this.attacksUpdateCallbacks.notify(message as AttacksUpdatePayload);
|
|
break;
|
|
|
|
case "ShipsUpdate":
|
|
this.shipsUpdateCallbacks.notify(message as ShipsUpdatePayload);
|
|
// Also forward to renderer for visual updates
|
|
if (this.renderer) {
|
|
this.renderer.updateShips(message as ShipsUpdatePayload);
|
|
}
|
|
break;
|
|
|
|
case "GameEnded":
|
|
this.gameEndCallbacks.notify(message.outcome as GameOutcome);
|
|
break;
|
|
|
|
case "SpawnPhaseUpdate":
|
|
const update: SpawnPhaseUpdate = {
|
|
countdown: message.countdown
|
|
? {
|
|
startedAtMs: message.countdown.started_at_ms,
|
|
durationSecs: message.countdown.duration_secs,
|
|
}
|
|
: null,
|
|
};
|
|
this.spawnPhaseUpdateCallbacks.notify(update);
|
|
break;
|
|
|
|
case "SpawnPhaseEnded":
|
|
this.spawnPhaseEndedCallbacks.notify();
|
|
break;
|
|
|
|
case "HighlightNation":
|
|
this.renderer?.setHighlightedNation(message.nation_id ?? null);
|
|
break;
|
|
|
|
default:
|
|
console.warn("Unknown backend message type:", message.msg_type);
|
|
break;
|
|
}
|
|
}
|
|
|
|
private handleBinaryData(data: Uint8Array, type: "init" | "delta"): void {
|
|
if (type === "init") {
|
|
// Decode complete initialization data (terrain + territory + nation palette)
|
|
const decoded = decodeInitBinary(data);
|
|
if (!decoded) {
|
|
console.error("Failed to decode init binary data");
|
|
return;
|
|
}
|
|
|
|
// Assemble complete initialization data
|
|
const completeInitData: RenderInitData = {
|
|
palette: {
|
|
colors: decoded.nationPalette,
|
|
},
|
|
terrain_palette: {
|
|
colors: decoded.terrain.palette,
|
|
},
|
|
terrain: {
|
|
size: {
|
|
x: decoded.terrain.width,
|
|
y: decoded.terrain.height,
|
|
},
|
|
terrain_data: decoded.terrain.tileIds,
|
|
},
|
|
initial_territories: {
|
|
turn: 0,
|
|
territories: {
|
|
indices: decoded.territory.indices,
|
|
ownerIds: decoded.territory.ownerIds,
|
|
},
|
|
},
|
|
};
|
|
|
|
console.log("Complete initialization data ready:", completeInitData);
|
|
|
|
// Notify subscribers
|
|
this.renderInitCallbacks.notify(completeInitData);
|
|
return;
|
|
}
|
|
|
|
// For delta updates, need renderer to be ready
|
|
if (!this.renderer) {
|
|
this.queuedRenderMessages.push({ _type: "binary", data, binaryType: type });
|
|
return;
|
|
}
|
|
|
|
// Apply delta
|
|
this.renderer.updateBinaryDelta(data);
|
|
}
|
|
|
|
startGame(): void {
|
|
this.transport.sendJson({ msg_type: "StartGame" });
|
|
}
|
|
|
|
quitGame(): void {
|
|
this.transport.sendJson({ msg_type: "QuitGame" });
|
|
}
|
|
|
|
onLeaderboardSnapshot(callback: (data: LeaderboardSnapshot) => void): UnsubscribeFn {
|
|
return this.snapshotCallbacks.subscribe(callback);
|
|
}
|
|
|
|
onGameEnded(callback: (outcome: GameOutcome) => void): UnsubscribeFn {
|
|
return this.gameEndCallbacks.subscribe(callback);
|
|
}
|
|
|
|
onAttacksUpdate(callback: (data: AttacksUpdatePayload) => void): UnsubscribeFn {
|
|
return this.attacksUpdateCallbacks.subscribe(callback);
|
|
}
|
|
|
|
onShipsUpdate(callback: (data: ShipsUpdatePayload) => void): UnsubscribeFn {
|
|
return this.shipsUpdateCallbacks.subscribe(callback);
|
|
}
|
|
|
|
onSpawnPhaseUpdate(callback: (update: SpawnPhaseUpdate) => void): UnsubscribeFn {
|
|
return this.spawnPhaseUpdateCallbacks.subscribe(callback);
|
|
}
|
|
|
|
onSpawnPhaseEnded(callback: () => void): UnsubscribeFn {
|
|
return this.spawnPhaseEndedCallbacks.subscribe(callback);
|
|
}
|
|
|
|
onRenderInit(callback: (data: RenderInitData) => void): UnsubscribeFn {
|
|
return this.renderInitCallbacks.subscribe(callback);
|
|
}
|
|
|
|
setRenderer(renderer: GameRenderer): void {
|
|
this.renderer = renderer;
|
|
|
|
// Replay any queued binary delta messages that arrived before renderer was ready
|
|
if (this.queuedRenderMessages.length > 0) {
|
|
for (const message of this.queuedRenderMessages) {
|
|
if (message._type === "binary") {
|
|
this.handleBinaryData(message.data, message.binaryType);
|
|
}
|
|
}
|
|
this.queuedRenderMessages = [];
|
|
}
|
|
}
|
|
|
|
sendMapClick(data: { tile_index: number | null; world_x: number; world_y: number; button: number }): void {
|
|
// Transport implementations may have specialized methods for render input
|
|
if ("sendRenderInput" in this.transport) {
|
|
(this.transport as any).sendRenderInput({
|
|
type: "MapClick",
|
|
tile_index: data.tile_index,
|
|
world_x: data.world_x,
|
|
world_y: data.world_y,
|
|
button: data.button,
|
|
});
|
|
}
|
|
}
|
|
|
|
sendMapHover(data: { tile_index: number | null; world_x: number; world_y: number }): void {
|
|
if ("sendRenderInput" in this.transport) {
|
|
(this.transport as any).sendRenderInput({
|
|
type: "MapHover",
|
|
tile_index: data.tile_index,
|
|
world_x: data.world_x,
|
|
world_y: data.world_y,
|
|
});
|
|
}
|
|
}
|
|
|
|
sendKeyPress(data: { key: string; pressed: boolean }): void {
|
|
if ("sendRenderInput" in this.transport) {
|
|
(this.transport as any).sendRenderInput({
|
|
type: "KeyPress",
|
|
key: data.key,
|
|
pressed: data.pressed,
|
|
});
|
|
}
|
|
}
|
|
|
|
sendAttackRatio(ratio: number): void {
|
|
this.transport.sendJson({
|
|
msg_type: "SetAttackRatio",
|
|
ratio: ratio,
|
|
});
|
|
}
|
|
|
|
async getGameState(): Promise<any | null> {
|
|
// WASM doesn't persist state across reloads - always start fresh
|
|
// Tauri could implement this if needed
|
|
return null;
|
|
}
|
|
|
|
track(event: string, properties?: Record<string, any>): void {
|
|
this.transport.sendAnalytics(event, properties || {});
|
|
}
|
|
}
|