@rpgjs/common 5.0.0-alpha.4 → 5.0.0-alpha.41
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/PerlinNoise.d.ts +167 -0
- package/dist/Player.d.ts +92 -89
- package/dist/PrebuiltGui.d.ts +3 -1
- package/dist/Presets.d.ts +9 -0
- package/dist/Shape.d.ts +100 -0
- package/dist/Utils.d.ts +1 -0
- package/dist/database/Item.d.ts +17 -1
- package/dist/database/Skill.d.ts +21 -0
- package/dist/database/index.d.ts +1 -0
- package/dist/index.d.ts +7 -2
- package/dist/index.js +8994 -13678
- package/dist/index.js.map +1 -1
- package/dist/modules.d.ts +17 -0
- package/dist/movement/MovementManager.d.ts +16 -72
- package/dist/movement/MovementStrategy.d.ts +1 -39
- package/dist/movement/index.d.ts +3 -12
- package/dist/rooms/Map.d.ts +512 -21
- package/dist/rooms/WorldMaps.d.ts +163 -0
- package/dist/services/save.d.ts +12 -0
- package/dist/weather.d.ts +27 -0
- package/package.json +8 -9
- package/src/PerlinNoise.ts +294 -0
- package/src/Player.ts +126 -149
- package/src/PrebuiltGui.ts +4 -2
- package/src/Presets.ts +9 -0
- package/src/Shape.ts +149 -0
- package/src/Utils.ts +4 -0
- package/src/database/Item.ts +27 -6
- package/src/database/Skill.ts +35 -0
- package/src/database/index.ts +2 -1
- package/src/index.ts +8 -3
- package/src/modules.ts +29 -1
- package/src/movement/MovementManager.ts +38 -119
- package/src/movement/MovementStrategy.ts +4 -42
- package/src/movement/index.ts +14 -15
- package/src/rooms/Map.ts +1624 -138
- package/src/rooms/WorldMaps.ts +269 -0
- package/src/services/save.ts +14 -0
- package/src/weather.ts +29 -0
- package/dist/Physic.d.ts +0 -619
- package/dist/movement/strategies/CompositeMovement.d.ts +0 -76
- package/dist/movement/strategies/Dash.d.ts +0 -52
- package/dist/movement/strategies/IceMovement.d.ts +0 -87
- package/dist/movement/strategies/Knockback.d.ts +0 -50
- package/dist/movement/strategies/LinearMove.d.ts +0 -43
- package/dist/movement/strategies/LinearRepulsion.d.ts +0 -55
- package/dist/movement/strategies/Oscillate.d.ts +0 -60
- package/dist/movement/strategies/PathFollow.d.ts +0 -78
- package/dist/movement/strategies/ProjectileMovement.d.ts +0 -138
- package/dist/movement/strategies/SeekAvoid.d.ts +0 -27
- package/src/Physic.ts +0 -1644
- package/src/movement/strategies/CompositeMovement.ts +0 -173
- package/src/movement/strategies/Dash.ts +0 -82
- package/src/movement/strategies/IceMovement.ts +0 -158
- package/src/movement/strategies/Knockback.ts +0 -81
- package/src/movement/strategies/LinearMove.ts +0 -58
- package/src/movement/strategies/LinearRepulsion.ts +0 -128
- package/src/movement/strategies/Oscillate.ts +0 -144
- package/src/movement/strategies/PathFollow.ts +0 -156
- package/src/movement/strategies/ProjectileMovement.ts +0 -322
- package/src/movement/strategies/SeekAvoid.ts +0 -123
- package/tests/physic.spec.ts +0 -454
package/src/rooms/Map.ts
CHANGED
|
@@ -1,18 +1,170 @@
|
|
|
1
1
|
import { generateShortUUID, users } from "@signe/sync";
|
|
2
2
|
import { effect, Signal, signal } from "@signe/reactive";
|
|
3
3
|
import { Direction, RpgCommonPlayer } from "../Player";
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
import {
|
|
5
|
+
PhysicsEngine,
|
|
6
|
+
Vector2,
|
|
7
|
+
Entity,
|
|
8
|
+
EntityState,
|
|
9
|
+
assignPolygonCollider,
|
|
10
|
+
AABB,
|
|
11
|
+
createCollider,
|
|
12
|
+
} from "@rpgjs/physic";
|
|
13
|
+
import { combineLatest, Observable, share, Subject, Subscription } from "rxjs";
|
|
14
|
+
import { MovementManager } from "../movement";
|
|
15
|
+
import { WorldMapsManager, type RpgWorldMaps } from "./WorldMaps";
|
|
16
|
+
|
|
17
|
+
export type PhysicsEntityKind = "hero" | "npc" | "generic";
|
|
18
|
+
|
|
19
|
+
export interface MapPhysicsInitContext {
|
|
20
|
+
mapData: any;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface MapPhysicsEntityContext {
|
|
24
|
+
owner: any;
|
|
25
|
+
entity: Entity;
|
|
26
|
+
kind: PhysicsEntityKind;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface ZoneOptions {
|
|
30
|
+
x?: number;
|
|
31
|
+
y?: number;
|
|
32
|
+
radius: number;
|
|
33
|
+
angle?: number;
|
|
34
|
+
direction?: "up" | "down" | "left" | "right";
|
|
35
|
+
linkedTo?: string;
|
|
36
|
+
limitedByWalls?: boolean;
|
|
37
|
+
}
|
|
7
38
|
|
|
8
39
|
export abstract class RpgCommonMap<T extends RpgCommonPlayer> {
|
|
9
40
|
abstract players: Signal<Record<string, T>>;
|
|
10
41
|
abstract events: Signal<Record<string, any>>;
|
|
11
|
-
|
|
42
|
+
|
|
12
43
|
data = signal<any | null>(null);
|
|
13
|
-
physic = new
|
|
14
|
-
|
|
44
|
+
physic = new PhysicsEngine({
|
|
45
|
+
timeStep: 1 / 60,
|
|
46
|
+
gravity: new Vector2(0, 0),
|
|
47
|
+
enableSleep: false,
|
|
48
|
+
});
|
|
49
|
+
moveManager = new MovementManager(() => this.physic);
|
|
50
|
+
|
|
51
|
+
private speedScalar = 50; // Default speed scalar for movement
|
|
52
|
+
|
|
53
|
+
// World Maps properties
|
|
54
|
+
tileWidth: number = 32;
|
|
55
|
+
tileHeight: number = 32;
|
|
56
|
+
worldMapsManager?: WorldMapsManager;
|
|
57
|
+
|
|
58
|
+
// Synchronization throttling properties
|
|
59
|
+
throttleSync?: number;
|
|
60
|
+
throttleStorage?: number;
|
|
61
|
+
sessionExpiryTime?: number;
|
|
62
|
+
|
|
63
|
+
tickSubscription?: Subscription | null;
|
|
64
|
+
playersSubscription?: Subscription | null;
|
|
65
|
+
eventsSubscription?: Subscription | null;
|
|
66
|
+
private physicsAccumulatorMs = 0;
|
|
67
|
+
private physicsSyncDepth = 0;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Whether to automatically subscribe to tick$ for physics updates
|
|
71
|
+
* Set to false in test environments for manual control with nextTick()
|
|
72
|
+
*/
|
|
73
|
+
protected autoTickEnabled: boolean = true;
|
|
74
|
+
|
|
75
|
+
get isStandalone() {
|
|
76
|
+
return typeof window !== 'undefined'
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Get the width of the map in pixels
|
|
82
|
+
*
|
|
83
|
+
* @returns The width of the map in pixels, or 0 if not loaded
|
|
84
|
+
*
|
|
85
|
+
* @example
|
|
86
|
+
* ```ts
|
|
87
|
+
* const width = map.widthPx;
|
|
88
|
+
* console.log(`Map width: ${width}px`);
|
|
89
|
+
* ```
|
|
90
|
+
*/
|
|
91
|
+
get widthPx(): number {
|
|
92
|
+
return this.data()?.width ?? 0
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Get the height of the map in pixels
|
|
97
|
+
*
|
|
98
|
+
* @returns The height of the map in pixels, or 0 if not loaded
|
|
99
|
+
*
|
|
100
|
+
* @example
|
|
101
|
+
* ```ts
|
|
102
|
+
* const height = map.heightPx;
|
|
103
|
+
* console.log(`Map height: ${height}px`);
|
|
104
|
+
* ```
|
|
105
|
+
*/
|
|
106
|
+
get heightPx(): number {
|
|
107
|
+
return this.data()?.height ?? 0
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Get the unique identifier of the map
|
|
112
|
+
*
|
|
113
|
+
* @returns The map ID, or empty string if not loaded
|
|
114
|
+
*
|
|
115
|
+
* @example
|
|
116
|
+
* ```ts
|
|
117
|
+
* const mapId = map.id;
|
|
118
|
+
* console.log(`Current map: ${mapId}`);
|
|
119
|
+
* ```
|
|
120
|
+
*/
|
|
121
|
+
get id(): string {
|
|
122
|
+
return this.data()?.id ?? ''
|
|
123
|
+
}
|
|
15
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Get the X position of this map in the world coordinate system
|
|
127
|
+
*
|
|
128
|
+
* This is used when maps are part of a larger world map. The world position
|
|
129
|
+
* indicates where this map is located relative to other maps.
|
|
130
|
+
*
|
|
131
|
+
* @returns The X position in world coordinates, or 0 if not in a world
|
|
132
|
+
*
|
|
133
|
+
* @example
|
|
134
|
+
* ```ts
|
|
135
|
+
* const worldX = map.worldX;
|
|
136
|
+
* console.log(`Map is at world position (${worldX}, ${map.worldY})`);
|
|
137
|
+
* ```
|
|
138
|
+
*/
|
|
139
|
+
get worldX(): number {
|
|
140
|
+
const worldMaps = this.getWorldMapsManager?.();
|
|
141
|
+
if (!worldMaps) return 0;
|
|
142
|
+
// Extract real map ID (remove "map-" prefix if present)
|
|
143
|
+
const mapId = this.id.startsWith('map-') ? this.id.slice(4) : this.id;
|
|
144
|
+
return worldMaps.getMapInfo(mapId)?.worldX ?? 0
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Get the Y position of this map in the world coordinate system
|
|
149
|
+
*
|
|
150
|
+
* This is used when maps are part of a larger world map. The world position
|
|
151
|
+
* indicates where this map is located relative to other maps.
|
|
152
|
+
*
|
|
153
|
+
* @returns The Y position in world coordinates, or 0 if not in a world
|
|
154
|
+
*
|
|
155
|
+
* @example
|
|
156
|
+
* ```ts
|
|
157
|
+
* const worldY = map.worldY;
|
|
158
|
+
* console.log(`Map is at world position (${map.worldX}, ${worldY})`);
|
|
159
|
+
* ```
|
|
160
|
+
*/
|
|
161
|
+
get worldY(): number {
|
|
162
|
+
const worldMaps = this.getWorldMapsManager?.();
|
|
163
|
+
if (!worldMaps) return 0;
|
|
164
|
+
// Extract real map ID (remove "map-" prefix if present)
|
|
165
|
+
const mapId = this.id.startsWith('map-') ? this.id.slice(4) : this.id;
|
|
166
|
+
return worldMaps.getMapInfo(mapId)?.worldY ?? 0
|
|
167
|
+
}
|
|
16
168
|
|
|
17
169
|
/**
|
|
18
170
|
* Observable representing the game loop tick
|
|
@@ -21,12 +173,33 @@ export abstract class RpgCommonMap<T extends RpgCommonPlayer> {
|
|
|
21
173
|
* It's shared using the share() operator, meaning that all subscribers will receive
|
|
22
174
|
* events from a single interval rather than creating multiple intervals.
|
|
23
175
|
*
|
|
176
|
+
* ## Physics Loop Architecture
|
|
177
|
+
*
|
|
178
|
+
* The physics simulation is centralized in this game loop:
|
|
179
|
+
*
|
|
180
|
+
* 1. **Input Processing** (`processInput`): Only updates entity velocities, does NOT step physics
|
|
181
|
+
* 2. **Game Loop** (`tick$` -> `runFixedTicks`): Executes physics simulation with fixed timestep
|
|
182
|
+
* 3. **Fixed Timestep Pattern**: Accumulator-based approach ensures deterministic physics
|
|
183
|
+
*
|
|
184
|
+
* ```
|
|
185
|
+
* Input Events ─────────────────────────────────────────────────────────────►
|
|
186
|
+
* │
|
|
187
|
+
* ▼ (update velocity only)
|
|
188
|
+
* ┌─────────────────────────────────────────────────────────────────────────┐
|
|
189
|
+
* │ Game Loop (tick$) │
|
|
190
|
+
* │ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
|
|
191
|
+
* │ │ updateMovements │ → │ stepOneTick │ → │ postTickUpdates │ │
|
|
192
|
+
* │ │ (apply velocity)│ │ (physics step) │ │ (zones, sync) │ │
|
|
193
|
+
* │ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
|
|
194
|
+
* └─────────────────────────────────────────────────────────────────────────┘
|
|
195
|
+
* ```
|
|
196
|
+
*
|
|
24
197
|
* @example
|
|
25
198
|
* ```ts
|
|
26
|
-
* // Subscribe to the game tick
|
|
27
|
-
* map.tick$.subscribe(timestamp => {
|
|
28
|
-
* //
|
|
29
|
-
* this.
|
|
199
|
+
* // Subscribe to the game tick for custom updates
|
|
200
|
+
* map.tick$.subscribe(({ delta, timestamp }) => {
|
|
201
|
+
* // Custom game logic runs alongside physics
|
|
202
|
+
* this.updateCustomEntities(delta);
|
|
30
203
|
* });
|
|
31
204
|
* ```
|
|
32
205
|
*/
|
|
@@ -42,76 +215,243 @@ export abstract class RpgCommonMap<T extends RpgCommonPlayer> {
|
|
|
42
215
|
share()
|
|
43
216
|
);
|
|
44
217
|
|
|
218
|
+
/**
|
|
219
|
+
* Clear all physics content and reset to initial state
|
|
220
|
+
*
|
|
221
|
+
* This method completely clears the physics system by:
|
|
222
|
+
* - Removing all hitboxes (static and movable)
|
|
223
|
+
* - Removing all zones
|
|
224
|
+
* - Clearing all collision data and events
|
|
225
|
+
* - Clearing all movement events and sliding data
|
|
226
|
+
* - Unsubscribing from the tick subscription
|
|
227
|
+
* - Resetting the physics engine to a clean state
|
|
228
|
+
*
|
|
229
|
+
* Use this method when you need to completely reset the map's physics
|
|
230
|
+
* system, such as when changing maps or restarting a level.
|
|
231
|
+
*
|
|
232
|
+
* @example
|
|
233
|
+
* ```ts
|
|
234
|
+
* // Clear all physics when changing maps
|
|
235
|
+
* map.clearPhysic();
|
|
236
|
+
*
|
|
237
|
+
* // Then reload physics for the new map
|
|
238
|
+
* map.loadPhysic();
|
|
239
|
+
* ```
|
|
240
|
+
*/
|
|
241
|
+
clearPhysic() {
|
|
242
|
+
// Unsubscribe from tick to stop physics updates
|
|
243
|
+
if (this.tickSubscription) {
|
|
244
|
+
this.tickSubscription.unsubscribe();
|
|
245
|
+
this.tickSubscription = null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (this.playersSubscription) {
|
|
249
|
+
this.playersSubscription.unsubscribe();
|
|
250
|
+
this.playersSubscription = null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (this.eventsSubscription) {
|
|
254
|
+
this.eventsSubscription.unsubscribe();
|
|
255
|
+
this.eventsSubscription = null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Clear all hitboxes and zones from physics system
|
|
259
|
+
this.clearAll();
|
|
260
|
+
|
|
261
|
+
// Reset movement manager
|
|
262
|
+
this.moveManager.clearAll();
|
|
263
|
+
this.emitPhysicsReset();
|
|
264
|
+
|
|
265
|
+
this.physicsAccumulatorMs = 0;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Clear all physics entities and internal state
|
|
270
|
+
* @private
|
|
271
|
+
*/
|
|
272
|
+
private clearAll(): void {
|
|
273
|
+
// Remove all entities from physics engine
|
|
274
|
+
const entities = this.physic.getEntities();
|
|
275
|
+
for (const entity of entities) {
|
|
276
|
+
const owner = (entity as any).owner;
|
|
277
|
+
if (owner) {
|
|
278
|
+
this.emitPhysicsEntityRemove({
|
|
279
|
+
owner,
|
|
280
|
+
entity,
|
|
281
|
+
kind: this.resolvePhysicsEntityKind(owner, entity.uuid),
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
this.physic.removeEntity(entity);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Clear movement manager and zone manager
|
|
288
|
+
this.physic.getMovementManager().clearAll();
|
|
289
|
+
this.physic.getZoneManager().clear();
|
|
290
|
+
}
|
|
291
|
+
|
|
45
292
|
loadPhysic() {
|
|
46
|
-
|
|
293
|
+
this.clearPhysic();
|
|
294
|
+
|
|
295
|
+
const mapData = this.data?.();
|
|
296
|
+
const mapWidth = typeof mapData?.width === "number" ? mapData.width : 0;
|
|
297
|
+
const mapHeight = typeof mapData?.height === "number" ? mapData.height : 0;
|
|
298
|
+
const hitboxes: Array<
|
|
299
|
+
| { id?: string; x: number; y: number; width: number; height: number }
|
|
300
|
+
| { id?: string; points: number[][] }
|
|
301
|
+
> = Array.isArray(mapData?.hitboxes) ? mapData.hitboxes : [];
|
|
47
302
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
303
|
+
if (mapWidth > 0 && mapHeight > 0) {
|
|
304
|
+
const gap = 100;
|
|
305
|
+
this.addStaticHitbox('map-width-left', -gap, 0, gap, mapHeight);
|
|
306
|
+
this.addStaticHitbox('map-width-right', mapWidth, 0, gap, mapHeight);
|
|
307
|
+
this.addStaticHitbox('map-height-top', 0, -gap, mapWidth, gap);
|
|
308
|
+
this.addStaticHitbox('map-height-bottom', 0, mapHeight, mapWidth, gap);
|
|
309
|
+
}
|
|
53
310
|
|
|
54
311
|
for (let staticHitbox of hitboxes) {
|
|
55
|
-
|
|
312
|
+
if ('x' in staticHitbox) {
|
|
313
|
+
this.addStaticHitbox(staticHitbox.id ?? generateShortUUID(), staticHitbox.x, staticHitbox.y, staticHitbox.width, staticHitbox.height);
|
|
314
|
+
}
|
|
315
|
+
else if ('points' in staticHitbox) {
|
|
316
|
+
this.addStaticHitbox(staticHitbox.id ?? generateShortUUID(), staticHitbox.points);
|
|
317
|
+
}
|
|
56
318
|
}
|
|
57
319
|
|
|
58
|
-
this.
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
320
|
+
this.emitPhysicsInit({ mapData });
|
|
321
|
+
|
|
322
|
+
this.playersSubscription = (this.players as any).observable.subscribe(
|
|
323
|
+
({ value: player, type, key }: any) => {
|
|
324
|
+
if (type === "remove") {
|
|
325
|
+
this.removeHitbox(key, player, "hero");
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (type == 'reset') {
|
|
330
|
+
if (!player) return;
|
|
331
|
+
for (let id in player) {
|
|
332
|
+
const _player = player[id]
|
|
333
|
+
_player.id = _player.id ?? id;
|
|
334
|
+
this.createCharacterHitbox(_player, "hero");
|
|
335
|
+
}
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (!player) return;
|
|
340
|
+
if (type === "add") {
|
|
341
|
+
player.id = key;
|
|
342
|
+
this.createCharacterHitbox(player, "hero");
|
|
343
|
+
} else if (type === "update") {
|
|
344
|
+
player.id = player.id ?? key;
|
|
345
|
+
if (!this.getBody(key)) {
|
|
346
|
+
this.createCharacterHitbox(player, "hero");
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if (this.isPhysicsSyncingSignals) {
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
this.updateCharacterHitbox(player);
|
|
353
|
+
}
|
|
354
|
+
},
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
this.eventsSubscription = this.events.observable.subscribe(({ value: event, type, key }) => {
|
|
358
|
+
if (type === "add") {
|
|
359
|
+
event.id = key;
|
|
360
|
+
this.createCharacterHitbox(event, "npc", {
|
|
361
|
+
mass: 100,
|
|
62
362
|
});
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
363
|
+
} else if (type === "remove") {
|
|
364
|
+
// Clean up movement event subscriptions
|
|
365
|
+
const eventObj = this.getObjectById(key);
|
|
366
|
+
if (eventObj && typeof (eventObj as any)._movementUnsubscribe === 'function') {
|
|
367
|
+
(eventObj as any)._movementUnsubscribe();
|
|
368
|
+
}
|
|
369
|
+
this.removeHitbox(key, event, "npc");
|
|
370
|
+
} else if (type === "update") {
|
|
371
|
+
event.id = event.id ?? key;
|
|
372
|
+
if (!this.getBody(key)) {
|
|
373
|
+
this.createCharacterHitbox(event, "npc", {
|
|
374
|
+
mass: 100,
|
|
375
|
+
});
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
this.updateCharacterHitbox(event);
|
|
379
|
+
} else if (type === "reset") {
|
|
380
|
+
for (const id in event) {
|
|
381
|
+
const _event = event[id];
|
|
382
|
+
if (!_event) continue;
|
|
383
|
+
_event.id = _event.id ?? id;
|
|
384
|
+
this.createCharacterHitbox(_event, "npc", {
|
|
385
|
+
mass: 100,
|
|
386
|
+
});
|
|
387
|
+
}
|
|
71
388
|
}
|
|
72
389
|
});
|
|
73
390
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
391
|
+
// Hydrate physics world with already-loaded scene objects.
|
|
392
|
+
// This covers cases where sync state is present before subscriptions are attached.
|
|
393
|
+
const players = this.players();
|
|
394
|
+
for (const id in players) {
|
|
395
|
+
const player = players[id];
|
|
396
|
+
if (!player) continue;
|
|
397
|
+
player.id = player.id ?? id;
|
|
398
|
+
this.createCharacterHitbox(player, "hero");
|
|
399
|
+
}
|
|
79
400
|
|
|
80
|
-
|
|
81
|
-
|
|
401
|
+
const events = this.events();
|
|
402
|
+
for (const id in events) {
|
|
403
|
+
const event = events[id];
|
|
404
|
+
if (!event) continue;
|
|
405
|
+
event.id = event.id ?? id;
|
|
406
|
+
this.createCharacterHitbox(event, "npc", {
|
|
407
|
+
mass: 100,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// S'abonner au ticker automatique seulement si autoTickEnabled est true
|
|
412
|
+
if (this.autoTickEnabled) {
|
|
413
|
+
this.tickSubscription = this.tick$.subscribe(({ delta }) => {
|
|
414
|
+
this.runFixedTicks(delta);
|
|
415
|
+
});
|
|
416
|
+
}
|
|
82
417
|
}
|
|
83
418
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
* @returns Boolean indicating success
|
|
93
|
-
*
|
|
94
|
-
* @example
|
|
95
|
-
* ```ts
|
|
96
|
-
* // Register movement events for the player
|
|
97
|
-
* map.registerMovementEvents('player1',
|
|
98
|
-
* () => console.log('Player started moving'),
|
|
99
|
-
* () => console.log('Player stopped moving')
|
|
100
|
-
* );
|
|
101
|
-
* ```
|
|
102
|
-
*/
|
|
103
|
-
registerMovementEvents(
|
|
104
|
-
id: string,
|
|
105
|
-
onStartMoving?: () => void,
|
|
106
|
-
onStopMoving?: () => void
|
|
107
|
-
): boolean {
|
|
108
|
-
// Check if the object with this ID exists
|
|
109
|
-
const object = this.getObjectById(id);
|
|
110
|
-
if (!object) return false;
|
|
419
|
+
async movePlayer(player: T, direction: Direction) {
|
|
420
|
+
// Calculate next position before movement
|
|
421
|
+
const currentX = player.x();
|
|
422
|
+
const currentY = player.y();
|
|
423
|
+
const speed = player.speed();
|
|
424
|
+
|
|
425
|
+
let nextX = currentX;
|
|
426
|
+
let nextY = currentY;
|
|
111
427
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
428
|
+
switch (direction) {
|
|
429
|
+
case Direction.Left:
|
|
430
|
+
nextX = currentX - speed;
|
|
431
|
+
break;
|
|
432
|
+
case Direction.Right:
|
|
433
|
+
nextX = currentX + speed;
|
|
434
|
+
break;
|
|
435
|
+
case Direction.Up:
|
|
436
|
+
nextY = currentY - speed;
|
|
437
|
+
break;
|
|
438
|
+
case Direction.Down:
|
|
439
|
+
nextY = currentY + speed;
|
|
440
|
+
break;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
player.changeDirection(direction);
|
|
444
|
+
|
|
445
|
+
// Check for automatic map change if the method exists
|
|
446
|
+
if (typeof (player as any).autoChangeMap === 'function' && !player.isEvent()) {
|
|
447
|
+
const mapChanged = await (player as any).autoChangeMap({ x: nextX, y: nextY }, direction);
|
|
448
|
+
if (mapChanged) {
|
|
449
|
+
this.stopMovement(player);
|
|
450
|
+
return
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
this.moveBody(player, direction);
|
|
115
455
|
}
|
|
116
456
|
|
|
117
457
|
/**
|
|
@@ -129,7 +469,7 @@ export abstract class RpgCommonMap<T extends RpgCommonPlayer> {
|
|
|
129
469
|
* ```
|
|
130
470
|
*/
|
|
131
471
|
isMoving(id: string): boolean {
|
|
132
|
-
return this.
|
|
472
|
+
return this.isEntityMoving(id);
|
|
133
473
|
}
|
|
134
474
|
|
|
135
475
|
getObjectById(id: string) {
|
|
@@ -137,121 +477,483 @@ export abstract class RpgCommonMap<T extends RpgCommonPlayer> {
|
|
|
137
477
|
}
|
|
138
478
|
|
|
139
479
|
/**
|
|
140
|
-
*
|
|
480
|
+
* Execute physics simulation with fixed timestep
|
|
141
481
|
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
482
|
+
* This method runs the physics engine using a fixed timestep accumulator pattern.
|
|
483
|
+
* It ensures deterministic physics regardless of frame rate by:
|
|
484
|
+
* 1. Accumulating delta time
|
|
485
|
+
* 2. Running fixed-size physics steps until the accumulator is depleted
|
|
486
|
+
* 3. Calling `updateMovements()` before each step to apply velocity changes
|
|
487
|
+
* 4. Running post-tick updates (zones, callbacks) after each step
|
|
145
488
|
*
|
|
146
|
-
*
|
|
147
|
-
* at the given speed, detecting collisions with players and events at each step.
|
|
489
|
+
* ## Architecture
|
|
148
490
|
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
491
|
+
* The physics loop is centralized here and called from `tick$` subscription.
|
|
492
|
+
* Input processing (`processInput`) only updates entity velocities - it does NOT
|
|
493
|
+
* step the physics. This ensures:
|
|
494
|
+
* - Consistent physics timing (60fps fixed timestep)
|
|
495
|
+
* - No double-stepping when inputs are processed
|
|
496
|
+
* - Proper accumulator-based interpolation support
|
|
497
|
+
*
|
|
498
|
+
* @param deltaMs - Time elapsed since last call in milliseconds
|
|
499
|
+
* @param hooks - Optional callbacks for before/after each physics step
|
|
500
|
+
* @returns Number of physics ticks executed
|
|
152
501
|
*
|
|
153
502
|
* @example
|
|
154
503
|
* ```ts
|
|
155
|
-
* //
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
* }
|
|
164
|
-
* complete() {
|
|
165
|
-
* console.log('Movement finished');
|
|
166
|
-
* }
|
|
504
|
+
* // Called automatically by tick$ subscription
|
|
505
|
+
* this.tickSubscription = this.tick$.subscribe(({ delta }) => {
|
|
506
|
+
* this.runFixedTicks(delta);
|
|
507
|
+
* });
|
|
508
|
+
*
|
|
509
|
+
* // Or manually with hooks for debugging
|
|
510
|
+
* this.runFixedTicks(16, {
|
|
511
|
+
* beforeStep: () => console.log('Before physics step'),
|
|
512
|
+
* afterStep: (tick) => console.log(`Physics tick ${tick} completed`)
|
|
167
513
|
* });
|
|
168
514
|
* ```
|
|
169
515
|
*/
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
return;
|
|
181
|
-
}
|
|
516
|
+
protected runFixedTicks(
|
|
517
|
+
deltaMs: number,
|
|
518
|
+
hooks?: {
|
|
519
|
+
beforeStep?: () => void;
|
|
520
|
+
afterStep?: (tick: number) => void;
|
|
521
|
+
},
|
|
522
|
+
): number {
|
|
523
|
+
if (!Number.isFinite(deltaMs) || deltaMs <= 0) {
|
|
524
|
+
return 0;
|
|
525
|
+
}
|
|
182
526
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
527
|
+
const fixedStepMs = this.physic.getWorld().getTimeStep() * 1000;
|
|
528
|
+
this.physicsAccumulatorMs += deltaMs;
|
|
529
|
+
let executed = 0;
|
|
186
530
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
531
|
+
while (this.physicsAccumulatorMs >= fixedStepMs) {
|
|
532
|
+
this.physicsAccumulatorMs -= fixedStepMs;
|
|
533
|
+
hooks?.beforeStep?.();
|
|
190
534
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
535
|
+
// Update movements before physics step (applies velocity changes from inputs)
|
|
536
|
+
this.physic.updateMovements();
|
|
537
|
+
|
|
538
|
+
const tick = this.physic.stepOneTick();
|
|
539
|
+
executed += 1;
|
|
540
|
+
|
|
541
|
+
// Run post-tick updates (zones, position sync callbacks)
|
|
542
|
+
this.runPostTickUpdates();
|
|
543
|
+
|
|
544
|
+
hooks?.afterStep?.(tick);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
return executed;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Manually trigger a single game tick
|
|
552
|
+
*
|
|
553
|
+
* This method allows you to manually advance the game by one tick (16ms at 60fps).
|
|
554
|
+
* It's primarily useful for testing where you need precise control over when
|
|
555
|
+
* physics updates occur, rather than relying on the automatic tick$ subscription.
|
|
556
|
+
*
|
|
557
|
+
* ## Use Cases
|
|
558
|
+
*
|
|
559
|
+
* - **Testing**: Control exactly when physics steps occur in unit tests
|
|
560
|
+
* - **Manual control**: Step through game state manually for debugging
|
|
561
|
+
* - **Deterministic testing**: Ensure consistent timing in test scenarios
|
|
562
|
+
*
|
|
563
|
+
* ## Important
|
|
564
|
+
*
|
|
565
|
+
* This method should NOT be used in production code alongside the automatic `tick$`
|
|
566
|
+
* subscription, as it will cause double-stepping. Use either:
|
|
567
|
+
* - Automatic ticks (via `loadPhysic()` which subscribes to `tick$`)
|
|
568
|
+
* - Manual ticks (via `nextTick()` without `loadPhysic()` subscription)
|
|
569
|
+
*
|
|
570
|
+
* @param deltaMs - Optional delta time in milliseconds (default: 16ms for 60fps)
|
|
571
|
+
* @returns Number of physics ticks executed
|
|
572
|
+
*
|
|
573
|
+
* @example
|
|
574
|
+
* ```ts
|
|
575
|
+
* // In tests: manually advance game by one tick
|
|
576
|
+
* map.nextTick(); // Advances by 16ms (one frame at 60fps)
|
|
577
|
+
*
|
|
578
|
+
* // With custom delta
|
|
579
|
+
* map.nextTick(32); // Advances by 32ms (two frames at 60fps)
|
|
580
|
+
*
|
|
581
|
+
* // In a test loop
|
|
582
|
+
* for (let i = 0; i < 60; i++) {
|
|
583
|
+
* map.nextTick(); // Simulate 1 second of game time
|
|
584
|
+
* }
|
|
585
|
+
* ```
|
|
586
|
+
*/
|
|
587
|
+
nextTick(deltaMs: number = 16): number {
|
|
588
|
+
return this.runFixedTicks(deltaMs);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Force a single physics tick outside of the normal game loop
|
|
593
|
+
*
|
|
594
|
+
* This method is primarily used for **client-side prediction** where the client
|
|
595
|
+
* needs to immediately simulate physics in response to local input, rather than
|
|
596
|
+
* waiting for the next game loop tick.
|
|
597
|
+
*
|
|
598
|
+
* ## Use Cases
|
|
599
|
+
*
|
|
600
|
+
* - **Client-side prediction**: Immediately simulate player movement for responsive feel
|
|
601
|
+
* - **Testing**: Force a physics step in unit tests
|
|
602
|
+
* - **Special effects**: Immediate physics response for specific game events
|
|
603
|
+
*
|
|
604
|
+
* ## Important
|
|
605
|
+
*
|
|
606
|
+
* This method should NOT be used on the server for normal input processing.
|
|
607
|
+
* Server-side physics is handled by `runFixedTicks` in the main game loop to ensure
|
|
608
|
+
* deterministic simulation.
|
|
609
|
+
*
|
|
610
|
+
* @param hooks - Optional callbacks for before/after the physics step
|
|
611
|
+
* @returns The physics tick number
|
|
612
|
+
*
|
|
613
|
+
* @example
|
|
614
|
+
* ```ts
|
|
615
|
+
* // Client-side: immediately simulate predicted movement
|
|
616
|
+
* class RpgClientMap extends RpgCommonMap {
|
|
617
|
+
* stepPredictionTick(): void {
|
|
618
|
+
* this.forceSingleTick();
|
|
619
|
+
* }
|
|
620
|
+
* }
|
|
621
|
+
* ```
|
|
622
|
+
*/
|
|
623
|
+
protected forceSingleTick(hooks?: { beforeStep?: () => void; afterStep?: (tick: number) => void }): number {
|
|
624
|
+
hooks?.beforeStep?.();
|
|
625
|
+
this.physic.updateMovements();
|
|
626
|
+
const tick = this.physic.stepOneTick();
|
|
627
|
+
this.runPostTickUpdates();
|
|
628
|
+
hooks?.afterStep?.(tick);
|
|
629
|
+
const fixedMs = this.physic.getWorld().getTimeStep() * 1000;
|
|
630
|
+
this.physicsAccumulatorMs = Math.max(0, this.physicsAccumulatorMs - fixedMs);
|
|
631
|
+
return tick;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
private createCharacterHitbox(
|
|
635
|
+
owner: any,
|
|
636
|
+
kind: PhysicsEntityKind,
|
|
637
|
+
options?: { isStatic?: boolean; mass?: number },
|
|
638
|
+
): void {
|
|
639
|
+
if (!owner?.id) {
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
const existingEntity = this.physic.getEntityByUUID(owner.id);
|
|
643
|
+
if (existingEntity) {
|
|
644
|
+
// Rebind owner when the player instance is restored/replaced (e.g. map transfer).
|
|
645
|
+
// Position sync callbacks read entity.owner at runtime.
|
|
646
|
+
(existingEntity as any).owner = owner;
|
|
647
|
+
this.updateCharacterHitbox(owner);
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const hitbox = typeof owner.hitbox === "function" ? owner.hitbox() : owner.hitbox;
|
|
652
|
+
const width = hitbox?.w ?? 32;
|
|
653
|
+
const height = hitbox?.h ?? 32;
|
|
654
|
+
const radius = Math.max(width, height) / 2;
|
|
655
|
+
this.addCharacter({
|
|
656
|
+
owner,
|
|
657
|
+
radius,
|
|
658
|
+
kind,
|
|
659
|
+
maxSpeed: owner.speed(),
|
|
660
|
+
collidesWithCharacters: !this.shouldDisableCharacterCollisions(owner),
|
|
661
|
+
isStatic: options?.isStatic,
|
|
662
|
+
mass: options?.mass,
|
|
663
|
+
});
|
|
664
|
+
const entity = this.getBody(owner.id);
|
|
665
|
+
if (entity) {
|
|
666
|
+
this.emitPhysicsEntityAdd({
|
|
667
|
+
owner,
|
|
668
|
+
entity,
|
|
669
|
+
kind,
|
|
195
670
|
});
|
|
671
|
+
}
|
|
672
|
+
}
|
|
196
673
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
674
|
+
private updateCharacterHitbox(owner: any): void {
|
|
675
|
+
if (!owner?.id) return;
|
|
676
|
+
const entity = this.physic.getEntityByUUID(owner.id);
|
|
677
|
+
if (!entity) return;
|
|
678
|
+
|
|
679
|
+
// Rebind owner on every update to keep physics callbacks attached to the
|
|
680
|
+
// current player instance after room/session transfers.
|
|
681
|
+
(entity as any).owner = owner;
|
|
682
|
+
|
|
683
|
+
const hitbox = typeof owner.hitbox === "function" ? owner.hitbox() : owner.hitbox;
|
|
684
|
+
const width = hitbox?.w ?? 32;
|
|
685
|
+
const height = hitbox?.h ?? 32;
|
|
686
|
+
const topLeftX = this.resolveNumeric(owner.x);
|
|
687
|
+
const topLeftY = this.resolveNumeric(owner.y);
|
|
688
|
+
this.updateHitbox(owner.id, topLeftX, topLeftY, width, height);
|
|
689
|
+
this.setCharacterCollisionEnabled(owner.id, !this.shouldDisableCharacterCollisions(owner));
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
private resolveNumeric(source: any, fallback = 0): number {
|
|
693
|
+
if (typeof source === "function") {
|
|
694
|
+
try {
|
|
695
|
+
return Number(source()) ?? fallback;
|
|
696
|
+
} catch {
|
|
697
|
+
return fallback;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
if (typeof source === "number") {
|
|
701
|
+
return source;
|
|
702
|
+
}
|
|
703
|
+
return fallback;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
private shouldDisableCharacterCollisions(owner: any): boolean {
|
|
707
|
+
if (typeof owner._through === "function") {
|
|
708
|
+
try {
|
|
709
|
+
return !!owner._through();
|
|
710
|
+
} catch {
|
|
711
|
+
return false;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
if (typeof owner.through === "boolean") {
|
|
715
|
+
return owner.through;
|
|
716
|
+
}
|
|
717
|
+
return false;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
private resolvePhysicsEntityKind(owner: any, id?: string): PhysicsEntityKind {
|
|
721
|
+
if (typeof owner?.isEvent === "function") {
|
|
722
|
+
try {
|
|
723
|
+
if (owner.isEvent()) {
|
|
724
|
+
return "npc";
|
|
211
725
|
}
|
|
212
|
-
|
|
726
|
+
} catch {
|
|
727
|
+
// Ignore owner inspection errors and fallback below
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const ownerId = typeof owner?.id === "string" ? owner.id : id;
|
|
732
|
+
if (ownerId && this.players()?.[ownerId]) {
|
|
733
|
+
return "hero";
|
|
734
|
+
}
|
|
735
|
+
if (ownerId && this.events()?.[ownerId]) {
|
|
736
|
+
return "npc";
|
|
737
|
+
}
|
|
738
|
+
return "generic";
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
protected emitPhysicsInit(_context: MapPhysicsInitContext): void {}
|
|
742
|
+
|
|
743
|
+
protected emitPhysicsEntityAdd(_context: MapPhysicsEntityContext): void {}
|
|
744
|
+
|
|
745
|
+
protected emitPhysicsEntityRemove(_context: MapPhysicsEntityContext): void {}
|
|
746
|
+
|
|
747
|
+
protected emitPhysicsReset(): void {}
|
|
748
|
+
|
|
749
|
+
protected withPhysicsSync<T>(run: () => T): T {
|
|
750
|
+
this.physicsSyncDepth += 1;
|
|
751
|
+
try {
|
|
752
|
+
return run();
|
|
753
|
+
} finally {
|
|
754
|
+
this.physicsSyncDepth -= 1;
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
protected get isPhysicsSyncingSignals(): boolean {
|
|
759
|
+
return this.physicsSyncDepth > 0;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* Get the world maps manager
|
|
764
|
+
*
|
|
765
|
+
* @returns WorldMapsManager instance or null if not configured
|
|
766
|
+
*
|
|
767
|
+
* @example
|
|
768
|
+
* ```ts
|
|
769
|
+
* const worldMaps = map.getWorldMapsManager();
|
|
770
|
+
* if (worldMaps) {
|
|
771
|
+
* const adjacentMaps = worldMaps.getAdjacentMaps(currentMap, coordinates);
|
|
772
|
+
* }
|
|
773
|
+
* ```
|
|
774
|
+
*/
|
|
775
|
+
getWorldMapsManager(): WorldMapsManager | null {
|
|
776
|
+
return this.worldMapsManager ?? null;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* Get attached World
|
|
781
|
+
*
|
|
782
|
+
* Recover the world attached to this map (undefined if no world attached)
|
|
783
|
+
*
|
|
784
|
+
* @since 3.0.0-beta.8
|
|
785
|
+
* @returns {RpgWorldMaps | undefined} The world maps manager instance if attached, otherwise undefined
|
|
786
|
+
*
|
|
787
|
+
* @example
|
|
788
|
+
* ```ts
|
|
789
|
+
* const world = map.getInWorldMaps();
|
|
790
|
+
* if (world) {
|
|
791
|
+
* console.log(world.getAllMaps());
|
|
792
|
+
* }
|
|
793
|
+
* ```
|
|
794
|
+
*/
|
|
795
|
+
getInWorldMaps(): RpgWorldMaps | undefined {
|
|
796
|
+
return this.worldMapsManager ?? undefined;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* Remove this map from the world
|
|
801
|
+
*
|
|
802
|
+
* Remove this map from the world
|
|
803
|
+
*
|
|
804
|
+
* @since 3.0.0-beta.8
|
|
805
|
+
* @returns {boolean | undefined} True if removed, false if not found, undefined if no world attached
|
|
806
|
+
*
|
|
807
|
+
* @example
|
|
808
|
+
* ```ts
|
|
809
|
+
* const removed = map.removeFromWorldMaps();
|
|
810
|
+
* ```
|
|
811
|
+
*/
|
|
812
|
+
removeFromWorldMaps(): boolean | undefined {
|
|
813
|
+
if (!this.worldMapsManager) return undefined;
|
|
814
|
+
const id = (this as any).id as string | undefined;
|
|
815
|
+
if (!id) return false;
|
|
816
|
+
return this.worldMapsManager.removeMap(id);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* Assign the map to a world
|
|
821
|
+
*
|
|
822
|
+
* Assign the map to a world
|
|
823
|
+
*
|
|
824
|
+
* @since 3.0.0-beta.8
|
|
825
|
+
* @param {RpgWorldMaps} worldMap world maps
|
|
826
|
+
* @returns {void}
|
|
827
|
+
*
|
|
828
|
+
* @example
|
|
829
|
+
* ```ts
|
|
830
|
+
* const world = new WorldMapsManager();
|
|
831
|
+
* world.configure([{ id: 'm1', worldX: 0, worldY: 0, width: 1024, height: 1024 }]);
|
|
832
|
+
* map.setInWorldMaps(world);
|
|
833
|
+
* ```
|
|
834
|
+
*/
|
|
835
|
+
setInWorldMaps(worldMap: RpgWorldMaps): void {
|
|
836
|
+
this.worldMapsManager = worldMap;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* Create a temporary and moving hitbox on the map
|
|
843
|
+
*
|
|
844
|
+
* Allows to create a temporary hitbox that moves through multiple positions sequentially.
|
|
845
|
+
* For example, you can use it to explode a bomb and find all the affected players,
|
|
846
|
+
* or during a sword strike, you can create a moving hitbox and find the affected players.
|
|
847
|
+
*
|
|
848
|
+
* The method creates a zone sensor that moves through the specified hitbox positions
|
|
849
|
+
* at the given speed, detecting collisions with players and events at each step.
|
|
850
|
+
*
|
|
851
|
+
* @param hitboxes - Array of hitbox positions to move through sequentially
|
|
852
|
+
* @param options - Configuration options for the movement
|
|
853
|
+
* @returns Observable that emits arrays of hit entities and completes when movement is finished
|
|
854
|
+
*
|
|
855
|
+
* @example
|
|
856
|
+
* ```ts
|
|
857
|
+
* // Create a sword slash effect that moves through two positions
|
|
858
|
+
* map.createMovingHitbox([
|
|
859
|
+
* { x: 100, y: 100, width: 50, height: 50 },
|
|
860
|
+
* { x: 120, y: 100, width: 50, height: 50 }
|
|
861
|
+
* ], { speed: 2 }).subscribe({
|
|
862
|
+
* next(hits) {
|
|
863
|
+
* // hits contains RpgPlayer or RpgEvent objects that were hit
|
|
864
|
+
* console.log('Hit entities:', hits);
|
|
865
|
+
* },
|
|
866
|
+
* complete() {
|
|
867
|
+
* console.log('Movement finished');
|
|
868
|
+
* }
|
|
869
|
+
* });
|
|
870
|
+
* ```
|
|
871
|
+
*/
|
|
872
|
+
createMovingHitbox(
|
|
873
|
+
hitboxes: Array<{ x: number; y: number; width: number; height: number }>,
|
|
874
|
+
options: { speed?: number } = {}
|
|
875
|
+
): Observable<(T | any)[]> {
|
|
876
|
+
const { speed = 1 } = options;
|
|
877
|
+
const zoneId = `moving_hitbox_${generateShortUUID()}`;
|
|
878
|
+
|
|
879
|
+
return new Observable(observer => {
|
|
880
|
+
if (hitboxes.length === 0) {
|
|
881
|
+
observer.complete();
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
let currentIndex = 0;
|
|
886
|
+
let frameCounter = 0;
|
|
887
|
+
const hitEntities = new Set<string>();
|
|
888
|
+
|
|
889
|
+
// Create initial zone at first hitbox position
|
|
890
|
+
const firstHitbox = hitboxes[0];
|
|
891
|
+
const radius = Math.max(firstHitbox.width, firstHitbox.height) / 2;
|
|
892
|
+
|
|
893
|
+
this.addZone(zoneId, {
|
|
894
|
+
x: firstHitbox.x + firstHitbox.width / 2,
|
|
895
|
+
y: firstHitbox.y + firstHitbox.height / 2,
|
|
896
|
+
radius: radius
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
// Register zone events to detect hits
|
|
900
|
+
this.registerZoneEvents(
|
|
901
|
+
zoneId,
|
|
902
|
+
(hitIds: string[]) => {
|
|
903
|
+
// Convert hit IDs to actual objects and emit
|
|
904
|
+
const hitObjects = hitIds
|
|
905
|
+
.map(id => this.getObjectById(id))
|
|
906
|
+
.filter(obj => obj !== undefined);
|
|
907
|
+
|
|
908
|
+
if (hitObjects.length > 0) {
|
|
909
|
+
// Track hit entities to avoid duplicates
|
|
910
|
+
hitIds.forEach(id => hitEntities.add(id));
|
|
911
|
+
observer.next(hitObjects);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
);
|
|
213
915
|
|
|
214
916
|
// Subscribe to tick to handle movement
|
|
215
917
|
const tickSubscription = this.tick$.subscribe(() => {
|
|
216
918
|
frameCounter++;
|
|
217
|
-
|
|
919
|
+
|
|
218
920
|
// Move to next position based on speed
|
|
219
921
|
if (frameCounter >= speed) {
|
|
220
922
|
frameCounter = 0;
|
|
221
923
|
currentIndex++;
|
|
222
|
-
|
|
924
|
+
|
|
223
925
|
// Check if we've reached the end
|
|
224
926
|
if (currentIndex >= hitboxes.length) {
|
|
225
927
|
// Clean up and complete
|
|
226
|
-
this.
|
|
928
|
+
this.removeZone(zoneId);
|
|
227
929
|
tickSubscription.unsubscribe();
|
|
228
930
|
observer.complete();
|
|
229
931
|
return;
|
|
230
932
|
}
|
|
231
|
-
|
|
933
|
+
|
|
232
934
|
// Move zone to next position
|
|
233
935
|
const nextHitbox = hitboxes[currentIndex];
|
|
234
|
-
const zone = this.
|
|
235
|
-
|
|
936
|
+
const zone = this.getZone(zoneId);
|
|
937
|
+
|
|
236
938
|
if (zone) {
|
|
237
939
|
// Remove current zone and create new one at next position
|
|
238
|
-
this.
|
|
239
|
-
|
|
940
|
+
this.removeZone(zoneId);
|
|
941
|
+
|
|
240
942
|
const newRadius = Math.max(nextHitbox.width, nextHitbox.height) / 2;
|
|
241
|
-
this.
|
|
943
|
+
this.addZone(zoneId, {
|
|
242
944
|
x: nextHitbox.x + nextHitbox.width / 2,
|
|
243
945
|
y: nextHitbox.y + nextHitbox.height / 2,
|
|
244
946
|
radius: newRadius
|
|
245
947
|
});
|
|
246
|
-
|
|
948
|
+
|
|
247
949
|
// Re-register zone events for the new zone
|
|
248
|
-
this.
|
|
950
|
+
this.registerZoneEvents(
|
|
249
951
|
zoneId,
|
|
250
952
|
(hitIds: string[]) => {
|
|
251
953
|
const hitObjects = hitIds
|
|
252
954
|
.map(id => this.getObjectById(id))
|
|
253
955
|
.filter(obj => obj !== undefined);
|
|
254
|
-
|
|
956
|
+
|
|
255
957
|
if (hitObjects.length > 0) {
|
|
256
958
|
hitIds.forEach(id => hitEntities.add(id));
|
|
257
959
|
observer.next(hitObjects);
|
|
@@ -265,8 +967,792 @@ export abstract class RpgCommonMap<T extends RpgCommonPlayer> {
|
|
|
265
967
|
// Cleanup function
|
|
266
968
|
return () => {
|
|
267
969
|
tickSubscription.unsubscribe();
|
|
268
|
-
this.
|
|
970
|
+
this.removeZone(zoneId);
|
|
269
971
|
};
|
|
270
972
|
});
|
|
271
973
|
}
|
|
974
|
+
|
|
975
|
+
/**
|
|
976
|
+
* Add a static hitbox to the physics world
|
|
977
|
+
* @private
|
|
978
|
+
*/
|
|
979
|
+
private addStaticHitbox(
|
|
980
|
+
id: string,
|
|
981
|
+
xOrPoints: number | number[][],
|
|
982
|
+
y?: number,
|
|
983
|
+
width?: number,
|
|
984
|
+
height?: number,
|
|
985
|
+
): string {
|
|
986
|
+
// Check if entity already exists
|
|
987
|
+
if (this.physic.getEntityByUUID(id)) {
|
|
988
|
+
throw new Error(`Hitbox with id ${id} already exists`);
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
let entity: Entity;
|
|
992
|
+
let boxWidth: number;
|
|
993
|
+
let boxHeight: number;
|
|
994
|
+
|
|
995
|
+
if (Array.isArray(xOrPoints)) {
|
|
996
|
+
const points = xOrPoints;
|
|
997
|
+
if (points.length < 3) {
|
|
998
|
+
throw new Error(`Polygon must have at least 3 points, got ${points.length}`);
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
let minX = Number.POSITIVE_INFINITY;
|
|
1002
|
+
let minY = Number.POSITIVE_INFINITY;
|
|
1003
|
+
let maxX = Number.NEGATIVE_INFINITY;
|
|
1004
|
+
let maxY = Number.NEGATIVE_INFINITY;
|
|
1005
|
+
|
|
1006
|
+
for (const point of points) {
|
|
1007
|
+
if (!Array.isArray(point) || point.length !== 2 || typeof point[0] !== "number" || typeof point[1] !== "number") {
|
|
1008
|
+
throw new Error(`Invalid point ${JSON.stringify(point)}. Expected [x, y].`);
|
|
1009
|
+
}
|
|
1010
|
+
minX = Math.min(minX, point[0]);
|
|
1011
|
+
maxX = Math.max(maxX, point[0]);
|
|
1012
|
+
minY = Math.min(minY, point[1]);
|
|
1013
|
+
maxY = Math.max(maxY, point[1]);
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
const centerX = (minX + maxX) / 2;
|
|
1017
|
+
const centerY = (minY + maxY) / 2;
|
|
1018
|
+
boxWidth = Math.max(maxX - minX, 1);
|
|
1019
|
+
boxHeight = Math.max(maxY - minY, 1);
|
|
1020
|
+
|
|
1021
|
+
entity = this.physic.createEntity({
|
|
1022
|
+
uuid: id,
|
|
1023
|
+
position: { x: centerX, y: centerY },
|
|
1024
|
+
width: boxWidth,
|
|
1025
|
+
height: boxHeight,
|
|
1026
|
+
mass: Infinity,
|
|
1027
|
+
state: EntityState.Static,
|
|
1028
|
+
restitution: 0
|
|
1029
|
+
});
|
|
1030
|
+
entity.freeze();
|
|
1031
|
+
|
|
1032
|
+
const localVertices = points.map((point) => {
|
|
1033
|
+
const [px, py] = point as [number, number];
|
|
1034
|
+
return new Vector2(px - centerX, py - centerY);
|
|
1035
|
+
});
|
|
1036
|
+
assignPolygonCollider(entity, { vertices: localVertices });
|
|
1037
|
+
} else {
|
|
1038
|
+
if (typeof y !== "number" || typeof width !== "number" || typeof height !== "number") {
|
|
1039
|
+
throw new Error("Rectangle hitbox requires x, y, width and height parameters");
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
const centerX = xOrPoints + width / 2;
|
|
1043
|
+
const centerY = y + height / 2;
|
|
1044
|
+
boxWidth = Math.max(width, 1);
|
|
1045
|
+
boxHeight = Math.max(height, 1);
|
|
1046
|
+
|
|
1047
|
+
entity = this.physic.createEntity({
|
|
1048
|
+
uuid: id,
|
|
1049
|
+
position: { x: centerX, y: centerY },
|
|
1050
|
+
width: boxWidth,
|
|
1051
|
+
height: boxHeight,
|
|
1052
|
+
mass: Infinity,
|
|
1053
|
+
state: EntityState.Static,
|
|
1054
|
+
restitution: 0
|
|
1055
|
+
});
|
|
1056
|
+
entity.freeze();
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
return id;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/**
|
|
1063
|
+
* Add a character to the physics world
|
|
1064
|
+
* @private
|
|
1065
|
+
*/
|
|
1066
|
+
/**
|
|
1067
|
+
* Add a character entity to the physics world
|
|
1068
|
+
*
|
|
1069
|
+
* Creates a physics entity for a character (player or NPC) with proper position handling.
|
|
1070
|
+
* The owner's x/y signals represent **top-left** coordinates, while the physics entity
|
|
1071
|
+
* uses **center** coordinates internally.
|
|
1072
|
+
*
|
|
1073
|
+
* ## Position System
|
|
1074
|
+
*
|
|
1075
|
+
* - `owner.x()` / `owner.y()` → **top-left** corner of the character's hitbox
|
|
1076
|
+
* - `entity.position` → **center** of the physics collider
|
|
1077
|
+
* - Conversion: `center = topLeft + (size / 2)`
|
|
1078
|
+
*
|
|
1079
|
+
* @param options - Character configuration
|
|
1080
|
+
* @returns The character's unique ID
|
|
1081
|
+
*
|
|
1082
|
+
* @example
|
|
1083
|
+
* ```ts
|
|
1084
|
+
* // Player at top-left position (100, 100) with 32x32 hitbox
|
|
1085
|
+
* // Physics entity will be at center (116, 116)
|
|
1086
|
+
* this.addCharacter({
|
|
1087
|
+
* owner: player,
|
|
1088
|
+
* x: 116, // center X (ignored, uses owner.x())
|
|
1089
|
+
* y: 116, // center Y (ignored, uses owner.y())
|
|
1090
|
+
* kind: "hero"
|
|
1091
|
+
* });
|
|
1092
|
+
* ```
|
|
1093
|
+
*
|
|
1094
|
+
* @private
|
|
1095
|
+
*/
|
|
1096
|
+
private addCharacter(options: {
|
|
1097
|
+
owner: any;
|
|
1098
|
+
radius?: number;
|
|
1099
|
+
kind?: PhysicsEntityKind;
|
|
1100
|
+
collidesWithCharacters?: boolean;
|
|
1101
|
+
maxSpeed?: number;
|
|
1102
|
+
isStatic?: boolean;
|
|
1103
|
+
friction?: number;
|
|
1104
|
+
mass?: number;
|
|
1105
|
+
}): string {
|
|
1106
|
+
if (!options || typeof options.owner?.id !== "string") {
|
|
1107
|
+
throw new Error("Character requires an owner object with a string id");
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
const owner = options.owner;
|
|
1111
|
+
const id = owner.id;
|
|
1112
|
+
|
|
1113
|
+
// Get hitbox dimensions - hitbox.w/h are the FULL dimensions, not radius
|
|
1114
|
+
const hitbox = typeof owner.hitbox === "function" ? owner.hitbox() : owner.hitbox;
|
|
1115
|
+
const width = hitbox?.w ?? 32;
|
|
1116
|
+
const height = hitbox?.h ?? 32;
|
|
1117
|
+
|
|
1118
|
+
// Calculate radius from dimensions (use the larger dimension for circular collider)
|
|
1119
|
+
const radius = Math.max(width, height) / 2;
|
|
1120
|
+
|
|
1121
|
+
// owner.x() and owner.y() are TOP-LEFT positions
|
|
1122
|
+
const topLeftX = owner.x();
|
|
1123
|
+
const topLeftY = owner.y();
|
|
1124
|
+
|
|
1125
|
+
// Convert to CENTER for physics engine
|
|
1126
|
+
const centerX = topLeftX + width / 2;
|
|
1127
|
+
const centerY = topLeftY + height / 2;
|
|
1128
|
+
|
|
1129
|
+
const isStatic = !!options.isStatic;
|
|
1130
|
+
|
|
1131
|
+
const entity = this.physic.createEntity({
|
|
1132
|
+
uuid: id,
|
|
1133
|
+
position: { x: centerX, y: centerY },
|
|
1134
|
+
// Use radius for circular collision detection
|
|
1135
|
+
radius: Math.max(radius, 1),
|
|
1136
|
+
// Also store explicit width/height for consistent position conversions
|
|
1137
|
+
// This ensures getBodyPosition/setBodyPosition use the same dimensions
|
|
1138
|
+
width: width,
|
|
1139
|
+
height: height,
|
|
1140
|
+
mass: options.mass ?? (isStatic ? Infinity : 1),
|
|
1141
|
+
friction: options.friction ?? 0.4,
|
|
1142
|
+
linearDamping: isStatic ? 1 : 0.2,
|
|
1143
|
+
maxLinearVelocity: options.maxSpeed ? options.maxSpeed * this.speedScalar : 200,
|
|
1144
|
+
restitution: 0
|
|
1145
|
+
});
|
|
1146
|
+
|
|
1147
|
+
if (isStatic) {
|
|
1148
|
+
entity.freeze();
|
|
1149
|
+
} else {
|
|
1150
|
+
entity.unfreeze();
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
// Store owner reference directly on entity for syncing positions
|
|
1154
|
+
(entity as any).owner = owner;
|
|
1155
|
+
|
|
1156
|
+
entity.onDirectionChange(({ cardinalDirection }) => {
|
|
1157
|
+
// hack to prevent direction in client side
|
|
1158
|
+
if (!('$send' in this)) return;
|
|
1159
|
+
const owner = (entity as any).owner;
|
|
1160
|
+
if (!owner) return;
|
|
1161
|
+
if (cardinalDirection === 'idle') return;
|
|
1162
|
+
// Don't change direction if it's locked
|
|
1163
|
+
if (owner.directionFixed) return;
|
|
1164
|
+
owner.changeDirection(cardinalDirection as Direction);
|
|
1165
|
+
});
|
|
1166
|
+
|
|
1167
|
+
entity.onMovementChange(({ isMoving, intensity }) => {
|
|
1168
|
+
// Prevent animation changes on client side (same as onDirectionChange)
|
|
1169
|
+
if (!('$send' in this)) return;
|
|
1170
|
+
|
|
1171
|
+
// Get owner from entity (same pattern as onDirectionChange)
|
|
1172
|
+
const owner = (entity as any).owner;
|
|
1173
|
+
if (!owner) return;
|
|
1174
|
+
|
|
1175
|
+
// Don't change animation if it's locked
|
|
1176
|
+
if (owner.animationFixed) return;
|
|
1177
|
+
|
|
1178
|
+
// Only change animation if intensity is low (avoid animation flicker on micro-movements)
|
|
1179
|
+
// Intensity threshold: 10 pixels/second (adjust based on your game's needs)
|
|
1180
|
+
const LOW_INTENSITY_THRESHOLD = 10;
|
|
1181
|
+
|
|
1182
|
+
// Try to use setAnimation method if available (preferred method)
|
|
1183
|
+
// Otherwise, try to access animationName signal directly
|
|
1184
|
+
const hasSetAnimation = typeof owner.setAnimation === 'function';
|
|
1185
|
+
const animationNameSignal = owner.animationName;
|
|
1186
|
+
const ownerHasAnimationName = animationNameSignal && typeof animationNameSignal === 'object' && typeof animationNameSignal.set === 'function';
|
|
1187
|
+
|
|
1188
|
+
if (isMoving && intensity > LOW_INTENSITY_THRESHOLD) {
|
|
1189
|
+
if (hasSetAnimation) {
|
|
1190
|
+
owner.setGraphicAnimation("walk");
|
|
1191
|
+
} else if (ownerHasAnimationName) {
|
|
1192
|
+
animationNameSignal.set("walk");
|
|
1193
|
+
}
|
|
1194
|
+
} else if (!isMoving) {
|
|
1195
|
+
if (hasSetAnimation) {
|
|
1196
|
+
owner.setGraphicAnimation("stand");
|
|
1197
|
+
} else if (ownerHasAnimationName) {
|
|
1198
|
+
animationNameSignal.set("stand");
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
// If moving with high intensity, keep current animation (e.g., already running)
|
|
1202
|
+
});
|
|
1203
|
+
|
|
1204
|
+
// Register position sync handler to update owner.x and owner.y.
|
|
1205
|
+
// Read owner dynamically from entity to avoid stale references after map transfer.
|
|
1206
|
+
|
|
1207
|
+
entity.onPositionChange(({ x, y }) => {
|
|
1208
|
+
const currentOwner = (entity as any).owner;
|
|
1209
|
+
if (!currentOwner) {
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
const entityWidth = entity.width || width;
|
|
1214
|
+
const entityHeight = entity.height || height;
|
|
1215
|
+
// Calculate top-left from center using the original hitbox dimensions
|
|
1216
|
+
// This ensures consistency: center = topLeft + (size / 2)
|
|
1217
|
+
// Therefore: topLeft = center - (size / 2)
|
|
1218
|
+
const topLeftX = x - entityWidth / 2;
|
|
1219
|
+
const topLeftY = y - entityHeight / 2;
|
|
1220
|
+
let changed = false;
|
|
1221
|
+
|
|
1222
|
+
if (typeof currentOwner.x === "function" && typeof currentOwner.x.set === "function") {
|
|
1223
|
+
currentOwner.x.set(Math.round(topLeftX));
|
|
1224
|
+
changed = true;
|
|
1225
|
+
}
|
|
1226
|
+
if (typeof currentOwner.y === "function" && typeof currentOwner.y.set === "function") {
|
|
1227
|
+
currentOwner.y.set(Math.round(topLeftY));
|
|
1228
|
+
changed = true;
|
|
1229
|
+
}
|
|
1230
|
+
if (changed) {
|
|
1231
|
+
currentOwner.applyFrames?.();
|
|
1232
|
+
}
|
|
1233
|
+
});
|
|
1234
|
+
|
|
1235
|
+
// Add resolution filter for through/throughOtherPlayer/throughEvent
|
|
1236
|
+
// This filter determines if collisions should be RESOLVED (blocking) or just DETECTED.
|
|
1237
|
+
// When returning false, entities pass through each other but collision events still fire.
|
|
1238
|
+
entity.addResolutionFilter((self, other) => {
|
|
1239
|
+
const selfOwner = (self as any).owner;
|
|
1240
|
+
const otherOwner = (other as any).owner;
|
|
1241
|
+
|
|
1242
|
+
// If either entity has no owner, resolve collision (e.g., walls, obstacles must block)
|
|
1243
|
+
if (!selfOwner || !otherOwner) {
|
|
1244
|
+
return true;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
|
|
1248
|
+
if (selfOwner.z() !== otherOwner.z()) {
|
|
1249
|
+
return false; // Don't resolve collision
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
// Check if selfOwner has _through property (passes through everything)
|
|
1253
|
+
// This applies to both players and events
|
|
1254
|
+
if (typeof selfOwner._through === "function") {
|
|
1255
|
+
try {
|
|
1256
|
+
if (selfOwner._through() === true) {
|
|
1257
|
+
return false; // Pass through but events still fire
|
|
1258
|
+
}
|
|
1259
|
+
} catch {
|
|
1260
|
+
// Ignore errors
|
|
1261
|
+
}
|
|
1262
|
+
} else if (selfOwner.through === true) {
|
|
1263
|
+
return false;
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
// Determine the type of both entities via lookup in players/events
|
|
1267
|
+
const playersMap = this.players();
|
|
1268
|
+
const eventsMap = this.events();
|
|
1269
|
+
const isSelfPlayer = !!playersMap[self.uuid];
|
|
1270
|
+
const isOtherPlayer = !!playersMap[other.uuid];
|
|
1271
|
+
const isSelfEvent = !!eventsMap[self.uuid];
|
|
1272
|
+
const isOtherEvent = !!eventsMap[other.uuid];
|
|
1273
|
+
const readScenarioOwnerId = (owner: any): string | undefined => {
|
|
1274
|
+
const scenarioOwnerId = owner?._scenarioOwnerId ?? owner?.scenarioOwnerId;
|
|
1275
|
+
return typeof scenarioOwnerId === "string" && scenarioOwnerId.length > 0
|
|
1276
|
+
? scenarioOwnerId
|
|
1277
|
+
: undefined;
|
|
1278
|
+
};
|
|
1279
|
+
const selfScenarioOwnerId = readScenarioOwnerId(selfOwner);
|
|
1280
|
+
const otherScenarioOwnerId = readScenarioOwnerId(otherOwner);
|
|
1281
|
+
|
|
1282
|
+
// Scenario events are isolated per player:
|
|
1283
|
+
// they only collide with their owner and scenario events of the same owner.
|
|
1284
|
+
if (selfScenarioOwnerId) {
|
|
1285
|
+
if (isOtherPlayer && other.uuid !== selfScenarioOwnerId) {
|
|
1286
|
+
return false;
|
|
1287
|
+
}
|
|
1288
|
+
if (isOtherEvent && otherScenarioOwnerId !== selfScenarioOwnerId) {
|
|
1289
|
+
return false;
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
if (otherScenarioOwnerId) {
|
|
1293
|
+
if (isSelfPlayer && self.uuid !== otherScenarioOwnerId) {
|
|
1294
|
+
return false;
|
|
1295
|
+
}
|
|
1296
|
+
if (isSelfEvent && selfScenarioOwnerId !== otherScenarioOwnerId) {
|
|
1297
|
+
return false;
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
// throughOtherPlayer only applies when SELF is a player and OTHER is also a player
|
|
1302
|
+
// (players passing through other players)
|
|
1303
|
+
if (isSelfPlayer && isOtherPlayer) {
|
|
1304
|
+
if (typeof selfOwner._throughOtherPlayer === "function") {
|
|
1305
|
+
try {
|
|
1306
|
+
if (selfOwner._throughOtherPlayer() === true) {
|
|
1307
|
+
return false; // Pass through players but events still fire
|
|
1308
|
+
}
|
|
1309
|
+
} catch {
|
|
1310
|
+
// Ignore errors
|
|
1311
|
+
}
|
|
1312
|
+
} else if (selfOwner.throughOtherPlayer === true) {
|
|
1313
|
+
return false;
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
// throughEvent only applies when SELF is a player and OTHER is an event
|
|
1318
|
+
// (players passing through events)
|
|
1319
|
+
if (isSelfPlayer && isOtherEvent) {
|
|
1320
|
+
if (typeof selfOwner._throughEvent === "function") {
|
|
1321
|
+
try {
|
|
1322
|
+
if (selfOwner._throughEvent() === true) {
|
|
1323
|
+
return false; // Pass through events but events still fire
|
|
1324
|
+
}
|
|
1325
|
+
} catch {
|
|
1326
|
+
// Ignore errors
|
|
1327
|
+
}
|
|
1328
|
+
} else if (selfOwner.throughEvent === true) {
|
|
1329
|
+
return false;
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
return true; // Resolve collision (block movement)
|
|
1334
|
+
});
|
|
1335
|
+
|
|
1336
|
+
return id;
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
/**
|
|
1340
|
+
* Update hitbox position and size
|
|
1341
|
+
*
|
|
1342
|
+
* @param id - Entity ID
|
|
1343
|
+
* @param x - Top-left X coordinate
|
|
1344
|
+
* @param y - Top-left Y coordinate
|
|
1345
|
+
* @param width - Optional width
|
|
1346
|
+
* @param height - Optional height
|
|
1347
|
+
* @returns True if hitbox was updated successfully
|
|
1348
|
+
*/
|
|
1349
|
+
updateHitbox(id: string, x: number, y: number, width?: number, height?: number): boolean {
|
|
1350
|
+
const entity = this.physic.getEntityByUUID(id);
|
|
1351
|
+
if (!entity) return false;
|
|
1352
|
+
|
|
1353
|
+
if (typeof width === "number" && typeof height === "number") {
|
|
1354
|
+
entity.width = Math.max(width, 1);
|
|
1355
|
+
entity.height = Math.max(height, 1);
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
// Calculate center from top-left
|
|
1359
|
+
const entityWidth = entity.width || entity.radius * 2 || 32;
|
|
1360
|
+
const entityHeight = entity.height || entity.radius * 2 || 32;
|
|
1361
|
+
const centerX = x + entityWidth / 2;
|
|
1362
|
+
const centerY = y + entityHeight / 2;
|
|
1363
|
+
entity.position.set(centerX, centerY);
|
|
1364
|
+
|
|
1365
|
+
return true;
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
/**
|
|
1369
|
+
* Remove a hitbox from the physics world
|
|
1370
|
+
* @private
|
|
1371
|
+
*/
|
|
1372
|
+
private removeHitbox(id: string, owner?: any, kind?: PhysicsEntityKind): boolean {
|
|
1373
|
+
const entity = this.physic.getEntityByUUID(id);
|
|
1374
|
+
if (!entity) {
|
|
1375
|
+
return false;
|
|
1376
|
+
}
|
|
1377
|
+
const resolvedOwner = owner ?? (entity as any).owner;
|
|
1378
|
+
if (resolvedOwner) {
|
|
1379
|
+
this.emitPhysicsEntityRemove({
|
|
1380
|
+
owner: resolvedOwner,
|
|
1381
|
+
entity,
|
|
1382
|
+
kind: kind ?? this.resolvePhysicsEntityKind(resolvedOwner, id),
|
|
1383
|
+
});
|
|
1384
|
+
}
|
|
1385
|
+
this.physic.removeEntity(entity);
|
|
1386
|
+
return true;
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
/**
|
|
1390
|
+
* Check if an entity is moving
|
|
1391
|
+
* @private
|
|
1392
|
+
*/
|
|
1393
|
+
private isEntityMoving(id: string): boolean {
|
|
1394
|
+
const entity = this.physic.getEntityByUUID(id);
|
|
1395
|
+
if (!entity) return false;
|
|
1396
|
+
// Check if entity has velocity
|
|
1397
|
+
return entity.velocity.length() > 0.1;
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
/**
|
|
1401
|
+
* Move a body in a direction
|
|
1402
|
+
* @private
|
|
1403
|
+
*/
|
|
1404
|
+
private moveBody(player: any, direction: Direction): boolean {
|
|
1405
|
+
const entity = this.physic.getEntityByUUID(player.id);
|
|
1406
|
+
if (!entity) return false;
|
|
1407
|
+
|
|
1408
|
+
const speedValue = player.speed()
|
|
1409
|
+
|
|
1410
|
+
let vx = 0, vy = 0;
|
|
1411
|
+
switch (direction) {
|
|
1412
|
+
case Direction.Left:
|
|
1413
|
+
vx = -speedValue * this.speedScalar;
|
|
1414
|
+
break;
|
|
1415
|
+
case Direction.Right:
|
|
1416
|
+
vx = speedValue * this.speedScalar;
|
|
1417
|
+
break;
|
|
1418
|
+
case Direction.Up:
|
|
1419
|
+
vy = -speedValue * this.speedScalar;
|
|
1420
|
+
break;
|
|
1421
|
+
case Direction.Down:
|
|
1422
|
+
vy = speedValue * this.speedScalar;
|
|
1423
|
+
break;
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
// Input sets the base velocity. Movement strategies (dash/knockback/AI, etc.)
|
|
1427
|
+
// are responsible for adding or overriding velocity when needed.
|
|
1428
|
+
entity.setVelocity({ x: vx, y: vy });
|
|
1429
|
+
entity.wakeUp();
|
|
1430
|
+
return true;
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
/**
|
|
1434
|
+
* Stop movement for a player
|
|
1435
|
+
*
|
|
1436
|
+
* Completely stops all movement for a player, including:
|
|
1437
|
+
* - Clearing all active movement strategies (dash, linear moves, etc.)
|
|
1438
|
+
* - Setting velocity to zero
|
|
1439
|
+
* - Resetting intended direction
|
|
1440
|
+
*
|
|
1441
|
+
* This method is particularly useful when changing maps to ensure
|
|
1442
|
+
* the player doesn't carry over movement from the previous map.
|
|
1443
|
+
*
|
|
1444
|
+
* @param player - The player to stop
|
|
1445
|
+
* @returns True if the player was found and movement was stopped
|
|
1446
|
+
*
|
|
1447
|
+
* @example
|
|
1448
|
+
* ```ts
|
|
1449
|
+
* // Stop player movement when changing maps
|
|
1450
|
+
* if (mapChanged) {
|
|
1451
|
+
* map.stopMovement(player);
|
|
1452
|
+
* }
|
|
1453
|
+
*
|
|
1454
|
+
* // Stop movement when player dies
|
|
1455
|
+
* if (player.isDead()) {
|
|
1456
|
+
* map.stopMovement(player);
|
|
1457
|
+
* }
|
|
1458
|
+
* ```
|
|
1459
|
+
* @protected
|
|
1460
|
+
*/
|
|
1461
|
+
protected stopMovement(player: any): boolean {
|
|
1462
|
+
const entity = this.physic.getEntityByUUID(player.id);
|
|
1463
|
+
if (!entity) return false;
|
|
1464
|
+
|
|
1465
|
+
// Stop all movement using the MovementManager (clears strategies and stops entity movement)
|
|
1466
|
+
this.moveManager.stopMovement(player.id);
|
|
1467
|
+
|
|
1468
|
+
player.pendingInputs = [];
|
|
1469
|
+
|
|
1470
|
+
return true;
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
/**
|
|
1474
|
+
* Set character collision enabled
|
|
1475
|
+
* @private
|
|
1476
|
+
*/
|
|
1477
|
+
private setCharacterCollisionEnabled(id: string, collides: boolean): boolean {
|
|
1478
|
+
const entity = this.physic.getEntityByUUID(id);
|
|
1479
|
+
if (!entity) {
|
|
1480
|
+
return false;
|
|
1481
|
+
}
|
|
1482
|
+
// Collision filtering is handled by PhysicsEngine's collision system
|
|
1483
|
+
// This method is kept for API compatibility but doesn't need to do anything
|
|
1484
|
+
// as PhysicsEngine handles collisions automatically
|
|
1485
|
+
return true;
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
/**
|
|
1489
|
+
* Get collisions for an entity
|
|
1490
|
+
*
|
|
1491
|
+
* Returns all entities that are colliding with the specified entity.
|
|
1492
|
+
* Uses the entity's actual AABB bounds to detect collisions in all directions.
|
|
1493
|
+
*
|
|
1494
|
+
* @param id - Entity UUID to check collisions for
|
|
1495
|
+
* @returns Array of entity UUIDs that are colliding with the specified entity
|
|
1496
|
+
*
|
|
1497
|
+
* @example
|
|
1498
|
+
* ```ts
|
|
1499
|
+
* // Get all entities colliding with player
|
|
1500
|
+
* const collisions = map.getCollisions('player1');
|
|
1501
|
+
* collisions.forEach(id => {
|
|
1502
|
+
* console.log(`Entity ${id} is colliding with player`);
|
|
1503
|
+
* });
|
|
1504
|
+
* ```
|
|
1505
|
+
*/
|
|
1506
|
+
protected getCollisions(id: string): string[] {
|
|
1507
|
+
const entity = this.physic.getEntityByUUID(id);
|
|
1508
|
+
if (!entity) return [];
|
|
1509
|
+
|
|
1510
|
+
// Get the entity's actual collider and AABB bounds
|
|
1511
|
+
const collider = createCollider(entity);
|
|
1512
|
+
if (!collider) return [];
|
|
1513
|
+
|
|
1514
|
+
const entityAABB = collider.getBounds();
|
|
1515
|
+
|
|
1516
|
+
// Expand AABB slightly to ensure we catch nearby entities
|
|
1517
|
+
// This helps with edge cases where entities are just touching
|
|
1518
|
+
const expandedAABB = entityAABB.expand(1);
|
|
1519
|
+
|
|
1520
|
+
// Query nearby entities using the expanded AABB
|
|
1521
|
+
const nearby = this.physic.queryAABB(expandedAABB);
|
|
1522
|
+
const collisions: string[] = [];
|
|
1523
|
+
|
|
1524
|
+
// Check actual AABB intersections for each nearby entity
|
|
1525
|
+
for (const other of nearby) {
|
|
1526
|
+
if (other.uuid === id) continue;
|
|
1527
|
+
|
|
1528
|
+
const otherCollider = createCollider(other);
|
|
1529
|
+
if (!otherCollider) continue;
|
|
1530
|
+
|
|
1531
|
+
const otherAABB = otherCollider.getBounds();
|
|
1532
|
+
|
|
1533
|
+
// Check if AABBs actually intersect
|
|
1534
|
+
if (entityAABB.intersects(otherAABB)) {
|
|
1535
|
+
collisions.push(other.uuid);
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
return collisions;
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
/**
|
|
1543
|
+
* Get physics body (entity) for an id
|
|
1544
|
+
* @protected
|
|
1545
|
+
*/
|
|
1546
|
+
public getBody(id: string): Entity | undefined {
|
|
1547
|
+
return this.physic.getEntityByUUID(id);
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
/**
|
|
1551
|
+
* Get the current physics tick
|
|
1552
|
+
* @returns Current tick number
|
|
1553
|
+
*/
|
|
1554
|
+
getTick(): number {
|
|
1555
|
+
return this.physic.getTick();
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
/**
|
|
1559
|
+
* Get body position in different modes
|
|
1560
|
+
*
|
|
1561
|
+
* @param id - Entity ID
|
|
1562
|
+
* @param mode - Position mode: "center" or "top-left"
|
|
1563
|
+
* @returns Position coordinates or undefined if entity not found
|
|
1564
|
+
*/
|
|
1565
|
+
getBodyPosition(
|
|
1566
|
+
id: string,
|
|
1567
|
+
mode: "center" | "top-left" = "center",
|
|
1568
|
+
): { x: number; y: number } | undefined {
|
|
1569
|
+
const entity = this.physic.getEntityByUUID(id);
|
|
1570
|
+
if (!entity) return undefined;
|
|
1571
|
+
|
|
1572
|
+
const centerX = entity.position.x;
|
|
1573
|
+
const centerY = entity.position.y;
|
|
1574
|
+
if (mode === "center") {
|
|
1575
|
+
return { x: centerX, y: centerY };
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
// Calculate top-left from center
|
|
1579
|
+
const width = entity.width || (entity.radius ? entity.radius * 2 : 32);
|
|
1580
|
+
const height = entity.height || (entity.radius ? entity.radius * 2 : 32);
|
|
1581
|
+
return {
|
|
1582
|
+
x: centerX - width / 2,
|
|
1583
|
+
y: centerY - height / 2,
|
|
1584
|
+
};
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
/**
|
|
1588
|
+
* Set body position
|
|
1589
|
+
*
|
|
1590
|
+
* @param id - Entity ID
|
|
1591
|
+
* @param x - X coordinate
|
|
1592
|
+
* @param y - Y coordinate
|
|
1593
|
+
* @param mode - Position mode: "center" or "top-left"
|
|
1594
|
+
* @returns True if position was set successfully
|
|
1595
|
+
*/
|
|
1596
|
+
setBodyPosition(
|
|
1597
|
+
id: string,
|
|
1598
|
+
x: number,
|
|
1599
|
+
y: number,
|
|
1600
|
+
mode: "center" | "top-left" = "center",
|
|
1601
|
+
): Entity | undefined {
|
|
1602
|
+
const entity = this.physic.getEntityByUUID(id);
|
|
1603
|
+
if (!entity) return;
|
|
1604
|
+
|
|
1605
|
+
const width = entity.width || (entity.radius ? entity.radius * 2 : 32);
|
|
1606
|
+
const height = entity.height || (entity.radius ? entity.radius * 2 : 32);
|
|
1607
|
+
|
|
1608
|
+
let centerX = x;
|
|
1609
|
+
let centerY = y;
|
|
1610
|
+
if (mode === "top-left") {
|
|
1611
|
+
centerX = x + width / 2;
|
|
1612
|
+
centerY = y + height / 2;
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
entity.position.set(centerX, centerY);
|
|
1616
|
+
entity.notifyPositionChange();
|
|
1617
|
+
|
|
1618
|
+
return entity;
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
/**
|
|
1622
|
+
* Handle collision enter
|
|
1623
|
+
* @private
|
|
1624
|
+
*/
|
|
1625
|
+
// Collision handling is now done directly via entity hooks in addCharacter
|
|
1626
|
+
// These methods are no longer needed as PhysicsEngine handles collisions internally
|
|
1627
|
+
|
|
1628
|
+
/**
|
|
1629
|
+
* Add a zone
|
|
1630
|
+
* @private
|
|
1631
|
+
*/
|
|
1632
|
+
private addZone(id: string, options: ZoneOptions): string {
|
|
1633
|
+
// Check if zone or entity already exists
|
|
1634
|
+
const zoneManager = this.physic.getZoneManager();
|
|
1635
|
+
if (this.physic.getEntityByUUID(id)) {
|
|
1636
|
+
throw new Error(`Zone with id ${id} already exists as entity`);
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
const radius = options.radius;
|
|
1640
|
+
if (typeof radius !== "number" || radius <= 0) {
|
|
1641
|
+
throw new Error("Zone radius must be a positive number");
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
// If linkedTo is specified, get the entity
|
|
1645
|
+
let attachedEntity: Entity | undefined;
|
|
1646
|
+
if (options.linkedTo) {
|
|
1647
|
+
attachedEntity = this.physic.getEntityByUUID(options.linkedTo);
|
|
1648
|
+
if (!attachedEntity) {
|
|
1649
|
+
throw new Error(`Cannot link zone to unknown entity ${options.linkedTo}`);
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
const callbacks: { onEnter?: (entities: Entity[]) => void; onExit?: (entities: Entity[]) => void } = {};
|
|
1654
|
+
|
|
1655
|
+
// Store callbacks for later updates
|
|
1656
|
+
(callbacks as any)._onEnterString = undefined;
|
|
1657
|
+
(callbacks as any)._onExitString = undefined;
|
|
1658
|
+
|
|
1659
|
+
const zoneId = attachedEntity
|
|
1660
|
+
? zoneManager.createAttachedZone(attachedEntity, {
|
|
1661
|
+
radius,
|
|
1662
|
+
angle: options.angle ?? 360,
|
|
1663
|
+
direction: options.direction ?? 'down',
|
|
1664
|
+
limitedByWalls: options.limitedByWalls ?? false,
|
|
1665
|
+
}, callbacks)
|
|
1666
|
+
: zoneManager.createZone({
|
|
1667
|
+
position: { x: options.x ?? 0, y: options.y ?? 0 },
|
|
1668
|
+
radius,
|
|
1669
|
+
angle: options.angle ?? 360,
|
|
1670
|
+
direction: options.direction ?? 'down',
|
|
1671
|
+
limitedByWalls: options.limitedByWalls ?? false,
|
|
1672
|
+
}, callbacks);
|
|
1673
|
+
|
|
1674
|
+
// Store zone ID mapping
|
|
1675
|
+
(this as any)._zoneIdMap = (this as any)._zoneIdMap || new Map();
|
|
1676
|
+
(this as any)._zoneIdMap.set(id, zoneId);
|
|
1677
|
+
|
|
1678
|
+
return id;
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
/**
|
|
1682
|
+
* Remove a zone
|
|
1683
|
+
* @private
|
|
1684
|
+
*/
|
|
1685
|
+
private removeZone(id: string): boolean {
|
|
1686
|
+
const zoneIdMap = (this as any)._zoneIdMap;
|
|
1687
|
+
if (!zoneIdMap) return false;
|
|
1688
|
+
|
|
1689
|
+
const zoneId = zoneIdMap.get(id);
|
|
1690
|
+
if (!zoneId) return false;
|
|
1691
|
+
|
|
1692
|
+
const zoneManager = this.physic.getZoneManager();
|
|
1693
|
+
zoneManager.removeZone(zoneId);
|
|
1694
|
+
zoneIdMap.delete(id);
|
|
1695
|
+
return true;
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
/**
|
|
1699
|
+
* Get a zone
|
|
1700
|
+
* @private
|
|
1701
|
+
*/
|
|
1702
|
+
private getZone(id: string): any {
|
|
1703
|
+
const zoneIdMap = (this as any)._zoneIdMap;
|
|
1704
|
+
if (!zoneIdMap) return undefined;
|
|
1705
|
+
|
|
1706
|
+
const zoneId = zoneIdMap.get(id);
|
|
1707
|
+
if (!zoneId) return undefined;
|
|
1708
|
+
|
|
1709
|
+
const zoneManager = this.physic.getZoneManager();
|
|
1710
|
+
return zoneManager.getZone(zoneId);
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
/**
|
|
1714
|
+
* Register zone events
|
|
1715
|
+
* @private
|
|
1716
|
+
*/
|
|
1717
|
+
private registerZoneEvents(
|
|
1718
|
+
id: string,
|
|
1719
|
+
onEnter?: (hitIds: string[]) => void,
|
|
1720
|
+
onExit?: (hitIds: string[]) => void,
|
|
1721
|
+
): boolean {
|
|
1722
|
+
const zoneIdMap = (this as any)._zoneIdMap;
|
|
1723
|
+
if (!zoneIdMap) return false;
|
|
1724
|
+
|
|
1725
|
+
const zoneId = zoneIdMap.get(id);
|
|
1726
|
+
if (!zoneId) return false;
|
|
1727
|
+
|
|
1728
|
+
const zoneManager = this.physic.getZoneManager();
|
|
1729
|
+
|
|
1730
|
+
// Use registerCallbacks to update callbacks
|
|
1731
|
+
const callbacks: { onEnter?: (entities: Entity[]) => void; onExit?: (entities: Entity[]) => void } = {};
|
|
1732
|
+
if (onEnter) {
|
|
1733
|
+
callbacks.onEnter = (entities: Entity[]) => {
|
|
1734
|
+
onEnter(entities.map(e => e.uuid));
|
|
1735
|
+
};
|
|
1736
|
+
}
|
|
1737
|
+
if (onExit) {
|
|
1738
|
+
callbacks.onExit = (entities: Entity[]) => {
|
|
1739
|
+
onExit(entities.map(e => e.uuid));
|
|
1740
|
+
};
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
return zoneManager.registerCallbacks(zoneId, callbacks);
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
/**
|
|
1747
|
+
* Run post-tick updates (update zones)
|
|
1748
|
+
* @private
|
|
1749
|
+
*/
|
|
1750
|
+
private runPostTickUpdates(): void {
|
|
1751
|
+
// Position sync is now handled automatically by entity.onPositionChange hooks
|
|
1752
|
+
// Movement callbacks are also handled in the onPositionChange handler
|
|
1753
|
+
|
|
1754
|
+
// Update zones
|
|
1755
|
+
const zoneManager = this.physic.getZoneManager();
|
|
1756
|
+
zoneManager.update();
|
|
1757
|
+
}
|
|
272
1758
|
}
|