@rpgjs/client 5.0.0-alpha.14 → 5.0.0-alpha.16
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 +2 -1
- package/dist/RpgClient.d.ts +39 -0
- package/dist/RpgClientEngine.d.ts +138 -2
- package/dist/index10.js +1 -2
- package/dist/index10.js.map +1 -1
- package/dist/index15.js +58 -16
- package/dist/index15.js.map +1 -1
- package/dist/index2.js +303 -3
- package/dist/index2.js.map +1 -1
- package/dist/index20.js +3 -0
- package/dist/index20.js.map +1 -1
- package/dist/index22.js +3 -3
- package/dist/index23.js +2 -2
- package/dist/index25.js +1 -2
- package/dist/index25.js.map +1 -1
- package/dist/index33.js +1 -1
- package/dist/index34.js +1 -1
- package/dist/index35.js +9 -184
- package/dist/index35.js.map +1 -1
- package/dist/index36.js +6 -503
- package/dist/index36.js.map +1 -1
- package/dist/index37.js +3687 -9
- package/dist/index37.js.map +1 -1
- package/dist/index38.js +186 -6
- package/dist/index38.js.map +1 -1
- package/dist/index39.js +499 -3685
- package/dist/index39.js.map +1 -1
- package/dist/index40.js +1 -1
- package/dist/index41.js +1 -1
- package/dist/index42.js +119 -16
- package/dist/index42.js.map +1 -1
- package/dist/index43.js +16 -92
- package/dist/index43.js.map +1 -1
- package/dist/index8.js +8 -0
- package/dist/index8.js.map +1 -1
- package/package.json +4 -4
- package/src/Game/Map.ts +5 -1
- package/src/Game/Object.ts +37 -4
- package/src/RpgClient.ts +40 -0
- package/src/RpgClientEngine.ts +374 -11
- package/src/components/animations/animation.ce +1 -2
- package/src/components/character.ce +80 -20
- package/src/components/gui/dialogbox/index.ce +1 -2
- package/src/module.ts +8 -0
package/src/RpgClientEngine.ts
CHANGED
|
@@ -3,7 +3,9 @@ import { Context, inject } from "@signe/di";
|
|
|
3
3
|
import { signal, bootstrapCanvas } from "canvasengine";
|
|
4
4
|
import { AbstractWebsocket, WebSocketToken } from "./services/AbstractSocket";
|
|
5
5
|
import { LoadMapService, LoadMapToken } from "./services/loadMap";
|
|
6
|
-
import { Hooks, ModulesToken } from "@rpgjs/common";
|
|
6
|
+
import { Hooks, ModulesToken, Direction } from "@rpgjs/common";
|
|
7
|
+
|
|
8
|
+
type DirectionValue = "up" | "down" | "left" | "right";
|
|
7
9
|
import { load } from "@signe/sync";
|
|
8
10
|
import { RpgClientMap } from "./Game/Map"
|
|
9
11
|
import { RpgGui } from "./Gui/Gui";
|
|
@@ -12,6 +14,10 @@ import { lastValueFrom, Observable } from "rxjs";
|
|
|
12
14
|
import { GlobalConfigToken } from "./module";
|
|
13
15
|
import * as PIXI from "pixi.js";
|
|
14
16
|
import { PrebuiltComponentAnimations } from "./components/animations";
|
|
17
|
+
import {
|
|
18
|
+
PredictionController,
|
|
19
|
+
type PredictionState,
|
|
20
|
+
} from "@rpgjs/common";
|
|
15
21
|
|
|
16
22
|
export class RpgClientEngine<T = any> {
|
|
17
23
|
private guiService: RpgGui;
|
|
@@ -28,17 +34,29 @@ export class RpgClientEngine<T = any> {
|
|
|
28
34
|
spritesheets: Map<string, any> = new Map();
|
|
29
35
|
sounds: Map<string, any> = new Map();
|
|
30
36
|
componentAnimations: any[] = [];
|
|
37
|
+
private spritesheetResolver?: (id: string) => any | Promise<any>;
|
|
31
38
|
particleSettings: {
|
|
32
39
|
emitters: any[]
|
|
33
40
|
} = {
|
|
34
|
-
|
|
35
|
-
|
|
41
|
+
emitters: []
|
|
42
|
+
}
|
|
36
43
|
renderer: PIXI.Renderer;
|
|
37
44
|
tick: Observable<number>;
|
|
38
45
|
playerIdSignal = signal<string | null>(null);
|
|
39
46
|
spriteComponentsBehind = signal<any[]>([]);
|
|
40
47
|
spriteComponentsInFront = signal<any[]>([]);
|
|
41
48
|
|
|
49
|
+
private predictionEnabled = false;
|
|
50
|
+
private prediction?: PredictionController<Direction>;
|
|
51
|
+
private readonly SERVER_CORRECTION_THRESHOLD = 30;
|
|
52
|
+
private inputFrameCounter = 0;
|
|
53
|
+
private frameOffset = 0;
|
|
54
|
+
// Ping/Pong for RTT measurement
|
|
55
|
+
private rtt: number = 0; // Round-trip time in ms
|
|
56
|
+
private pingInterval: any = null;
|
|
57
|
+
private readonly PING_INTERVAL_MS = 5000; // Send ping every 5 seconds
|
|
58
|
+
private lastInputTime = 0;
|
|
59
|
+
|
|
42
60
|
constructor(public context: Context) {
|
|
43
61
|
this.webSocket = inject(context, WebSocketToken);
|
|
44
62
|
this.guiService = inject(context, RpgGui);
|
|
@@ -47,10 +65,10 @@ export class RpgClientEngine<T = any> {
|
|
|
47
65
|
this.globalConfig = inject(context, GlobalConfigToken)
|
|
48
66
|
|
|
49
67
|
if (!this.globalConfig) {
|
|
50
|
-
this.globalConfig = {}
|
|
68
|
+
this.globalConfig = {} as T
|
|
51
69
|
}
|
|
52
|
-
if (!this.globalConfig.box) {
|
|
53
|
-
this.globalConfig.box = {
|
|
70
|
+
if (!(this.globalConfig as any).box) {
|
|
71
|
+
(this.globalConfig as any).box = {
|
|
54
72
|
styles: {
|
|
55
73
|
backgroundColor: "#1a1a2e",
|
|
56
74
|
backgroundOpacity: 0.9
|
|
@@ -63,6 +81,9 @@ export class RpgClientEngine<T = any> {
|
|
|
63
81
|
id: "animation",
|
|
64
82
|
component: PrebuiltComponentAnimations.Animation
|
|
65
83
|
})
|
|
84
|
+
|
|
85
|
+
this.predictionEnabled = (this.globalConfig as any)?.prediction?.enabled !== false;
|
|
86
|
+
this.initializePredictionController();
|
|
66
87
|
}
|
|
67
88
|
|
|
68
89
|
async start() {
|
|
@@ -73,8 +94,17 @@ export class RpgClientEngine<T = any> {
|
|
|
73
94
|
this.renderer = app.renderer as PIXI.Renderer;
|
|
74
95
|
this.tick = canvasElement?.propObservables?.context['tick'].observable
|
|
75
96
|
|
|
97
|
+
this.tick.subscribe(() => {
|
|
98
|
+
if (Date.now() - this.lastInputTime > 100) {
|
|
99
|
+
const player = this.getCurrentPlayer();
|
|
100
|
+
if (!player) return;
|
|
101
|
+
(this.sceneMap as any).stopMovement(player);
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
|
|
76
105
|
|
|
77
106
|
this.hooks.callHooks("client-spritesheets-load", this).subscribe();
|
|
107
|
+
this.hooks.callHooks("client-spritesheetResolver-load", this).subscribe();
|
|
78
108
|
this.hooks.callHooks("client-sounds-load", this).subscribe();
|
|
79
109
|
this.hooks.callHooks("client-gui-load", this).subscribe();
|
|
80
110
|
this.hooks.callHooks("client-particles-load", this).subscribe();
|
|
@@ -90,6 +120,13 @@ export class RpgClientEngine<T = any> {
|
|
|
90
120
|
|
|
91
121
|
this.tick.subscribe((tick) => {
|
|
92
122
|
this.hooks.callHooks("client-engine-onStep", this, tick).subscribe();
|
|
123
|
+
|
|
124
|
+
// Clean up old prediction states and input history every 60 ticks (approximately every second at 60fps)
|
|
125
|
+
if (tick % 60 === 0) {
|
|
126
|
+
const now = Date.now();
|
|
127
|
+
this.prediction?.cleanup(now);
|
|
128
|
+
this.prediction?.tryApplyPendingSnapshot();
|
|
129
|
+
}
|
|
93
130
|
})
|
|
94
131
|
|
|
95
132
|
await this.webSocket.connection(() => {
|
|
@@ -100,11 +137,33 @@ export class RpgClientEngine<T = any> {
|
|
|
100
137
|
|
|
101
138
|
private initListeners() {
|
|
102
139
|
this.webSocket.on("sync", (data) => {
|
|
140
|
+
|
|
103
141
|
if (data.pId) this.playerIdSignal.set(data.pId)
|
|
142
|
+
// Apply client-side prediction filtering and server reconciliation
|
|
143
|
+
|
|
104
144
|
this.hooks.callHooks("client-sceneMap-onChanges", this.sceneMap, { partial: data }).subscribe();
|
|
145
|
+
|
|
105
146
|
load(this.sceneMap, data, true);
|
|
106
147
|
});
|
|
107
148
|
|
|
149
|
+
// Handle pong responses for RTT measurement
|
|
150
|
+
this.webSocket.on("pong", (data: { serverTick: number; clientFrame: number; clientTime: number }) => {
|
|
151
|
+
const now = Date.now();
|
|
152
|
+
this.rtt = now - data.clientTime;
|
|
153
|
+
|
|
154
|
+
// Calculate frame offset: how many ticks ahead the server is compared to our frame counter
|
|
155
|
+
// This helps us estimate which server tick corresponds to each client input frame
|
|
156
|
+
const estimatedTicksInFlight = Math.floor(this.rtt / 2 / (1000 / 60)); // Estimate ticks during half RTT
|
|
157
|
+
const estimatedServerTickNow = data.serverTick + estimatedTicksInFlight;
|
|
158
|
+
|
|
159
|
+
// Update frame offset (only if we have inputs to calibrate with)
|
|
160
|
+
if (this.inputFrameCounter > 0) {
|
|
161
|
+
this.frameOffset = estimatedServerTickNow - data.clientFrame;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
console.debug(`[Ping/Pong] RTT: ${this.rtt}ms, ServerTick: ${data.serverTick}, FrameOffset: ${this.frameOffset}`);
|
|
165
|
+
});
|
|
166
|
+
|
|
108
167
|
this.webSocket.on("changeMap", (data) => {
|
|
109
168
|
this.sceneMap.reset()
|
|
110
169
|
this.loadScene(data.mapId);
|
|
@@ -127,19 +186,98 @@ export class RpgClientEngine<T = any> {
|
|
|
127
186
|
|
|
128
187
|
this.webSocket.on('open', () => {
|
|
129
188
|
this.hooks.callHooks("client-engine-onConnected", this, this.socket).subscribe();
|
|
189
|
+
// Start ping/pong for synchronization
|
|
130
190
|
})
|
|
131
191
|
|
|
132
192
|
this.webSocket.on('close', () => {
|
|
133
193
|
this.hooks.callHooks("client-engine-onDisconnected", this, this.socket).subscribe();
|
|
194
|
+
// Stop ping/pong when disconnected
|
|
195
|
+
this.stopPingPong();
|
|
134
196
|
})
|
|
135
197
|
|
|
136
198
|
this.webSocket.on('error', (error) => {
|
|
137
199
|
this.hooks.callHooks("client-engine-onConnectError", this, error, this.socket).subscribe();
|
|
138
200
|
})
|
|
139
201
|
}
|
|
140
|
-
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Start periodic ping/pong for client-server synchronization
|
|
205
|
+
*
|
|
206
|
+
* Sends ping requests to the server to measure round-trip time (RTT) and
|
|
207
|
+
* calculate the frame offset between client and server ticks.
|
|
208
|
+
*
|
|
209
|
+
* ## Design
|
|
210
|
+
*
|
|
211
|
+
* - Sends ping every 5 seconds
|
|
212
|
+
* - Measures RTT for latency compensation
|
|
213
|
+
* - Calculates frame offset to map client frames to server ticks
|
|
214
|
+
* - Used for accurate server reconciliation
|
|
215
|
+
*
|
|
216
|
+
* @example
|
|
217
|
+
* ```ts
|
|
218
|
+
* // Called automatically when connection opens
|
|
219
|
+
* this.startPingPong();
|
|
220
|
+
* ```
|
|
221
|
+
*/
|
|
222
|
+
private startPingPong(): void {
|
|
223
|
+
// Stop existing interval if any
|
|
224
|
+
this.stopPingPong();
|
|
225
|
+
|
|
226
|
+
// Send initial ping immediately
|
|
227
|
+
this.sendPing();
|
|
228
|
+
|
|
229
|
+
// Set up periodic pings
|
|
230
|
+
this.pingInterval = setInterval(() => {
|
|
231
|
+
this.sendPing();
|
|
232
|
+
}, this.PING_INTERVAL_MS);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Stop periodic ping/pong
|
|
237
|
+
*
|
|
238
|
+
* Stops the ping interval when disconnecting or changing maps.
|
|
239
|
+
*
|
|
240
|
+
* @example
|
|
241
|
+
* ```ts
|
|
242
|
+
* // Called automatically when connection closes
|
|
243
|
+
* this.stopPingPong();
|
|
244
|
+
* ```
|
|
245
|
+
*/
|
|
246
|
+
private stopPingPong(): void {
|
|
247
|
+
if (this.pingInterval) {
|
|
248
|
+
clearInterval(this.pingInterval);
|
|
249
|
+
this.pingInterval = null;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Send a ping request to the server
|
|
255
|
+
*
|
|
256
|
+
* Sends current client time and frame counter to the server,
|
|
257
|
+
* which will respond with its server tick for synchronization.
|
|
258
|
+
*
|
|
259
|
+
* @example
|
|
260
|
+
* ```ts
|
|
261
|
+
* // Send a ping to measure RTT
|
|
262
|
+
* this.sendPing();
|
|
263
|
+
* ```
|
|
264
|
+
*/
|
|
265
|
+
private sendPing(): void {
|
|
266
|
+
const clientTime = Date.now();
|
|
267
|
+
const clientFrame = this.getPhysicsTick();
|
|
268
|
+
|
|
269
|
+
this.webSocket.emit('ping', {
|
|
270
|
+
clientTime,
|
|
271
|
+
clientFrame
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
141
275
|
private async loadScene(mapId: string) {
|
|
142
276
|
this.hooks.callHooks("client-sceneMap-onBeforeLoading", this.sceneMap).subscribe();
|
|
277
|
+
|
|
278
|
+
// Clear client prediction states when changing maps
|
|
279
|
+
this.clearClientPredictionStates();
|
|
280
|
+
|
|
143
281
|
this.webSocket.updateProperties({ room: mapId })
|
|
144
282
|
await this.webSocket.reconnect(() => {
|
|
145
283
|
this.initListeners()
|
|
@@ -148,7 +286,7 @@ export class RpgClientEngine<T = any> {
|
|
|
148
286
|
const res = await this.loadMapService.load(mapId)
|
|
149
287
|
this.sceneMap.data.set(res)
|
|
150
288
|
this.hooks.callHooks("client-sceneMap-onAfterLoading", this.sceneMap).subscribe();
|
|
151
|
-
|
|
289
|
+
this.sceneMap.loadPhysic()
|
|
152
290
|
}
|
|
153
291
|
|
|
154
292
|
addSpriteSheet<T = any>(spritesheetClass: any, id?: string): any {
|
|
@@ -156,6 +294,90 @@ export class RpgClientEngine<T = any> {
|
|
|
156
294
|
return spritesheetClass as any;
|
|
157
295
|
}
|
|
158
296
|
|
|
297
|
+
/**
|
|
298
|
+
* Set a resolver function for spritesheets
|
|
299
|
+
*
|
|
300
|
+
* The resolver is called when a spritesheet is requested but not found in the cache.
|
|
301
|
+
* It can be synchronous (returns directly) or asynchronous (returns a Promise).
|
|
302
|
+
* The resolved spritesheet is automatically cached for future use.
|
|
303
|
+
*
|
|
304
|
+
* @param resolver - Function that takes a spritesheet ID and returns a spritesheet or Promise of spritesheet
|
|
305
|
+
*
|
|
306
|
+
* @example
|
|
307
|
+
* ```ts
|
|
308
|
+
* // Synchronous resolver
|
|
309
|
+
* engine.setSpritesheetResolver((id) => {
|
|
310
|
+
* if (id === 'dynamic-sprite') {
|
|
311
|
+
* return { id: 'dynamic-sprite', image: 'path/to/image.png', framesWidth: 32, framesHeight: 32 };
|
|
312
|
+
* }
|
|
313
|
+
* return undefined;
|
|
314
|
+
* });
|
|
315
|
+
*
|
|
316
|
+
* // Asynchronous resolver (loading from API)
|
|
317
|
+
* engine.setSpritesheetResolver(async (id) => {
|
|
318
|
+
* const response = await fetch(`/api/spritesheets/${id}`);
|
|
319
|
+
* const data = await response.json();
|
|
320
|
+
* return data;
|
|
321
|
+
* });
|
|
322
|
+
* ```
|
|
323
|
+
*/
|
|
324
|
+
setSpritesheetResolver(resolver: (id: string) => any | Promise<any>): void {
|
|
325
|
+
this.spritesheetResolver = resolver;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Get a spritesheet by ID, using resolver if not found in cache
|
|
330
|
+
*
|
|
331
|
+
* This method first checks if the spritesheet exists in the cache.
|
|
332
|
+
* If not found and a resolver is set, it calls the resolver to create the spritesheet.
|
|
333
|
+
* The resolved spritesheet is automatically cached for future use.
|
|
334
|
+
*
|
|
335
|
+
* @param id - The spritesheet ID to retrieve
|
|
336
|
+
* @returns The spritesheet if found or created, or undefined if not found and no resolver
|
|
337
|
+
* @returns Promise<any> if the resolver is asynchronous
|
|
338
|
+
*
|
|
339
|
+
* @example
|
|
340
|
+
* ```ts
|
|
341
|
+
* // Synchronous usage
|
|
342
|
+
* const spritesheet = engine.getSpriteSheet('my-sprite');
|
|
343
|
+
*
|
|
344
|
+
* // Asynchronous usage (when resolver returns Promise)
|
|
345
|
+
* const spritesheet = await engine.getSpriteSheet('dynamic-sprite');
|
|
346
|
+
* ```
|
|
347
|
+
*/
|
|
348
|
+
getSpriteSheet(id: string): any | Promise<any> {
|
|
349
|
+
// Check cache first
|
|
350
|
+
if (this.spritesheets.has(id)) {
|
|
351
|
+
return this.spritesheets.get(id);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// If not in cache and resolver exists, use it
|
|
355
|
+
if (this.spritesheetResolver) {
|
|
356
|
+
const result = this.spritesheetResolver(id);
|
|
357
|
+
|
|
358
|
+
// Check if result is a Promise
|
|
359
|
+
if (result instanceof Promise) {
|
|
360
|
+
return result.then((spritesheet) => {
|
|
361
|
+
if (spritesheet) {
|
|
362
|
+
// Cache the resolved spritesheet
|
|
363
|
+
this.spritesheets.set(id, spritesheet);
|
|
364
|
+
}
|
|
365
|
+
return spritesheet;
|
|
366
|
+
});
|
|
367
|
+
} else {
|
|
368
|
+
// Synchronous result
|
|
369
|
+
if (result) {
|
|
370
|
+
// Cache the resolved spritesheet
|
|
371
|
+
this.spritesheets.set(id, result);
|
|
372
|
+
}
|
|
373
|
+
return result;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// No resolver and not in cache
|
|
378
|
+
return undefined;
|
|
379
|
+
}
|
|
380
|
+
|
|
159
381
|
addSound(sound: any, id?: string) {
|
|
160
382
|
this.sounds.set(id || sound.id, sound);
|
|
161
383
|
return sound;
|
|
@@ -267,9 +489,34 @@ export class RpgClientEngine<T = any> {
|
|
|
267
489
|
return componentAnimation.instance
|
|
268
490
|
}
|
|
269
491
|
|
|
270
|
-
processInput({ input }: { input:
|
|
492
|
+
async processInput({ input }: { input: Direction }) {
|
|
493
|
+
const timestamp = Date.now();
|
|
494
|
+
let frame: number;
|
|
495
|
+
let tick: number;
|
|
496
|
+
if (this.predictionEnabled && this.prediction) {
|
|
497
|
+
const meta = this.prediction.recordInput(input, timestamp);
|
|
498
|
+
frame = meta.frame;
|
|
499
|
+
tick = meta.tick;
|
|
500
|
+
} else {
|
|
501
|
+
frame = ++this.inputFrameCounter;
|
|
502
|
+
tick = this.getPhysicsTick();
|
|
503
|
+
}
|
|
271
504
|
this.hooks.callHooks("client-engine-onInput", this, { input, playerId: this.playerId }).subscribe();
|
|
272
|
-
|
|
505
|
+
|
|
506
|
+
this.webSocket.emit('move', {
|
|
507
|
+
input,
|
|
508
|
+
timestamp,
|
|
509
|
+
frame,
|
|
510
|
+
tick,
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
const currentPlayer = this.sceneMap.getCurrentPlayer();
|
|
514
|
+
if (currentPlayer) {
|
|
515
|
+
(this.sceneMap as any).moveBody(currentPlayer, input);
|
|
516
|
+
}
|
|
517
|
+
this.lastInputTime = Date.now();
|
|
518
|
+
const myId = this.playerIdSignal();
|
|
519
|
+
|
|
273
520
|
}
|
|
274
521
|
|
|
275
522
|
processAction({ action }: { action: number }) {
|
|
@@ -285,7 +532,7 @@ export class RpgClientEngine<T = any> {
|
|
|
285
532
|
get socket() {
|
|
286
533
|
return this.webSocket
|
|
287
534
|
}
|
|
288
|
-
|
|
535
|
+
|
|
289
536
|
get playerId() {
|
|
290
537
|
return this.playerIdSignal()
|
|
291
538
|
}
|
|
@@ -294,7 +541,123 @@ export class RpgClientEngine<T = any> {
|
|
|
294
541
|
return this.sceneMap
|
|
295
542
|
}
|
|
296
543
|
|
|
544
|
+
private getPhysicsTick(): number {
|
|
545
|
+
return this.sceneMap?.getTick?.() ?? 0;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
private getLocalPlayerState(): PredictionState<Direction> {
|
|
549
|
+
const currentPlayer = this.sceneMap?.getCurrentPlayer();
|
|
550
|
+
if (!currentPlayer) {
|
|
551
|
+
return { x: 0, y: 0, direction: Direction.Down };
|
|
552
|
+
}
|
|
553
|
+
const topLeft = this.sceneMap.getBodyPosition(currentPlayer.id, "top-left");
|
|
554
|
+
const x = topLeft?.x ?? currentPlayer.x();
|
|
555
|
+
const y = topLeft?.y ?? currentPlayer.y();
|
|
556
|
+
const direction = currentPlayer.direction();
|
|
557
|
+
return { x, y, direction };
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
private applyAuthoritativeState(state: PredictionState<Direction>): void {
|
|
561
|
+
const player = this.sceneMap?.getCurrentPlayer();
|
|
562
|
+
if (!player) return;
|
|
563
|
+
const hitbox = typeof player.hitbox === "function" ? player.hitbox() : player.hitbox;
|
|
564
|
+
const width = hitbox?.w ?? 0;
|
|
565
|
+
const height = hitbox?.h ?? 0;
|
|
566
|
+
const updated = this.sceneMap.updateHitbox(player.id, state.x, state.y, width, height);
|
|
567
|
+
if (!updated) {
|
|
568
|
+
this.sceneMap.setBodyPosition(player.id, state.x, state.y, "top-left");
|
|
569
|
+
}
|
|
570
|
+
player.x.set(Math.round(state.x));
|
|
571
|
+
player.y.set(Math.round(state.y));
|
|
572
|
+
if (state.direction) {
|
|
573
|
+
player.changeDirection(state.direction);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
private initializePredictionController(): void {
|
|
578
|
+
if (!this.predictionEnabled) {
|
|
579
|
+
this.prediction = undefined;
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
this.prediction = new PredictionController<Direction>({
|
|
583
|
+
correctionThreshold: (this.globalConfig as any)?.prediction?.correctionThreshold ?? this.SERVER_CORRECTION_THRESHOLD,
|
|
584
|
+
historyTtlMs: (this.globalConfig as any)?.prediction?.historyTtlMs ?? 2000,
|
|
585
|
+
getPhysicsTick: () => this.getPhysicsTick(),
|
|
586
|
+
getCurrentState: () => this.getLocalPlayerState(),
|
|
587
|
+
setAuthoritativeState: (state) => this.applyAuthoritativeState(state),
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
|
|
297
591
|
getCurrentPlayer() {
|
|
298
592
|
return this.sceneMap.getCurrentPlayer()
|
|
299
593
|
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Clear client prediction states for cleanup
|
|
597
|
+
*
|
|
598
|
+
* Removes old prediction states and input history to prevent memory leaks.
|
|
599
|
+
* Should be called when changing maps or disconnecting.
|
|
600
|
+
*
|
|
601
|
+
* @example
|
|
602
|
+
* ```ts
|
|
603
|
+
* // Clear prediction states when changing maps
|
|
604
|
+
* engine.clearClientPredictionStates();
|
|
605
|
+
* ```
|
|
606
|
+
*/
|
|
607
|
+
clearClientPredictionStates() {
|
|
608
|
+
this.initializePredictionController();
|
|
609
|
+
this.frameOffset = 0;
|
|
610
|
+
this.inputFrameCounter = 0;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
private applyServerAck(ack: { frame: number; serverTick?: number; x?: number; y?: number; direction?: Direction }) {
|
|
614
|
+
if (this.predictionEnabled && this.prediction) {
|
|
615
|
+
this.prediction.applyServerAck({
|
|
616
|
+
frame: ack.frame,
|
|
617
|
+
serverTick: ack.serverTick,
|
|
618
|
+
state:
|
|
619
|
+
typeof ack.x === "number" && typeof ack.y === "number"
|
|
620
|
+
? { x: ack.x, y: ack.y, direction: ack.direction }
|
|
621
|
+
: undefined,
|
|
622
|
+
});
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
if (typeof ack.x !== "number" || typeof ack.y !== "number") {
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
const player = this.getCurrentPlayer();
|
|
630
|
+
const myId = this.playerIdSignal();
|
|
631
|
+
if (!player || !myId) {
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
const hitbox = typeof player.hitbox === "function" ? player.hitbox() : player.hitbox;
|
|
635
|
+
const width = hitbox?.w ?? 0;
|
|
636
|
+
const height = hitbox?.h ?? 0;
|
|
637
|
+
const updated = this.sceneMap.updateHitbox(myId, ack.x, ack.y, width, height);
|
|
638
|
+
if (!updated) {
|
|
639
|
+
this.sceneMap.setBodyPosition(myId, ack.x, ack.y, "top-left");
|
|
640
|
+
}
|
|
641
|
+
player.x.set(Math.round(ack.x));
|
|
642
|
+
player.y.set(Math.round(ack.y));
|
|
643
|
+
if (ack.direction) {
|
|
644
|
+
player.changeDirection(ack.direction);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Replay unacknowledged inputs from a given frame to resimulate client prediction
|
|
650
|
+
* after applying server authority at a certain frame.
|
|
651
|
+
*
|
|
652
|
+
* @param startFrame - The last server-acknowledged frame
|
|
653
|
+
*
|
|
654
|
+
* @example
|
|
655
|
+
* ```ts
|
|
656
|
+
* // After applying a server correction at frame N
|
|
657
|
+
* this.replayUnackedInputsFromFrame(N);
|
|
658
|
+
* ```
|
|
659
|
+
*/
|
|
660
|
+
private async replayUnackedInputsFromFrame(_startFrame: number): Promise<void> {
|
|
661
|
+
// Prediction controller handles replay internally. Kept for backwards compatibility.
|
|
662
|
+
}
|
|
300
663
|
}
|
|
@@ -7,10 +7,9 @@
|
|
|
7
7
|
const { x, y, animationName, graphic, onFinish } = defineProps();
|
|
8
8
|
|
|
9
9
|
const client = inject(RpgClientEngine);
|
|
10
|
-
const spritesheets = client.spritesheets;
|
|
11
10
|
|
|
12
11
|
const sheet = {
|
|
13
|
-
definition:
|
|
12
|
+
definition: client.getSpriteSheet(graphic()),
|
|
14
13
|
playing: animationName() ?? 'default',
|
|
15
14
|
onFinish
|
|
16
15
|
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<Container x y zIndex={y} viewportFollow={isMe} controls onBeforeDestroy visible
|
|
1
|
+
<Container x={smoothX} y={smoothY} zIndex={y} viewportFollow={isMe} controls onBeforeDestroy visible>
|
|
2
2
|
@for (component of componentsBehind) {
|
|
3
3
|
<Container>
|
|
4
4
|
<component object />
|
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
</Container>
|
|
19
19
|
|
|
20
20
|
<script>
|
|
21
|
-
import { signal, effect, mount, computed, tick } from "canvasengine";
|
|
22
|
-
import { lastValueFrom } from "rxjs";
|
|
21
|
+
import { signal, effect, mount, computed, tick, animatedSignal } from "canvasengine";
|
|
22
|
+
import { lastValueFrom, combineLatest, pairwise, filter, map, startWith } from "rxjs";
|
|
23
23
|
import { Particle } from "@canvasengine/presets";
|
|
24
24
|
import { GameEngineToken, ModulesToken } from "@rpgjs/common";
|
|
25
25
|
import { RpgClientEngine } from "../RpgClientEngine";
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
import Hit from "./effects/hit.ce";
|
|
29
29
|
|
|
30
30
|
const { object, id } = defineProps();
|
|
31
|
-
|
|
31
|
+
|
|
32
32
|
const client = inject(RpgClientEngine);
|
|
33
33
|
const hooks = inject(ModulesToken);
|
|
34
34
|
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
particleName,
|
|
50
50
|
graphics,
|
|
51
51
|
hitbox,
|
|
52
|
-
isConnected
|
|
52
|
+
isConnected,
|
|
53
53
|
} = object;
|
|
54
54
|
|
|
55
55
|
const particleSettings = client.particleSettings;
|
|
@@ -57,6 +57,13 @@
|
|
|
57
57
|
const canControls = () => isMe() && object.canMove()
|
|
58
58
|
const keyboardControls = client.globalConfig.keyboardControls;
|
|
59
59
|
|
|
60
|
+
const visible = computed(() => {
|
|
61
|
+
if (object.type === 'event') {
|
|
62
|
+
return true
|
|
63
|
+
}
|
|
64
|
+
return isConnected()
|
|
65
|
+
});
|
|
66
|
+
|
|
60
67
|
const controls = signal({
|
|
61
68
|
down: {
|
|
62
69
|
repeat: true,
|
|
@@ -99,10 +106,28 @@
|
|
|
99
106
|
},
|
|
100
107
|
});
|
|
101
108
|
|
|
109
|
+
const smoothX = animatedSignal(x(), {
|
|
110
|
+
duration: isMe() ? 0 : 0
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const smoothY = animatedSignal(y(), {
|
|
114
|
+
duration: isMe() ? 0 : 0,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const realAnimationName = signal(animationName());
|
|
118
|
+
|
|
119
|
+
const xSubscription = x.observable.subscribe((value) => {
|
|
120
|
+
smoothX.set(value);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const ySubscription = y.observable.subscribe((value) => {
|
|
124
|
+
smoothY.set(value);
|
|
125
|
+
});
|
|
126
|
+
|
|
102
127
|
const sheet = (graphicId) => {
|
|
103
128
|
return {
|
|
104
|
-
definition:
|
|
105
|
-
playing:
|
|
129
|
+
definition: client.getSpriteSheet(graphicId),
|
|
130
|
+
playing: realAnimationName,
|
|
106
131
|
params: {
|
|
107
132
|
direction
|
|
108
133
|
},
|
|
@@ -112,27 +137,62 @@
|
|
|
112
137
|
};
|
|
113
138
|
}
|
|
114
139
|
|
|
115
|
-
//
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
140
|
+
// Combine animation change detection with movement state from smoothX/smoothY
|
|
141
|
+
const movementAnimations = ['walk', 'stand'];
|
|
142
|
+
const epsilon = 0; // movement threshold to consider the easing still running
|
|
143
|
+
|
|
144
|
+
const stateX$ = smoothX.animatedState.observable;
|
|
145
|
+
const stateY$ = smoothY.animatedState.observable;
|
|
146
|
+
const animationName$ = animationName.observable;
|
|
147
|
+
|
|
148
|
+
const moving$ = combineLatest([stateX$, stateY$]).pipe(
|
|
149
|
+
map(([sx, sy]) => {
|
|
150
|
+
const xFinished = Math.abs(sx.value.current - sx.value.end) <= epsilon;
|
|
151
|
+
const yFinished = Math.abs(sy.value.current - sy.value.end) <= epsilon;
|
|
152
|
+
return !xFinished || !yFinished; // moving if X or Y is not finished
|
|
153
|
+
}),
|
|
154
|
+
startWith(false)
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
const animationChange$ = animationName$.pipe(
|
|
158
|
+
startWith(animationName()),
|
|
159
|
+
pairwise(),
|
|
160
|
+
filter(([prev, curr]) => prev !== curr)
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
const animationMovementSubscription = combineLatest([animationChange$, moving$]).subscribe(([[prev, curr], isMoving]) => {
|
|
164
|
+
if (curr == 'stand' && !isMoving) {
|
|
165
|
+
realAnimationName.set(curr);
|
|
166
|
+
}
|
|
167
|
+
else if (curr == 'walk' && isMoving) {
|
|
168
|
+
realAnimationName.set(curr);
|
|
169
|
+
}
|
|
170
|
+
else if (!movementAnimations.includes(curr)) {
|
|
171
|
+
realAnimationName.set(curr);
|
|
172
|
+
}
|
|
173
|
+
if (!isMoving && object.animationIsPlaying && object.animationIsPlaying()) {
|
|
174
|
+
if (movementAnimations.includes(curr)) {
|
|
125
175
|
if (typeof object.resetAnimationState === 'function') {
|
|
126
176
|
object.resetAnimationState();
|
|
127
177
|
}
|
|
128
178
|
}
|
|
129
179
|
}
|
|
130
|
-
|
|
131
|
-
previousAnimationName = currentAnimationName;
|
|
132
|
-
|
|
133
180
|
});
|
|
134
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Cleanup subscriptions and call hooks before sprite destruction.
|
|
184
|
+
*
|
|
185
|
+
* # Design
|
|
186
|
+
* - Prevent memory leaks by unsubscribing from all local subscriptions created in this component.
|
|
187
|
+
* - Execute destruction hooks to notify modules and scene map of sprite removal.
|
|
188
|
+
*
|
|
189
|
+
* @example
|
|
190
|
+
* await onBeforeDestroy();
|
|
191
|
+
*/
|
|
135
192
|
const onBeforeDestroy = async () => {
|
|
193
|
+
animationMovementSubscription.unsubscribe();
|
|
194
|
+
xSubscription.unsubscribe();
|
|
195
|
+
ySubscription.unsubscribe();
|
|
136
196
|
await lastValueFrom(hooks.callHooks("client-sprite-onDestroy", object))
|
|
137
197
|
await lastValueFrom(hooks.callHooks("client-sceneMap-onRemoveSprite", client.sceneMap, object))
|
|
138
198
|
}
|
|
@@ -78,7 +78,6 @@
|
|
|
78
78
|
}
|
|
79
79
|
const dialogBoxTypewriterSound = client.globalConfig?.box?.sounds?.typewriter
|
|
80
80
|
|
|
81
|
-
const spritesheets = client.spritesheets;
|
|
82
81
|
const sounds = client.sounds;
|
|
83
82
|
|
|
84
83
|
client.stopProcessingInput = true;
|
|
@@ -140,7 +139,7 @@
|
|
|
140
139
|
|
|
141
140
|
const faceSheet = (graphicId, animationName) => {
|
|
142
141
|
return {
|
|
143
|
-
definition:
|
|
142
|
+
definition: client.getSpriteSheet(graphicId),
|
|
144
143
|
playing: animationName,
|
|
145
144
|
};
|
|
146
145
|
}
|