@rpgjs/client 5.0.0-alpha.32 → 5.0.0-alpha.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Game/Map.d.ts +11 -1
- package/dist/Game/Map.js +31 -1
- package/dist/Game/Map.js.map +1 -1
- package/dist/Game/Object.js +1 -1
- package/dist/Game/Object.js.map +1 -1
- package/dist/Gui/Gui.js +5 -5
- package/dist/RpgClientEngine.d.ts +15 -0
- package/dist/RpgClientEngine.js +241 -19
- package/dist/RpgClientEngine.js.map +1 -1
- package/dist/components/gui/box.ce.js +3 -3
- package/dist/components/scenes/canvas.ce.js +1 -1
- package/dist/components/scenes/canvas.ce.js.map +1 -1
- package/dist/components/scenes/draw-map.ce.js +34 -2
- package/dist/components/scenes/draw-map.ce.js.map +1 -1
- package/dist/core/inject.js +1 -1
- package/dist/core/setup.js +1 -1
- package/dist/index.js +1 -1
- package/dist/module.js +1 -1
- package/dist/node_modules/.pnpm/{@signe_di@2.8.2 → @signe_di@2.8.3}/node_modules/@signe/di/dist/index.js.map +1 -1
- package/dist/node_modules/.pnpm/{@signe_reactive@2.8.2 → @signe_reactive@2.8.3}/node_modules/@signe/reactive/dist/index.js.map +1 -1
- package/dist/node_modules/.pnpm/{@signe_room@2.8.2 → @signe_room@2.8.3}/node_modules/@signe/room/dist/index.js +4 -4
- package/dist/node_modules/.pnpm/@signe_room@2.8.3/node_modules/@signe/room/dist/index.js.map +1 -0
- package/dist/node_modules/.pnpm/{@signe_sync@2.8.2 → @signe_sync@2.8.3}/node_modules/@signe/sync/dist/chunk-7QVYU63E.js.map +1 -1
- package/dist/node_modules/.pnpm/{@signe_sync@2.8.2 → @signe_sync@2.8.3}/node_modules/@signe/sync/dist/client/index.js.map +1 -1
- package/dist/node_modules/.pnpm/{@signe_sync@2.8.2 → @signe_sync@2.8.3}/node_modules/@signe/sync/dist/index.js +1 -1
- package/dist/node_modules/.pnpm/{@signe_sync@2.8.2 → @signe_sync@2.8.3}/node_modules/@signe/sync/dist/index.js.map +1 -1
- package/dist/services/AbstractSocket.d.ts +9 -5
- package/dist/services/AbstractSocket.js.map +1 -1
- package/dist/services/loadMap.js +1 -1
- package/dist/services/mmorpg.d.ts +12 -8
- package/dist/services/mmorpg.js +68 -13
- package/dist/services/mmorpg.js.map +1 -1
- package/dist/services/standalone.d.ts +2 -4
- package/dist/services/standalone.js +2 -2
- package/dist/services/standalone.js.map +1 -1
- package/package.json +10 -10
- package/src/Game/Map.ts +38 -2
- package/src/Game/Object.ts +1 -1
- package/src/RpgClientEngine.ts +300 -20
- package/src/components/scenes/canvas.ce +1 -1
- package/src/components/scenes/draw-map.ce +37 -1
- package/src/services/AbstractSocket.ts +10 -2
- package/src/services/mmorpg.ts +84 -14
- package/src/services/standalone.ts +3 -3
- package/dist/node_modules/.pnpm/@signe_room@2.8.2/node_modules/@signe/room/dist/index.js.map +0 -1
- /package/dist/node_modules/.pnpm/{@signe_di@2.8.2 → @signe_di@2.8.3}/node_modules/@signe/di/dist/index.js +0 -0
- /package/dist/node_modules/.pnpm/{@signe_reactive@2.8.2 → @signe_reactive@2.8.3}/node_modules/@signe/reactive/dist/index.js +0 -0
- /package/dist/node_modules/.pnpm/{@signe_sync@2.8.2 → @signe_sync@2.8.3}/node_modules/@signe/sync/dist/chunk-7QVYU63E.js +0 -0
- /package/dist/node_modules/.pnpm/{@signe_sync@2.8.2 → @signe_sync@2.8.3}/node_modules/@signe/sync/dist/client/index.js +0 -0
package/src/RpgClientEngine.ts
CHANGED
|
@@ -18,11 +18,22 @@ import * as PIXI from "pixi.js";
|
|
|
18
18
|
import { PrebuiltComponentAnimations } from "./components/animations";
|
|
19
19
|
import {
|
|
20
20
|
PredictionController,
|
|
21
|
+
type PredictionHistoryEntry,
|
|
21
22
|
type PredictionState,
|
|
22
23
|
} from "@rpgjs/common";
|
|
23
24
|
import { NotificationManager } from "./Gui/NotificationManager";
|
|
24
25
|
import { SaveClientService } from "./services/save";
|
|
25
26
|
|
|
27
|
+
interface MovementTrajectoryPoint {
|
|
28
|
+
frame: number;
|
|
29
|
+
tick: number;
|
|
30
|
+
timestamp: number;
|
|
31
|
+
input: Direction;
|
|
32
|
+
x: number;
|
|
33
|
+
y: number;
|
|
34
|
+
direction?: Direction;
|
|
35
|
+
}
|
|
36
|
+
|
|
26
37
|
export class RpgClientEngine<T = any> {
|
|
27
38
|
private guiService: RpgGui;
|
|
28
39
|
private webSocket: AbstractWebsocket;
|
|
@@ -64,12 +75,18 @@ export class RpgClientEngine<T = any> {
|
|
|
64
75
|
private prediction?: PredictionController<Direction>;
|
|
65
76
|
private readonly SERVER_CORRECTION_THRESHOLD = 30;
|
|
66
77
|
private inputFrameCounter = 0;
|
|
78
|
+
private pendingPredictionFrames: number[] = [];
|
|
79
|
+
private lastClientPhysicsStepAt = 0;
|
|
67
80
|
private frameOffset = 0;
|
|
68
81
|
// Ping/Pong for RTT measurement
|
|
69
82
|
private rtt: number = 0; // Round-trip time in ms
|
|
70
83
|
private pingInterval: any = null;
|
|
71
84
|
private readonly PING_INTERVAL_MS = 5000; // Send ping every 5 seconds
|
|
72
85
|
private lastInputTime = 0;
|
|
86
|
+
private readonly MOVE_PATH_RESEND_INTERVAL_MS = 120;
|
|
87
|
+
private readonly MAX_MOVE_TRAJECTORY_POINTS = 240;
|
|
88
|
+
private lastMovePathSentAt = 0;
|
|
89
|
+
private lastMovePathSentFrame = 0;
|
|
73
90
|
// Track map loading state for onAfterLoading hook using RxJS
|
|
74
91
|
private mapLoadCompleted$ = new BehaviorSubject<boolean>(false);
|
|
75
92
|
private playerIdReceived$ = new BehaviorSubject<boolean>(false);
|
|
@@ -156,6 +173,8 @@ export class RpgClientEngine<T = any> {
|
|
|
156
173
|
|
|
157
174
|
async start() {
|
|
158
175
|
this.sceneMap = new RpgClientMap()
|
|
176
|
+
this.sceneMap.configureClientPrediction(this.predictionEnabled);
|
|
177
|
+
this.sceneMap.loadPhysic();
|
|
159
178
|
this.selector = document.body.querySelector("#rpg") as HTMLElement;
|
|
160
179
|
|
|
161
180
|
const bootstrapOptions = (this.globalConfig as any)?.bootstrapCanvasOptions;
|
|
@@ -200,6 +219,9 @@ export class RpgClientEngine<T = any> {
|
|
|
200
219
|
window.addEventListener('resize', this.resizeHandler);
|
|
201
220
|
|
|
202
221
|
const tickSubscription = this.tick.subscribe((tick) => {
|
|
222
|
+
this.stepClientPhysicsTick();
|
|
223
|
+
this.flushPendingPredictedStates();
|
|
224
|
+
this.flushPendingMovePath();
|
|
203
225
|
this.hooks.callHooks("client-engine-onStep", this, tick).subscribe();
|
|
204
226
|
|
|
205
227
|
// Clean up old prediction states and input history every 60 ticks (approximately every second at 60fps)
|
|
@@ -216,9 +238,56 @@ export class RpgClientEngine<T = any> {
|
|
|
216
238
|
saveClient.initialize(this.webSocket);
|
|
217
239
|
this.initListeners()
|
|
218
240
|
this.guiService._initialize()
|
|
241
|
+
this.startPingPong();
|
|
219
242
|
});
|
|
220
243
|
}
|
|
221
244
|
|
|
245
|
+
private prepareSyncPayload(data: any): any {
|
|
246
|
+
const payload = { ...(data ?? {}) };
|
|
247
|
+
delete payload.ack;
|
|
248
|
+
delete payload.timestamp;
|
|
249
|
+
|
|
250
|
+
const myId = this.playerIdSignal();
|
|
251
|
+
const players = payload.players;
|
|
252
|
+
const shouldMaskLocalPosition =
|
|
253
|
+
this.predictionEnabled && !!this.prediction?.hasPendingInputs();
|
|
254
|
+
if (shouldMaskLocalPosition && myId && players && players[myId]) {
|
|
255
|
+
const localPatch = { ...players[myId] };
|
|
256
|
+
delete localPatch.x;
|
|
257
|
+
delete localPatch.y;
|
|
258
|
+
delete localPatch.direction;
|
|
259
|
+
delete localPatch._frames;
|
|
260
|
+
payload.players = {
|
|
261
|
+
...players,
|
|
262
|
+
[myId]: localPatch,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return payload;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private normalizeAckWithSyncState(
|
|
270
|
+
ack: { frame: number; serverTick?: number; x?: number; y?: number; direction?: Direction },
|
|
271
|
+
syncData: any,
|
|
272
|
+
): { frame: number; serverTick?: number; x?: number; y?: number; direction?: Direction } {
|
|
273
|
+
const myId = this.playerIdSignal();
|
|
274
|
+
if (!myId) {
|
|
275
|
+
return ack;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const localPatch = syncData?.players?.[myId];
|
|
279
|
+
if (typeof localPatch?.x !== "number" || typeof localPatch?.y !== "number") {
|
|
280
|
+
return ack;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
...ack,
|
|
285
|
+
x: localPatch.x,
|
|
286
|
+
y: localPatch.y,
|
|
287
|
+
direction: localPatch.direction ?? ack.direction,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
222
291
|
private initListeners() {
|
|
223
292
|
this.webSocket.on("sync", (data) => {
|
|
224
293
|
if (data.pId) {
|
|
@@ -229,16 +298,27 @@ export class RpgClientEngine<T = any> {
|
|
|
229
298
|
|
|
230
299
|
if (this.sceneResetQueued) {
|
|
231
300
|
this.sceneMap.reset();
|
|
301
|
+
this.sceneMap.loadPhysic();
|
|
232
302
|
this.sceneResetQueued = false;
|
|
233
303
|
}
|
|
234
304
|
|
|
235
305
|
// Apply client-side prediction filtering and server reconciliation
|
|
236
306
|
this.hooks.callHooks("client-sceneMap-onChanges", this.sceneMap, { partial: data }).subscribe();
|
|
237
307
|
|
|
238
|
-
|
|
308
|
+
const ack = data?.ack;
|
|
309
|
+
const normalizedAck =
|
|
310
|
+
ack && typeof ack.frame === "number"
|
|
311
|
+
? this.normalizeAckWithSyncState(ack, data)
|
|
312
|
+
: undefined;
|
|
313
|
+
const payload = this.prepareSyncPayload(data);
|
|
314
|
+
load(this.sceneMap, payload, true);
|
|
239
315
|
|
|
240
|
-
|
|
241
|
-
|
|
316
|
+
if (normalizedAck) {
|
|
317
|
+
this.applyServerAck(normalizedAck);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
for (const playerId in payload.players ?? {}) {
|
|
321
|
+
const player = payload.players[playerId]
|
|
242
322
|
if (!player._param) continue
|
|
243
323
|
for (const param in player._param) {
|
|
244
324
|
this.sceneMap.players()[playerId]._param()[param] = player._param[param]
|
|
@@ -246,12 +326,12 @@ export class RpgClientEngine<T = any> {
|
|
|
246
326
|
}
|
|
247
327
|
|
|
248
328
|
// Check if players and events are present in sync data
|
|
249
|
-
const players =
|
|
329
|
+
const players = payload.players || this.sceneMap.players();
|
|
250
330
|
if (players && Object.keys(players).length > 0) {
|
|
251
331
|
this.playersReceived$.next(true);
|
|
252
332
|
}
|
|
253
333
|
|
|
254
|
-
const events =
|
|
334
|
+
const events = payload.events || this.sceneMap.events();
|
|
255
335
|
if (events !== undefined) {
|
|
256
336
|
this.eventsReceived$.next(true);
|
|
257
337
|
}
|
|
@@ -279,7 +359,8 @@ export class RpgClientEngine<T = any> {
|
|
|
279
359
|
this.sceneResetQueued = true;
|
|
280
360
|
// Reset camera follow to default (follow current player) when changing maps
|
|
281
361
|
this.cameraFollowTargetId.set(null);
|
|
282
|
-
|
|
362
|
+
const transferToken = typeof data?.transferToken === "string" ? data.transferToken : undefined;
|
|
363
|
+
this.loadScene(data.mapId, transferToken);
|
|
283
364
|
});
|
|
284
365
|
|
|
285
366
|
this.webSocket.on("showComponentAnimation", (data) => {
|
|
@@ -342,9 +423,36 @@ export class RpgClientEngine<T = any> {
|
|
|
342
423
|
});
|
|
343
424
|
});
|
|
344
425
|
|
|
426
|
+
this.webSocket.on("weatherState", (data) => {
|
|
427
|
+
const raw = (data && typeof data === "object" && "value" in data)
|
|
428
|
+
? (data as any).value
|
|
429
|
+
: data;
|
|
430
|
+
|
|
431
|
+
if (raw === null) {
|
|
432
|
+
this.sceneMap.weatherState.set(null);
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const validEffects = ["rain", "snow", "fog", "cloud"];
|
|
437
|
+
if (!raw || !validEffects.includes((raw as any).effect)) {
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
this.sceneMap.weatherState.set({
|
|
442
|
+
effect: (raw as any).effect,
|
|
443
|
+
preset: (raw as any).preset,
|
|
444
|
+
params: (raw as any).params,
|
|
445
|
+
transitionMs: (raw as any).transitionMs,
|
|
446
|
+
durationMs: (raw as any).durationMs,
|
|
447
|
+
startedAt: (raw as any).startedAt,
|
|
448
|
+
seed: (raw as any).seed,
|
|
449
|
+
});
|
|
450
|
+
});
|
|
451
|
+
|
|
345
452
|
this.webSocket.on('open', () => {
|
|
346
453
|
this.hooks.callHooks("client-engine-onConnected", this, this.socket).subscribe();
|
|
347
454
|
// Start ping/pong for synchronization
|
|
455
|
+
this.startPingPong();
|
|
348
456
|
})
|
|
349
457
|
|
|
350
458
|
this.webSocket.on('close', () => {
|
|
@@ -430,7 +538,7 @@ export class RpgClientEngine<T = any> {
|
|
|
430
538
|
});
|
|
431
539
|
}
|
|
432
540
|
|
|
433
|
-
private async loadScene(mapId: string) {
|
|
541
|
+
private async loadScene(mapId: string, transferToken?: string) {
|
|
434
542
|
await lastValueFrom(this.hooks.callHooks("client-sceneMap-onBeforeLoading", this.sceneMap));
|
|
435
543
|
|
|
436
544
|
// Clear client prediction states when changing maps
|
|
@@ -450,7 +558,10 @@ export class RpgClientEngine<T = any> {
|
|
|
450
558
|
// Setup RxJS observable to wait for all conditions
|
|
451
559
|
this.setupOnAfterLoadingObserver();
|
|
452
560
|
|
|
453
|
-
this.webSocket.updateProperties({
|
|
561
|
+
this.webSocket.updateProperties({
|
|
562
|
+
room: mapId,
|
|
563
|
+
query: transferToken ? { transferToken } : undefined,
|
|
564
|
+
})
|
|
454
565
|
await this.webSocket.reconnect(() => {
|
|
455
566
|
const saveClient = inject(SaveClientService);
|
|
456
567
|
saveClient.initialize(this.webSocket);
|
|
@@ -478,6 +589,7 @@ export class RpgClientEngine<T = any> {
|
|
|
478
589
|
|
|
479
590
|
// Signal that map loading is completed (this should be last to ensure other checks are done)
|
|
480
591
|
this.mapLoadCompleted$.next(true);
|
|
592
|
+
this.sceneMap.configureClientPrediction(this.predictionEnabled);
|
|
481
593
|
this.sceneMap.loadPhysic()
|
|
482
594
|
}
|
|
483
595
|
|
|
@@ -1075,22 +1187,24 @@ export class RpgClientEngine<T = any> {
|
|
|
1075
1187
|
frame = ++this.inputFrameCounter;
|
|
1076
1188
|
tick = this.getPhysicsTick();
|
|
1077
1189
|
}
|
|
1190
|
+
this.inputFrameCounter = frame;
|
|
1078
1191
|
this.hooks.callHooks("client-engine-onInput", this, { input, playerId: this.playerId }).subscribe();
|
|
1079
1192
|
|
|
1080
|
-
this.webSocket.emit('move', {
|
|
1081
|
-
input,
|
|
1082
|
-
timestamp,
|
|
1083
|
-
frame,
|
|
1084
|
-
tick,
|
|
1085
|
-
});
|
|
1086
|
-
|
|
1087
1193
|
const currentPlayer = this.sceneMap.getCurrentPlayer();
|
|
1088
|
-
|
|
1194
|
+
const bodyReady = this.ensureCurrentPlayerBody();
|
|
1195
|
+
if (currentPlayer && bodyReady) {
|
|
1196
|
+
currentPlayer.changeDirection(input);
|
|
1089
1197
|
(this.sceneMap as any).moveBody(currentPlayer, input);
|
|
1198
|
+
if (this.predictionEnabled && this.prediction) {
|
|
1199
|
+
this.pendingPredictionFrames.push(frame);
|
|
1200
|
+
if (this.pendingPredictionFrames.length > 240) {
|
|
1201
|
+
this.pendingPredictionFrames = this.pendingPredictionFrames.slice(-240);
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1090
1204
|
}
|
|
1091
|
-
this.lastInputTime = Date.now();
|
|
1092
|
-
const myId = this.playerIdSignal();
|
|
1093
1205
|
|
|
1206
|
+
this.emitMovePacket(input, frame, tick, timestamp, true);
|
|
1207
|
+
this.lastInputTime = Date.now();
|
|
1094
1208
|
}
|
|
1095
1209
|
|
|
1096
1210
|
processAction({ action }: { action: number }) {
|
|
@@ -1119,6 +1233,127 @@ export class RpgClientEngine<T = any> {
|
|
|
1119
1233
|
return this.sceneMap?.getTick?.() ?? 0;
|
|
1120
1234
|
}
|
|
1121
1235
|
|
|
1236
|
+
private ensureCurrentPlayerBody(): boolean {
|
|
1237
|
+
const player = this.sceneMap?.getCurrentPlayer();
|
|
1238
|
+
const myId = this.playerIdSignal();
|
|
1239
|
+
if (!player || !myId) {
|
|
1240
|
+
return false;
|
|
1241
|
+
}
|
|
1242
|
+
if (!player.id) {
|
|
1243
|
+
player.id = myId;
|
|
1244
|
+
}
|
|
1245
|
+
if (this.sceneMap.getBody(myId)) {
|
|
1246
|
+
return true;
|
|
1247
|
+
}
|
|
1248
|
+
try {
|
|
1249
|
+
this.sceneMap.loadPhysic();
|
|
1250
|
+
} catch (error) {
|
|
1251
|
+
console.error("[RPGJS] Unable to initialize client physics before input:", error);
|
|
1252
|
+
return false;
|
|
1253
|
+
}
|
|
1254
|
+
return !!this.sceneMap.getBody(myId);
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
private stepClientPhysicsTick(): void {
|
|
1258
|
+
if (!this.predictionEnabled || !this.sceneMap) {
|
|
1259
|
+
return;
|
|
1260
|
+
}
|
|
1261
|
+
const now = Date.now();
|
|
1262
|
+
if (this.lastClientPhysicsStepAt === 0) {
|
|
1263
|
+
this.lastClientPhysicsStepAt = now;
|
|
1264
|
+
}
|
|
1265
|
+
const deltaMs = Math.max(1, Math.min(100, now - this.lastClientPhysicsStepAt));
|
|
1266
|
+
this.lastClientPhysicsStepAt = now;
|
|
1267
|
+
this.sceneMap.stepClientPhysics(deltaMs);
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
private flushPendingPredictedStates(): void {
|
|
1271
|
+
if (!this.predictionEnabled || !this.prediction || this.pendingPredictionFrames.length === 0) {
|
|
1272
|
+
return;
|
|
1273
|
+
}
|
|
1274
|
+
const state = this.getLocalPlayerState();
|
|
1275
|
+
while (this.pendingPredictionFrames.length > 0) {
|
|
1276
|
+
const frame = this.pendingPredictionFrames.shift();
|
|
1277
|
+
if (typeof frame === "number") {
|
|
1278
|
+
this.prediction.attachPredictedState(frame, state);
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
private buildPendingMoveTrajectory(): MovementTrajectoryPoint[] {
|
|
1284
|
+
if (!this.predictionEnabled || !this.prediction) {
|
|
1285
|
+
return [];
|
|
1286
|
+
}
|
|
1287
|
+
const pendingInputs = this.prediction.getPendingInputs();
|
|
1288
|
+
const trajectory: MovementTrajectoryPoint[] = [];
|
|
1289
|
+
for (const entry of pendingInputs) {
|
|
1290
|
+
const state = entry.state;
|
|
1291
|
+
if (!state) continue;
|
|
1292
|
+
if (typeof state.x !== "number" || typeof state.y !== "number") continue;
|
|
1293
|
+
trajectory.push({
|
|
1294
|
+
frame: entry.frame,
|
|
1295
|
+
tick: entry.tick,
|
|
1296
|
+
timestamp: entry.timestamp,
|
|
1297
|
+
input: entry.direction,
|
|
1298
|
+
x: state.x,
|
|
1299
|
+
y: state.y,
|
|
1300
|
+
direction: state.direction ?? entry.direction,
|
|
1301
|
+
});
|
|
1302
|
+
}
|
|
1303
|
+
if (trajectory.length > this.MAX_MOVE_TRAJECTORY_POINTS) {
|
|
1304
|
+
return trajectory.slice(-this.MAX_MOVE_TRAJECTORY_POINTS);
|
|
1305
|
+
}
|
|
1306
|
+
return trajectory;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
private emitMovePacket(
|
|
1310
|
+
input: Direction,
|
|
1311
|
+
frame: number,
|
|
1312
|
+
tick: number,
|
|
1313
|
+
timestamp: number,
|
|
1314
|
+
force = false,
|
|
1315
|
+
): void {
|
|
1316
|
+
const trajectory = this.buildPendingMoveTrajectory();
|
|
1317
|
+
const latestTrajectoryFrame =
|
|
1318
|
+
trajectory.length > 0 ? trajectory[trajectory.length - 1].frame : frame;
|
|
1319
|
+
const shouldThrottle =
|
|
1320
|
+
!force &&
|
|
1321
|
+
latestTrajectoryFrame <= this.lastMovePathSentFrame &&
|
|
1322
|
+
timestamp - this.lastMovePathSentAt < this.MOVE_PATH_RESEND_INTERVAL_MS;
|
|
1323
|
+
if (shouldThrottle) {
|
|
1324
|
+
return;
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
this.webSocket.emit("move", {
|
|
1328
|
+
input,
|
|
1329
|
+
timestamp,
|
|
1330
|
+
frame,
|
|
1331
|
+
tick,
|
|
1332
|
+
trajectory,
|
|
1333
|
+
});
|
|
1334
|
+
this.lastMovePathSentAt = timestamp;
|
|
1335
|
+
this.lastMovePathSentFrame = Math.max(this.lastMovePathSentFrame, latestTrajectoryFrame, frame);
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
private flushPendingMovePath(): void {
|
|
1339
|
+
if (!this.predictionEnabled || !this.prediction) {
|
|
1340
|
+
return;
|
|
1341
|
+
}
|
|
1342
|
+
const pendingInputs = this.prediction.getPendingInputs();
|
|
1343
|
+
if (pendingInputs.length === 0) {
|
|
1344
|
+
return;
|
|
1345
|
+
}
|
|
1346
|
+
const latest = pendingInputs[pendingInputs.length - 1];
|
|
1347
|
+
if (!latest) {
|
|
1348
|
+
return;
|
|
1349
|
+
}
|
|
1350
|
+
const now = Date.now();
|
|
1351
|
+
if (now - this.lastMovePathSentAt < this.MOVE_PATH_RESEND_INTERVAL_MS) {
|
|
1352
|
+
return;
|
|
1353
|
+
}
|
|
1354
|
+
this.emitMovePacket(latest.direction, latest.frame, latest.tick, now, false);
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1122
1357
|
private getLocalPlayerState(): PredictionState<Direction> {
|
|
1123
1358
|
const currentPlayer = this.sceneMap?.getCurrentPlayer();
|
|
1124
1359
|
if (!currentPlayer) {
|
|
@@ -1151,11 +1386,21 @@ export class RpgClientEngine<T = any> {
|
|
|
1151
1386
|
private initializePredictionController(): void {
|
|
1152
1387
|
if (!this.predictionEnabled) {
|
|
1153
1388
|
this.prediction = undefined;
|
|
1389
|
+
this.sceneMap?.configureClientPrediction?.(false);
|
|
1154
1390
|
return;
|
|
1155
1391
|
}
|
|
1392
|
+
const configuredTtl = (this.globalConfig as any)?.prediction?.historyTtlMs;
|
|
1393
|
+
const historyTtlMs = typeof configuredTtl === "number" ? configuredTtl : 10000;
|
|
1394
|
+
const configuredMaxEntries = (this.globalConfig as any)?.prediction?.maxHistoryEntries;
|
|
1395
|
+
const maxHistoryEntries =
|
|
1396
|
+
typeof configuredMaxEntries === "number"
|
|
1397
|
+
? configuredMaxEntries
|
|
1398
|
+
: Math.max(600, Math.ceil(historyTtlMs / 16) + 120);
|
|
1399
|
+
this.sceneMap?.configureClientPrediction?.(true);
|
|
1156
1400
|
this.prediction = new PredictionController<Direction>({
|
|
1157
1401
|
correctionThreshold: (this.globalConfig as any)?.prediction?.correctionThreshold ?? this.SERVER_CORRECTION_THRESHOLD,
|
|
1158
|
-
historyTtlMs
|
|
1402
|
+
historyTtlMs,
|
|
1403
|
+
maxHistoryEntries,
|
|
1159
1404
|
getPhysicsTick: () => this.getPhysicsTick(),
|
|
1160
1405
|
getCurrentState: () => this.getLocalPlayerState(),
|
|
1161
1406
|
setAuthoritativeState: (state) => this.applyAuthoritativeState(state),
|
|
@@ -1222,6 +1467,10 @@ export class RpgClientEngine<T = any> {
|
|
|
1222
1467
|
this.initializePredictionController();
|
|
1223
1468
|
this.frameOffset = 0;
|
|
1224
1469
|
this.inputFrameCounter = 0;
|
|
1470
|
+
this.pendingPredictionFrames = [];
|
|
1471
|
+
this.lastClientPhysicsStepAt = 0;
|
|
1472
|
+
this.lastMovePathSentAt = 0;
|
|
1473
|
+
this.lastMovePathSentFrame = 0;
|
|
1225
1474
|
}
|
|
1226
1475
|
|
|
1227
1476
|
/**
|
|
@@ -1292,7 +1541,7 @@ export class RpgClientEngine<T = any> {
|
|
|
1292
1541
|
|
|
1293
1542
|
private applyServerAck(ack: { frame: number; serverTick?: number; x?: number; y?: number; direction?: Direction }) {
|
|
1294
1543
|
if (this.predictionEnabled && this.prediction) {
|
|
1295
|
-
this.prediction.applyServerAck({
|
|
1544
|
+
const result = this.prediction.applyServerAck({
|
|
1296
1545
|
frame: ack.frame,
|
|
1297
1546
|
serverTick: ack.serverTick,
|
|
1298
1547
|
state:
|
|
@@ -1300,6 +1549,9 @@ export class RpgClientEngine<T = any> {
|
|
|
1300
1549
|
? { x: ack.x, y: ack.y, direction: ack.direction }
|
|
1301
1550
|
: undefined,
|
|
1302
1551
|
});
|
|
1552
|
+
if (result.state && result.needsReconciliation) {
|
|
1553
|
+
this.reconcilePrediction(result.state, result.pendingInputs);
|
|
1554
|
+
}
|
|
1303
1555
|
return;
|
|
1304
1556
|
}
|
|
1305
1557
|
|
|
@@ -1325,6 +1577,32 @@ export class RpgClientEngine<T = any> {
|
|
|
1325
1577
|
}
|
|
1326
1578
|
}
|
|
1327
1579
|
|
|
1580
|
+
private reconcilePrediction(
|
|
1581
|
+
authoritativeState: PredictionState<Direction>,
|
|
1582
|
+
pendingInputs: PredictionHistoryEntry<Direction>[],
|
|
1583
|
+
): void {
|
|
1584
|
+
const player = this.getCurrentPlayer();
|
|
1585
|
+
if (!player) {
|
|
1586
|
+
return;
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
(this.sceneMap as any).stopMovement(player);
|
|
1590
|
+
this.applyAuthoritativeState(authoritativeState);
|
|
1591
|
+
|
|
1592
|
+
if (!pendingInputs.length) {
|
|
1593
|
+
return;
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
// Keep replay bounded while still tolerating high-latency links.
|
|
1597
|
+
const replayInputs = pendingInputs.slice(-600);
|
|
1598
|
+
for (const entry of replayInputs) {
|
|
1599
|
+
if (!entry?.direction) continue;
|
|
1600
|
+
(this.sceneMap as any).moveBody(player, entry.direction);
|
|
1601
|
+
this.sceneMap.stepPredictionTick();
|
|
1602
|
+
this.prediction?.attachPredictedState(entry.frame, this.getLocalPlayerState());
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1328
1606
|
/**
|
|
1329
1607
|
* Replay unacknowledged inputs from a given frame to resimulate client prediction
|
|
1330
1608
|
* after applying server authority at a certain frame.
|
|
@@ -1505,6 +1783,8 @@ export class RpgClientEngine<T = any> {
|
|
|
1505
1783
|
this.inputFrameCounter = 0;
|
|
1506
1784
|
this.frameOffset = 0;
|
|
1507
1785
|
this.rtt = 0;
|
|
1786
|
+
this.lastMovePathSentAt = 0;
|
|
1787
|
+
this.lastMovePathSentFrame = 0;
|
|
1508
1788
|
|
|
1509
1789
|
// Reset behavior subjects
|
|
1510
1790
|
this.mapLoadCompleted$.next(false);
|
|
@@ -10,6 +10,10 @@
|
|
|
10
10
|
}
|
|
11
11
|
</Container>
|
|
12
12
|
}
|
|
13
|
+
|
|
14
|
+
@if (weatherProps) {
|
|
15
|
+
<Weather ...weatherProps() />
|
|
16
|
+
}
|
|
13
17
|
</Container>
|
|
14
18
|
|
|
15
19
|
<script>
|
|
@@ -17,6 +21,7 @@
|
|
|
17
21
|
import { effect, signal, computed, mount, on, tick } from 'canvasengine'
|
|
18
22
|
import { inject } from "../../core/inject";
|
|
19
23
|
import { RpgClientEngine } from "../../RpgClientEngine";
|
|
24
|
+
import { Weather } from '@canvasengine/presets'
|
|
20
25
|
|
|
21
26
|
const engine = inject(RpgClientEngine);
|
|
22
27
|
const componentAnimations = engine.componentAnimations
|
|
@@ -25,6 +30,7 @@
|
|
|
25
30
|
const sceneParams = map()?.params
|
|
26
31
|
const mapParams = map()?.params
|
|
27
32
|
const animations = engine.sceneMap.animations
|
|
33
|
+
const weather = engine.sceneMap.weather
|
|
28
34
|
const backgroundMusic = { src: mapParams?.backgroundMusic, autoplay: true, loop: true }
|
|
29
35
|
const backgroundAmbientSound = { src: mapParams?.backgroundAmbientSound, autoplay: true, loop: true }
|
|
30
36
|
|
|
@@ -45,4 +51,34 @@
|
|
|
45
51
|
frequency: 10,
|
|
46
52
|
direction: 'both'
|
|
47
53
|
}
|
|
48
|
-
|
|
54
|
+
|
|
55
|
+
const weatherProps = computed(() => {
|
|
56
|
+
const state = weather?.()
|
|
57
|
+
if (!state) {
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
const validEffects = ['rain', 'snow', 'fog', 'cloud']
|
|
61
|
+
if (!validEffects.includes(state.effect)) {
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
64
|
+
const params = state.params ?? {}
|
|
65
|
+
return {
|
|
66
|
+
effect: state.effect,
|
|
67
|
+
speed: params.speed,
|
|
68
|
+
windDirection: params.windDirection,
|
|
69
|
+
windStrength: params.windStrength,
|
|
70
|
+
density: params.density,
|
|
71
|
+
maxDrops: params.maxDrops,
|
|
72
|
+
height: params.height,
|
|
73
|
+
scale: params.scale,
|
|
74
|
+
sunIntensity: params.sunIntensity,
|
|
75
|
+
sunAngle: params.sunAngle,
|
|
76
|
+
raySpread: params.raySpread,
|
|
77
|
+
rayTwinkle: params.rayTwinkle,
|
|
78
|
+
rayTwinkleSpeed: params.rayTwinkleSpeed,
|
|
79
|
+
zIndex: params.zIndex ?? 1000,
|
|
80
|
+
alpha: params.alpha,
|
|
81
|
+
blendMode: params.blendMode,
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
</script>
|
|
@@ -2,6 +2,14 @@ import { Context } from "@signe/di";
|
|
|
2
2
|
|
|
3
3
|
export const WebSocketToken = "websocket";
|
|
4
4
|
|
|
5
|
+
export type SocketQueryValue = string | null | undefined;
|
|
6
|
+
export type SocketQuery = Record<string, SocketQueryValue>;
|
|
7
|
+
export type SocketUpdateProperties = {
|
|
8
|
+
room: string;
|
|
9
|
+
host?: string;
|
|
10
|
+
query?: SocketQuery;
|
|
11
|
+
};
|
|
12
|
+
|
|
5
13
|
export abstract class AbstractWebsocket {
|
|
6
14
|
constructor(protected context: Context) {}
|
|
7
15
|
|
|
@@ -9,6 +17,6 @@ export abstract class AbstractWebsocket {
|
|
|
9
17
|
abstract emit(event: string, data: any): void;
|
|
10
18
|
abstract on(event: string, callback: (data: any) => void): void;
|
|
11
19
|
abstract off(event: string, callback: (data: any) => void): void;
|
|
12
|
-
abstract updateProperties(params:
|
|
13
|
-
abstract reconnect(listeners?: (data: any) => void): void
|
|
20
|
+
abstract updateProperties(params: SocketUpdateProperties): void;
|
|
21
|
+
abstract reconnect(listeners?: (data: any) => void): Promise<void>;
|
|
14
22
|
}
|