@rpgjs/server 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/Gui/DialogGui.d.ts +5 -0
- package/dist/Gui/GameoverGui.d.ts +23 -0
- package/dist/Gui/Gui.d.ts +6 -0
- package/dist/Gui/MenuGui.d.ts +22 -3
- package/dist/Gui/NotificationGui.d.ts +1 -2
- package/dist/Gui/SaveLoadGui.d.ts +13 -0
- package/dist/Gui/ShopGui.d.ts +28 -3
- package/dist/Gui/TitleGui.d.ts +23 -0
- package/dist/Gui/index.d.ts +10 -1
- package/dist/Player/BattleManager.d.ts +34 -12
- package/dist/Player/ClassManager.d.ts +46 -13
- package/dist/Player/ComponentManager.d.ts +123 -0
- package/dist/Player/Components.d.ts +345 -0
- package/dist/Player/EffectManager.d.ts +86 -0
- package/dist/Player/ElementManager.d.ts +104 -0
- package/dist/Player/GoldManager.d.ts +22 -0
- package/dist/Player/GuiManager.d.ts +259 -0
- package/dist/Player/ItemFixture.d.ts +6 -0
- package/dist/Player/ItemManager.d.ts +450 -9
- package/dist/Player/MoveManager.d.ts +324 -69
- package/dist/Player/ParameterManager.d.ts +344 -14
- package/dist/Player/Player.d.ts +460 -8
- package/dist/Player/SkillManager.d.ts +197 -15
- package/dist/Player/StateManager.d.ts +89 -25
- package/dist/Player/VariableManager.d.ts +74 -0
- package/dist/RpgServer.d.ts +502 -64
- package/dist/RpgServerEngine.d.ts +2 -1
- package/dist/decorators/event.d.ts +46 -0
- package/dist/decorators/map.d.ts +287 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +21653 -20900
- package/dist/index.js.map +1 -1
- package/dist/logs/log.d.ts +2 -3
- package/dist/module.d.ts +43 -1
- package/dist/presets/index.d.ts +0 -9
- package/dist/rooms/BaseRoom.d.ts +132 -0
- package/dist/rooms/lobby.d.ts +10 -2
- package/dist/rooms/map.d.ts +1236 -17
- package/dist/services/save.d.ts +43 -0
- package/dist/storage/index.d.ts +1 -0
- package/dist/storage/localStorage.d.ts +23 -0
- package/package.json +14 -10
- package/src/Gui/DialogGui.ts +19 -4
- package/src/Gui/GameoverGui.ts +39 -0
- package/src/Gui/Gui.ts +23 -1
- package/src/Gui/MenuGui.ts +155 -6
- package/src/Gui/NotificationGui.ts +1 -2
- package/src/Gui/SaveLoadGui.ts +60 -0
- package/src/Gui/ShopGui.ts +146 -16
- package/src/Gui/TitleGui.ts +39 -0
- package/src/Gui/index.ts +15 -2
- package/src/Player/BattleManager.ts +91 -49
- package/src/Player/ClassManager.ts +118 -50
- package/src/Player/ComponentManager.ts +425 -19
- package/src/Player/Components.ts +380 -0
- package/src/Player/EffectManager.ts +81 -44
- package/src/Player/ElementManager.ts +109 -86
- package/src/Player/GoldManager.ts +32 -35
- package/src/Player/GuiManager.ts +308 -150
- package/src/Player/ItemFixture.ts +4 -5
- package/src/Player/ItemManager.ts +774 -355
- package/src/Player/MoveManager.ts +1544 -774
- package/src/Player/ParameterManager.ts +546 -104
- package/src/Player/Player.ts +1163 -88
- package/src/Player/SkillManager.ts +520 -195
- package/src/Player/StateManager.ts +170 -182
- package/src/Player/VariableManager.ts +101 -63
- package/src/RpgServer.ts +525 -63
- package/src/core/context.ts +1 -0
- package/src/decorators/event.ts +61 -0
- package/src/decorators/map.ts +327 -0
- package/src/index.ts +11 -1
- package/src/logs/log.ts +10 -3
- package/src/module.ts +126 -3
- package/src/presets/index.ts +1 -10
- package/src/rooms/BaseRoom.ts +232 -0
- package/src/rooms/lobby.ts +25 -7
- package/src/rooms/map.ts +2502 -194
- package/src/services/save.ts +147 -0
- package/src/storage/index.ts +1 -0
- package/src/storage/localStorage.ts +76 -0
- package/tests/battle.spec.ts +375 -0
- package/tests/change-map.spec.ts +72 -0
- package/tests/class.spec.ts +274 -0
- package/tests/effect.spec.ts +219 -0
- package/tests/element.spec.ts +221 -0
- package/tests/event.spec.ts +80 -0
- package/tests/gold.spec.ts +99 -0
- package/tests/item.spec.ts +609 -0
- package/tests/module.spec.ts +38 -0
- package/tests/move.spec.ts +601 -0
- package/tests/player-param.spec.ts +28 -0
- package/tests/prediction-reconciliation.spec.ts +182 -0
- package/tests/random-move.spec.ts +65 -0
- package/tests/skill.spec.ts +658 -0
- package/tests/state.spec.ts +467 -0
- package/tests/variable.spec.ts +185 -0
- package/tests/world-maps.spec.ts +896 -0
- package/vite.config.ts +16 -0
- package/dist/Player/Event.d.ts +0 -0
- package/src/Player/Event.ts +0 -0
package/src/rooms/map.ts
CHANGED
|
@@ -1,7 +1,21 @@
|
|
|
1
|
-
import { Action, MockConnection, Request, Room, RoomOnJoin } from "@signe/room";
|
|
2
|
-
import {
|
|
1
|
+
import { Action, MockConnection, Request, Room, RoomMethods, RoomOnJoin } from "@signe/room";
|
|
2
|
+
import {
|
|
3
|
+
Hooks,
|
|
4
|
+
IceMovement,
|
|
5
|
+
ModulesToken,
|
|
6
|
+
ProjectileMovement,
|
|
7
|
+
ProjectileType,
|
|
8
|
+
RpgCommonMap,
|
|
9
|
+
Direction,
|
|
10
|
+
RpgCommonPlayer,
|
|
11
|
+
RpgShape,
|
|
12
|
+
findModules,
|
|
13
|
+
type MapPhysicsInitContext,
|
|
14
|
+
type MapPhysicsEntityContext,
|
|
15
|
+
} from "@rpgjs/common";
|
|
16
|
+
import { WorldMapsManager, type WeatherState, type WorldMapConfig } from "@rpgjs/common";
|
|
3
17
|
import { RpgPlayer, RpgEvent } from "../Player/Player";
|
|
4
|
-
import { generateShortUUID, sync, users } from "@signe/sync";
|
|
18
|
+
import { generateShortUUID, sync, type, users } from "@signe/sync";
|
|
5
19
|
import { signal } from "@signe/reactive";
|
|
6
20
|
import { inject } from "@signe/di";
|
|
7
21
|
import { context } from "../core/context";;
|
|
@@ -9,6 +23,61 @@ import { finalize, lastValueFrom } from "rxjs";
|
|
|
9
23
|
import { Subject } from "rxjs";
|
|
10
24
|
import { BehaviorSubject } from "rxjs";
|
|
11
25
|
import { COEFFICIENT_ELEMENTS, DAMAGE_CRITICAL, DAMAGE_PHYSIC, DAMAGE_SKILL } from "../presets";
|
|
26
|
+
import { z } from "zod";
|
|
27
|
+
import { EntityState } from "@rpgjs/physic";
|
|
28
|
+
import { MapOptions } from "../decorators/map";
|
|
29
|
+
import { EventMode } from "../decorators/event";
|
|
30
|
+
import { BaseRoom } from "./BaseRoom";
|
|
31
|
+
import { buildSaveSlotMeta, resolveSaveStorageStrategy } from "../services/save";
|
|
32
|
+
import { Log } from "../logs/log";
|
|
33
|
+
|
|
34
|
+
function isRpgLog(error: unknown): error is Log {
|
|
35
|
+
return error instanceof Log
|
|
36
|
+
|| (typeof error === "object"
|
|
37
|
+
&& error !== null
|
|
38
|
+
&& "id" in error
|
|
39
|
+
&& (error as any).name === "RpgLog");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Interface for input controls configuration
|
|
44
|
+
*
|
|
45
|
+
* Defines the structure for input validation and anti-cheat controls
|
|
46
|
+
*/
|
|
47
|
+
export interface Controls {
|
|
48
|
+
/** Maximum allowed time delta between inputs in milliseconds */
|
|
49
|
+
maxTimeDelta?: number;
|
|
50
|
+
/** Maximum allowed frame delta between inputs */
|
|
51
|
+
maxFrameDelta?: number;
|
|
52
|
+
/** Minimum time between inputs in milliseconds */
|
|
53
|
+
minTimeBetweenInputs?: number;
|
|
54
|
+
/** Whether to enable anti-cheat validation */
|
|
55
|
+
enableAntiCheat?: boolean;
|
|
56
|
+
/** Maximum number of queued inputs processed per server tick */
|
|
57
|
+
maxInputsPerTick?: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Zod schema for validating map update request body
|
|
62
|
+
*
|
|
63
|
+
* This schema ensures that the required fields are present and properly typed
|
|
64
|
+
* when updating a map configuration.
|
|
65
|
+
*/
|
|
66
|
+
const MapUpdateSchema = z.object({
|
|
67
|
+
/** Configuration object for the map (optional) */
|
|
68
|
+
config: z.any().optional(),
|
|
69
|
+
/** Damage formulas configuration (optional) */
|
|
70
|
+
damageFormulas: z.any().optional(),
|
|
71
|
+
/** Unique identifier for the map (required) */
|
|
72
|
+
id: z.string(),
|
|
73
|
+
/** Width of the map in pixels (required) */
|
|
74
|
+
width: z.number(),
|
|
75
|
+
/** Height of the map in pixels (required) */
|
|
76
|
+
height: z.number(),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const SAFE_MAP_WIDTH = 1000;
|
|
80
|
+
const SAFE_MAP_HEIGHT = 1000;
|
|
12
81
|
|
|
13
82
|
/**
|
|
14
83
|
* Interface representing hook methods available for map events
|
|
@@ -25,12 +94,13 @@ export interface EventHooks {
|
|
|
25
94
|
/** Called when a player touches this event */
|
|
26
95
|
onPlayerTouch?: (player: RpgPlayer) => void;
|
|
27
96
|
/** Called when a player enters a shape */
|
|
28
|
-
onInShape?: (zone:
|
|
97
|
+
onInShape?: (zone: RpgShape, player: RpgPlayer) => void;
|
|
29
98
|
/** Called when a player exits a shape */
|
|
30
|
-
onOutShape?: (zone:
|
|
31
|
-
|
|
32
|
-
onDetectInShape?: (player: RpgPlayer, shape:
|
|
33
|
-
|
|
99
|
+
onOutShape?: (zone: RpgShape, player: RpgPlayer) => void;
|
|
100
|
+
/** Called when a player is detected entering a shape */
|
|
101
|
+
onDetectInShape?: (player: RpgPlayer, shape: RpgShape) => void;
|
|
102
|
+
/** Called when a player is detected exiting a shape */
|
|
103
|
+
onDetectOutShape?: (player: RpgPlayer, shape: RpgShape) => void;
|
|
34
104
|
}
|
|
35
105
|
|
|
36
106
|
/** Type for event class constructor */
|
|
@@ -42,9 +112,13 @@ export type EventPosOption = {
|
|
|
42
112
|
id?: string,
|
|
43
113
|
|
|
44
114
|
/** X position of the event on the map */
|
|
45
|
-
x
|
|
115
|
+
x?: number,
|
|
46
116
|
/** Y position of the event on the map */
|
|
47
|
-
y
|
|
117
|
+
y?: number,
|
|
118
|
+
/** Event mode override */
|
|
119
|
+
mode?: EventMode | "shared" | "scenario",
|
|
120
|
+
/** Owner player id when mode is scenario */
|
|
121
|
+
scenarioOwnerId?: string,
|
|
48
122
|
/**
|
|
49
123
|
* Event definition - can be either:
|
|
50
124
|
* - A class that extends RpgPlayer
|
|
@@ -53,259 +127,2493 @@ export type EventPosOption = {
|
|
|
53
127
|
event: EventConstructor | (EventHooks & Record<string, any>)
|
|
54
128
|
}
|
|
55
129
|
|
|
130
|
+
type CreateDynamicEventOptions = {
|
|
131
|
+
mode?: EventMode | "shared" | "scenario";
|
|
132
|
+
scenarioOwnerId?: string;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
interface WeatherSetOptions {
|
|
136
|
+
sync?: boolean;
|
|
137
|
+
}
|
|
138
|
+
|
|
56
139
|
@Room({
|
|
57
|
-
path: "map-{id}"
|
|
58
|
-
throttleSync: 0
|
|
140
|
+
path: "map-{id}"
|
|
59
141
|
})
|
|
60
142
|
export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
|
|
143
|
+
/**
|
|
144
|
+
* Synchronized signal containing all players currently on the map
|
|
145
|
+
*
|
|
146
|
+
* This signal is automatically synchronized with clients using @signe/sync.
|
|
147
|
+
* Players are indexed by their unique ID.
|
|
148
|
+
*
|
|
149
|
+
* @example
|
|
150
|
+
* ```ts
|
|
151
|
+
* // Get all players
|
|
152
|
+
* const allPlayers = map.players();
|
|
153
|
+
*
|
|
154
|
+
* // Get a specific player
|
|
155
|
+
* const player = map.players()['player-id'];
|
|
156
|
+
* ```
|
|
157
|
+
*/
|
|
61
158
|
@users(RpgPlayer) players = signal({});
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Synchronized signal containing all events (NPCs, objects) on the map
|
|
162
|
+
*
|
|
163
|
+
* This signal is automatically synchronized with clients using @signe/sync.
|
|
164
|
+
* Events are indexed by their unique ID.
|
|
165
|
+
*
|
|
166
|
+
* @example
|
|
167
|
+
* ```ts
|
|
168
|
+
* // Get all events
|
|
169
|
+
* const allEvents = map.events();
|
|
170
|
+
*
|
|
171
|
+
* // Get a specific event
|
|
172
|
+
* const event = map.events()['event-id'];
|
|
173
|
+
* ```
|
|
174
|
+
*/
|
|
62
175
|
@sync(RpgPlayer) events = signal({});
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Signal containing the map's database of items, classes, and other game data
|
|
179
|
+
*
|
|
180
|
+
* This database can be dynamically populated using `addInDatabase()` and
|
|
181
|
+
* `removeInDatabase()` methods. It's used to store game entities like items,
|
|
182
|
+
* classes, skills, etc. that are specific to this map.
|
|
183
|
+
*
|
|
184
|
+
* @example
|
|
185
|
+
* ```ts
|
|
186
|
+
* // Add data to database
|
|
187
|
+
* map.addInDatabase('Potion', PotionClass);
|
|
188
|
+
*
|
|
189
|
+
* // Access database
|
|
190
|
+
* const potion = map.database()['Potion'];
|
|
191
|
+
* ```
|
|
192
|
+
*/
|
|
63
193
|
database = signal({});
|
|
64
|
-
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Array of map configurations - can contain MapOptions objects or instances of map classes
|
|
197
|
+
*
|
|
198
|
+
* This array stores the configuration for this map and any related maps.
|
|
199
|
+
* It's populated when the map is loaded via `updateMap()`.
|
|
200
|
+
*/
|
|
201
|
+
maps: (MapOptions | any)[] = []
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Array of sound IDs to play when players join the map
|
|
205
|
+
*
|
|
206
|
+
* These sounds are automatically played for each player when they join the map.
|
|
207
|
+
* Sounds must be defined on the client side.
|
|
208
|
+
*
|
|
209
|
+
* @example
|
|
210
|
+
* ```ts
|
|
211
|
+
* // Set sounds for the map
|
|
212
|
+
* map.sounds = ['background-music', 'ambient-forest'];
|
|
213
|
+
* ```
|
|
214
|
+
*/
|
|
215
|
+
sounds: string[] = []
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* BehaviorSubject that completes when the map data is ready
|
|
219
|
+
*
|
|
220
|
+
* This subject is used to signal when the map has finished loading all its data.
|
|
221
|
+
* Players wait for this to complete before the map is fully initialized.
|
|
222
|
+
*
|
|
223
|
+
* @example
|
|
224
|
+
* ```ts
|
|
225
|
+
* // Wait for map data to be ready
|
|
226
|
+
* map.dataIsReady$.subscribe(() => {
|
|
227
|
+
* console.log('Map is ready!');
|
|
228
|
+
* });
|
|
229
|
+
* ```
|
|
230
|
+
*/
|
|
65
231
|
dataIsReady$ = new BehaviorSubject<void>(undefined);
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Global configuration object for the map
|
|
235
|
+
*
|
|
236
|
+
* This object contains configuration settings that apply to the entire map.
|
|
237
|
+
* It's populated from the map data when `updateMap()` is called.
|
|
238
|
+
*/
|
|
66
239
|
globalConfig: any = {}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Damage formulas configuration for the map
|
|
243
|
+
*
|
|
244
|
+
* Contains formulas for calculating damage from skills, physical attacks,
|
|
245
|
+
* critical hits, and element coefficients. Default formulas are merged
|
|
246
|
+
* with custom formulas when the map is loaded.
|
|
247
|
+
*/
|
|
67
248
|
damageFormulas: any = {}
|
|
249
|
+
private _weatherState: WeatherState | null = null;
|
|
250
|
+
/** Internal: Map of shapes by name */
|
|
251
|
+
private _shapes: Map<string, RpgShape> = new Map();
|
|
252
|
+
/** Internal: Map of shape entity UUIDs to RpgShape instances */
|
|
253
|
+
private _shapeEntities: Map<string, RpgShape> = new Map();
|
|
254
|
+
/** Internal: Subscription for the input processing loop */
|
|
255
|
+
private _inputLoopSubscription?: any;
|
|
256
|
+
/** Enable/disable automatic tick processing (useful for unit tests) */
|
|
257
|
+
private _autoTickEnabled: boolean = true;
|
|
258
|
+
/** Runtime templates for scenario events to instantiate per player */
|
|
259
|
+
private _scenarioEventTemplates: EventPosOption[] = [];
|
|
260
|
+
/** Runtime registry of event mode by id */
|
|
261
|
+
private _eventModeById: Map<string, EventMode> = new Map();
|
|
262
|
+
/** Runtime registry of scenario owner by event id */
|
|
263
|
+
private _eventOwnerById: Map<string, string> = new Map();
|
|
264
|
+
/** Runtime registry of spawned scenario event ids by player id */
|
|
265
|
+
private _scenarioEventIdsByPlayer: Map<string, Set<string>> = new Map();
|
|
68
266
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
this.
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
267
|
+
autoSync: boolean = true;
|
|
268
|
+
|
|
269
|
+
constructor(room) {
|
|
270
|
+
super();
|
|
271
|
+
this.hooks.callHooks("server-map-onStart", this).subscribe();
|
|
272
|
+
const isTest = room.env.TEST === 'true' ? true : false;
|
|
273
|
+
if (isTest) {
|
|
274
|
+
this.autoSync = false;
|
|
275
|
+
this.setAutoTick(false);
|
|
276
|
+
this.autoTickEnabled = false;
|
|
277
|
+
this.throttleSync = 0;
|
|
278
|
+
this.throttleStorage = 0;
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
this.throttleSync = this.isStandalone ? 1 : 50
|
|
282
|
+
this.throttleStorage = this.isStandalone ? 1 : 50
|
|
283
|
+
};
|
|
284
|
+
this.sessionExpiryTime = 1000 * 60 * 5;
|
|
285
|
+
this.setupCollisionDetection();
|
|
286
|
+
if (this._autoTickEnabled) {
|
|
287
|
+
this.loop();
|
|
288
|
+
}
|
|
90
289
|
}
|
|
91
290
|
|
|
92
|
-
|
|
93
|
-
return
|
|
291
|
+
onStart() {
|
|
292
|
+
return BaseRoom.prototype.onStart.call(this)
|
|
94
293
|
}
|
|
95
294
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
//this.hooks.callHooks("server-player-guiInteraction", player, value);
|
|
99
|
-
player.syncChanges();
|
|
295
|
+
protected emitPhysicsInit(context: MapPhysicsInitContext): void {
|
|
296
|
+
this.hooks.callHooks("server-map-onPhysicsInit", this, context).subscribe();
|
|
100
297
|
}
|
|
101
298
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
player.removeGui(guiId, data)
|
|
299
|
+
protected emitPhysicsEntityAdd(context: MapPhysicsEntityContext): void {
|
|
300
|
+
this.hooks.callHooks("server-map-onPhysicsEntityAdd", this, context).subscribe();
|
|
105
301
|
}
|
|
106
302
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
303
|
+
protected emitPhysicsEntityRemove(context: MapPhysicsEntityContext): void {
|
|
304
|
+
this.hooks.callHooks("server-map-onPhysicsEntityRemove", this, context).subscribe();
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
protected emitPhysicsReset(): void {
|
|
308
|
+
this.hooks.callHooks("server-map-onPhysicsReset", this).subscribe();
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
private isPositiveNumber(value: unknown): value is number {
|
|
312
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
private resolveTrustedMapDimensions(map: any): void {
|
|
316
|
+
const normalizedId = typeof map?.id === "string"
|
|
317
|
+
? map.id.replace(/^map-/, "")
|
|
318
|
+
: "";
|
|
319
|
+
const worldMapInfo = normalizedId
|
|
320
|
+
? this.worldMapsManager?.getMapInfo(normalizedId)
|
|
321
|
+
: null;
|
|
322
|
+
|
|
323
|
+
if (!this.isPositiveNumber(map?.width)) {
|
|
324
|
+
map.width = this.isPositiveNumber(worldMapInfo?.width)
|
|
325
|
+
? worldMapInfo.width
|
|
326
|
+
: SAFE_MAP_WIDTH;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (!this.isPositiveNumber(map?.height)) {
|
|
330
|
+
map.height = this.isPositiveNumber(worldMapInfo?.height)
|
|
331
|
+
? worldMapInfo.height
|
|
332
|
+
: SAFE_MAP_HEIGHT;
|
|
115
333
|
}
|
|
116
|
-
player.execMethod('onInput', [action]);
|
|
117
334
|
}
|
|
118
335
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
336
|
+
private normalizeEventMode(mode: unknown): EventMode {
|
|
337
|
+
return mode === EventMode.Scenario || mode === "scenario"
|
|
338
|
+
? EventMode.Scenario
|
|
339
|
+
: EventMode.Shared;
|
|
122
340
|
}
|
|
123
341
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const map = await request.json()
|
|
130
|
-
this.data.set(map)
|
|
131
|
-
this.globalConfig = map.config
|
|
132
|
-
this.damageFormulas = map.damageFormulas || {};
|
|
133
|
-
this.damageFormulas = {
|
|
134
|
-
damageSkill: DAMAGE_SKILL,
|
|
135
|
-
damagePhysic: DAMAGE_PHYSIC,
|
|
136
|
-
damageCritical: DAMAGE_CRITICAL,
|
|
137
|
-
coefficientElements: COEFFICIENT_ELEMENTS,
|
|
138
|
-
...this.damageFormulas
|
|
342
|
+
private resolveEventMode(eventObj: any): EventMode {
|
|
343
|
+
if (!eventObj) return EventMode.Shared;
|
|
344
|
+
|
|
345
|
+
if (eventObj.mode !== undefined) {
|
|
346
|
+
return this.normalizeEventMode(eventObj.mode);
|
|
139
347
|
}
|
|
140
|
-
await lastValueFrom(this.hooks.callHooks("server-maps-load", this))
|
|
141
348
|
|
|
142
|
-
|
|
349
|
+
const eventDef = eventObj.event ?? eventObj;
|
|
350
|
+
if (eventDef?.mode !== undefined) {
|
|
351
|
+
return this.normalizeEventMode(eventDef.mode);
|
|
352
|
+
}
|
|
143
353
|
|
|
144
|
-
if (
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
354
|
+
if (typeof eventDef === "function") {
|
|
355
|
+
const staticMode = (eventDef as any).mode;
|
|
356
|
+
const prototypeMode = (eventDef as any).prototype?.mode;
|
|
357
|
+
if (staticMode !== undefined) {
|
|
358
|
+
return this.normalizeEventMode(staticMode);
|
|
359
|
+
}
|
|
360
|
+
if (prototypeMode !== undefined) {
|
|
361
|
+
return this.normalizeEventMode(prototypeMode);
|
|
362
|
+
}
|
|
152
363
|
}
|
|
153
364
|
|
|
154
|
-
|
|
365
|
+
return EventMode.Shared;
|
|
366
|
+
}
|
|
155
367
|
|
|
156
|
-
|
|
368
|
+
private resolveScenarioOwnerId(eventObj: any): string | undefined {
|
|
369
|
+
if (!eventObj) return undefined;
|
|
370
|
+
const ownerId = eventObj.scenarioOwnerId
|
|
371
|
+
?? eventObj._scenarioOwnerId
|
|
372
|
+
?? eventObj.event?.scenarioOwnerId
|
|
373
|
+
?? eventObj.event?._scenarioOwnerId;
|
|
374
|
+
return typeof ownerId === "string" && ownerId.length > 0 ? ownerId : undefined;
|
|
375
|
+
}
|
|
157
376
|
|
|
158
|
-
|
|
159
|
-
|
|
377
|
+
private normalizeEventObject(eventObj: EventPosOption | any): EventPosOption {
|
|
378
|
+
if (eventObj && typeof eventObj === "object" && "event" in eventObj) {
|
|
379
|
+
return eventObj as EventPosOption;
|
|
160
380
|
}
|
|
381
|
+
return {
|
|
382
|
+
event: eventObj as any,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
161
385
|
|
|
162
|
-
|
|
163
|
-
|
|
386
|
+
private cloneEventTemplate(eventObj: EventPosOption): EventPosOption {
|
|
387
|
+
const clone: EventPosOption = { ...eventObj };
|
|
388
|
+
if (clone.event && typeof clone.event === "object") {
|
|
389
|
+
clone.event = { ...(clone.event as Record<string, any>) } as any;
|
|
390
|
+
}
|
|
391
|
+
return clone;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
private buildRuntimeEventId(baseId: string | undefined, mode: EventMode, scenarioOwnerId?: string): string {
|
|
395
|
+
const fallbackId = baseId || generateShortUUID();
|
|
396
|
+
if (mode !== EventMode.Scenario || !scenarioOwnerId) {
|
|
397
|
+
return fallbackId;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const scopedId = `${fallbackId}::${scenarioOwnerId}`;
|
|
401
|
+
if (!this.events()[scopedId]) {
|
|
402
|
+
return scopedId;
|
|
403
|
+
}
|
|
404
|
+
return `${scopedId}::${generateShortUUID()}`;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
private setEventRuntimeMetadata(eventId: string, mode: EventMode, scenarioOwnerId?: string): void {
|
|
408
|
+
this._eventModeById.set(eventId, mode);
|
|
409
|
+
if (mode === EventMode.Scenario && scenarioOwnerId) {
|
|
410
|
+
this._eventOwnerById.set(eventId, scenarioOwnerId);
|
|
411
|
+
const ids = this._scenarioEventIdsByPlayer.get(scenarioOwnerId) ?? new Set<string>();
|
|
412
|
+
ids.add(eventId);
|
|
413
|
+
this._scenarioEventIdsByPlayer.set(scenarioOwnerId, ids);
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
this._eventOwnerById.delete(eventId);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
private clearEventRuntimeMetadata(eventId: string): void {
|
|
420
|
+
this._eventModeById.delete(eventId);
|
|
421
|
+
const ownerId = this._eventOwnerById.get(eventId);
|
|
422
|
+
if (ownerId) {
|
|
423
|
+
const ids = this._scenarioEventIdsByPlayer.get(ownerId);
|
|
424
|
+
if (ids) {
|
|
425
|
+
ids.delete(eventId);
|
|
426
|
+
if (ids.size === 0) {
|
|
427
|
+
this._scenarioEventIdsByPlayer.delete(ownerId);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
this._eventOwnerById.delete(eventId);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
private getEventModeById(eventId: string): EventMode {
|
|
435
|
+
const runtimeMode = this._eventModeById.get(eventId);
|
|
436
|
+
if (runtimeMode) {
|
|
437
|
+
return runtimeMode;
|
|
438
|
+
}
|
|
439
|
+
const event = this.getEvent(eventId) as any;
|
|
440
|
+
return this.normalizeEventMode(event?.mode);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
private getScenarioOwnerIdByEventId(eventId: string): string | undefined {
|
|
444
|
+
const runtimeOwnerId = this._eventOwnerById.get(eventId);
|
|
445
|
+
if (runtimeOwnerId) {
|
|
446
|
+
return runtimeOwnerId;
|
|
447
|
+
}
|
|
448
|
+
const event = this.getEvent(eventId) as any;
|
|
449
|
+
const ownerId = event?._scenarioOwnerId ?? event?.scenarioOwnerId;
|
|
450
|
+
return typeof ownerId === "string" && ownerId.length > 0 ? ownerId : undefined;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
isEventVisibleForPlayer(eventOrId: string | RpgEvent, playerOrId: string | RpgPlayer): boolean {
|
|
454
|
+
const playerId = typeof playerOrId === "string" ? playerOrId : playerOrId?.id;
|
|
455
|
+
if (!playerId) {
|
|
456
|
+
return false;
|
|
457
|
+
}
|
|
458
|
+
const eventId = typeof eventOrId === "string" ? eventOrId : eventOrId?.id;
|
|
459
|
+
if (!eventId) {
|
|
460
|
+
return false;
|
|
461
|
+
}
|
|
462
|
+
const mode = this.getEventModeById(eventId);
|
|
463
|
+
if (mode === EventMode.Shared) {
|
|
464
|
+
return true;
|
|
465
|
+
}
|
|
466
|
+
const ownerId = this.getScenarioOwnerIdByEventId(eventId);
|
|
467
|
+
return ownerId === playerId;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
private async spawnScenarioEventsForPlayer(player: RpgPlayer): Promise<void> {
|
|
471
|
+
if (!player?.id || this._scenarioEventTemplates.length === 0) {
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
this.removeScenarioEventsForPlayer(player.id);
|
|
475
|
+
for (const template of this._scenarioEventTemplates) {
|
|
476
|
+
const clone = this.cloneEventTemplate(template);
|
|
477
|
+
await this.createDynamicEvent(clone, { mode: EventMode.Scenario, scenarioOwnerId: player.id });
|
|
478
|
+
}
|
|
164
479
|
}
|
|
165
480
|
|
|
166
|
-
|
|
167
|
-
this.
|
|
481
|
+
private removeScenarioEventsForPlayer(playerId: string): void {
|
|
482
|
+
const ids = this._scenarioEventIdsByPlayer.get(playerId);
|
|
483
|
+
if (!ids || ids.size === 0) {
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
for (const eventId of [...ids]) {
|
|
487
|
+
const event = this.getEvent(eventId) as any;
|
|
488
|
+
if (event && typeof event.remove === "function") {
|
|
489
|
+
try {
|
|
490
|
+
event.remove();
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
catch {
|
|
494
|
+
// Fallback to direct map removal when the event lifecycle is already partially torn down.
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
this.removeEvent(eventId);
|
|
498
|
+
}
|
|
499
|
+
this._scenarioEventIdsByPlayer.delete(playerId);
|
|
168
500
|
}
|
|
169
501
|
|
|
170
502
|
/**
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
* This method handles both class-based events and object-based events with hooks.
|
|
174
|
-
* For class-based events, it creates a new instance of the class.
|
|
175
|
-
* For object-based events, it creates a dynamic class that extends RpgPlayer and
|
|
176
|
-
* implements the hook methods from the object.
|
|
503
|
+
* Setup collision detection between players, events, and shapes
|
|
177
504
|
*
|
|
178
|
-
*
|
|
505
|
+
* This method listens to physics collision events and triggers hooks:
|
|
506
|
+
* - `onPlayerTouch` on events when a player collides with them
|
|
507
|
+
* - `onInShape` on players and events when they enter a shape
|
|
508
|
+
* - `onOutShape` on players and events when they exit a shape
|
|
179
509
|
*
|
|
180
|
-
*
|
|
181
|
-
* // Using a class-based event
|
|
182
|
-
* class MyEvent extends RpgPlayer {
|
|
183
|
-
* onInit() {
|
|
184
|
-
* console.log('Event initialized');
|
|
185
|
-
* }
|
|
186
|
-
* }
|
|
510
|
+
* ## Architecture
|
|
187
511
|
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
* });
|
|
512
|
+
* Uses the physics engine's collision event system to detect when entities collide.
|
|
513
|
+
* When a collision is detected:
|
|
514
|
+
* - Between a player and an event: triggers `onPlayerTouch` on the event
|
|
515
|
+
* - Between a player/event and a shape: triggers `onInShape`/`onOutShape` hooks
|
|
193
516
|
*
|
|
194
|
-
*
|
|
517
|
+
* @example
|
|
518
|
+
* ```ts
|
|
519
|
+
* // Event with onPlayerTouch hook
|
|
195
520
|
* map.createDynamicEvent({
|
|
196
521
|
* x: 100,
|
|
197
522
|
* y: 200,
|
|
198
523
|
* event: {
|
|
199
|
-
* onInit() {
|
|
200
|
-
* console.log('Event initialized');
|
|
201
|
-
* },
|
|
202
524
|
* onPlayerTouch(player) {
|
|
203
|
-
* console.log(
|
|
525
|
+
* console.log(`Player ${player.id} touched this event!`);
|
|
204
526
|
* }
|
|
205
527
|
* }
|
|
206
528
|
* });
|
|
529
|
+
*
|
|
530
|
+
* // Player with onInShape hook
|
|
531
|
+
* const player: RpgPlayerHooks = {
|
|
532
|
+
* onInShape(player: RpgPlayer, shape: RpgShape) {
|
|
533
|
+
* console.log('in', player.name, shape.name);
|
|
534
|
+
* },
|
|
535
|
+
* onOutShape(player: RpgPlayer, shape: RpgShape) {
|
|
536
|
+
* console.log('out', player.name, shape.name);
|
|
537
|
+
* }
|
|
538
|
+
* };
|
|
539
|
+
* ```
|
|
207
540
|
*/
|
|
208
|
-
|
|
541
|
+
private setupCollisionDetection(): void {
|
|
542
|
+
// Track collisions to avoid calling hooks multiple times for the same collision
|
|
543
|
+
const activeCollisions = new Set<string>();
|
|
544
|
+
const activeShapeCollisions = new Set<string>();
|
|
545
|
+
|
|
546
|
+
// Helper function to check if entities have different z (height)
|
|
547
|
+
const hasDifferentZ = (entityA: any, entityB: any): boolean => {
|
|
548
|
+
const zA = entityA.owner?.z();
|
|
549
|
+
const zB = entityB.owner?.z();
|
|
550
|
+
return zA !== zB;
|
|
551
|
+
};
|
|
209
552
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
553
|
+
// Listen to collision enter events
|
|
554
|
+
this.physic.getEvents().onCollisionEnter((collision) => {
|
|
555
|
+
const entityA = collision.entityA;
|
|
556
|
+
const entityB = collision.entityB;
|
|
557
|
+
|
|
558
|
+
// Skip collision callbacks if entities have different z (height)
|
|
559
|
+
// Higher z entities should not trigger collision callbacks with lower z entities
|
|
560
|
+
if (hasDifferentZ(entityA, entityB)) {
|
|
561
|
+
return;
|
|
214
562
|
}
|
|
215
|
-
}
|
|
216
563
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
564
|
+
// Create a unique key for this collision pair
|
|
565
|
+
const collisionKey = entityA.uuid < entityB.uuid
|
|
566
|
+
? `${entityA.uuid}-${entityB.uuid}`
|
|
567
|
+
: `${entityB.uuid}-${entityA.uuid}`;
|
|
221
568
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
569
|
+
// Skip if we've already processed this collision
|
|
570
|
+
if (activeCollisions.has(collisionKey)) {
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
225
573
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
}
|
|
574
|
+
// Check for shape collisions first
|
|
575
|
+
const shapeA = this._shapeEntities.get(entityA.uuid);
|
|
576
|
+
const shapeB = this._shapeEntities.get(entityB.uuid);
|
|
230
577
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
// Handle event as an object with hooks
|
|
236
|
-
else {
|
|
237
|
-
// Create a new instance extending RpgPlayer with the hooks from the event object
|
|
238
|
-
class DynamicEvent extends RpgEvent {
|
|
239
|
-
onInit?: () => void;
|
|
240
|
-
onChanges?: (player: RpgPlayer) => void;
|
|
241
|
-
onAction?: (player: RpgPlayer) => void;
|
|
242
|
-
onPlayerTouch?: (player: RpgPlayer) => void;
|
|
243
|
-
onInShape?: (zone: ZoneData, player: RpgPlayer) => void;
|
|
244
|
-
onOutShape?: (zone: ZoneData, player: RpgPlayer) => void;
|
|
245
|
-
onDetectInShape?: (player: RpgPlayer, shape: ZoneData) => void;
|
|
246
|
-
onDetectOutShape?: (player: RpgPlayer, shape: ZoneData) => void;
|
|
578
|
+
if (shapeA || shapeB) {
|
|
579
|
+
// One of the entities is a shape
|
|
580
|
+
const shape = shapeA || shapeB;
|
|
581
|
+
const otherEntity = shapeA ? entityB : entityA;
|
|
247
582
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
583
|
+
if (shape) {
|
|
584
|
+
const shapeKey = `${otherEntity.uuid}-${shape.name}`;
|
|
585
|
+
if (!activeShapeCollisions.has(shapeKey)) {
|
|
586
|
+
activeShapeCollisions.add(shapeKey);
|
|
587
|
+
|
|
588
|
+
// Check if the other entity is a player or event
|
|
589
|
+
const player = this.getPlayer(otherEntity.uuid);
|
|
590
|
+
const event = this.getEvent(otherEntity.uuid);
|
|
591
|
+
|
|
592
|
+
if (player) {
|
|
593
|
+
// Trigger onInShape hook on player
|
|
594
|
+
player.execMethod('onInShape', [player, shape]);
|
|
595
|
+
}
|
|
596
|
+
if (event) {
|
|
597
|
+
// Trigger onInShape hook on event
|
|
598
|
+
event.execMethod('onInShape', [shape, player || event]);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
261
601
|
}
|
|
602
|
+
return;
|
|
262
603
|
}
|
|
263
604
|
|
|
264
|
-
|
|
265
|
-
|
|
605
|
+
// Check if one entity is a player and the other is an event
|
|
606
|
+
const player = this.getPlayer(entityA.uuid) || this.getPlayer(entityB.uuid);
|
|
607
|
+
if (!player) {
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
266
610
|
|
|
267
|
-
|
|
268
|
-
|
|
611
|
+
// Determine which entity is the event
|
|
612
|
+
const eventId = player.id === entityA.uuid ? entityB.uuid : entityA.uuid;
|
|
613
|
+
const event = this.getEvent(eventId);
|
|
269
614
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
this.events()[id] = eventInstance;
|
|
615
|
+
if (event && this.isEventVisibleForPlayer(eventId, player)) {
|
|
616
|
+
// Mark this collision as processed
|
|
617
|
+
activeCollisions.add(collisionKey);
|
|
274
618
|
|
|
275
|
-
|
|
276
|
-
|
|
619
|
+
// Trigger the onPlayerTouch hook on the event
|
|
620
|
+
event.execMethod('onPlayerTouch', [player]);
|
|
621
|
+
}
|
|
622
|
+
});
|
|
277
623
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
624
|
+
// Listen to collision exit events to clean up tracking
|
|
625
|
+
this.physic.getEvents().onCollisionExit((collision) => {
|
|
626
|
+
const entityA = collision.entityA;
|
|
627
|
+
const entityB = collision.entityB;
|
|
281
628
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
629
|
+
// Skip collision callbacks if entities have different z (height)
|
|
630
|
+
if (hasDifferentZ(entityA, entityB)) {
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
285
633
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
634
|
+
const collisionKey = entityA.uuid < entityB.uuid
|
|
635
|
+
? `${entityA.uuid}-${entityB.uuid}`
|
|
636
|
+
: `${entityB.uuid}-${entityA.uuid}`;
|
|
289
637
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
638
|
+
// Check for shape collisions
|
|
639
|
+
const shapeA = this._shapeEntities.get(entityA.uuid);
|
|
640
|
+
const shapeB = this._shapeEntities.get(entityB.uuid);
|
|
293
641
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
name
|
|
301
|
-
|
|
302
|
-
|
|
642
|
+
if (shapeA || shapeB) {
|
|
643
|
+
// One of the entities is a shape
|
|
644
|
+
const shape = shapeA || shapeB;
|
|
645
|
+
const otherEntity = shapeA ? entityB : entityA;
|
|
646
|
+
|
|
647
|
+
if (shape) {
|
|
648
|
+
const shapeKey = `${otherEntity.uuid}-${shape.name}`;
|
|
649
|
+
if (activeShapeCollisions.has(shapeKey)) {
|
|
650
|
+
activeShapeCollisions.delete(shapeKey);
|
|
651
|
+
|
|
652
|
+
// Check if the other entity is a player or event
|
|
653
|
+
const player = this.getPlayer(otherEntity.uuid);
|
|
654
|
+
const event = this.getEvent(otherEntity.uuid);
|
|
655
|
+
|
|
656
|
+
if (player) {
|
|
657
|
+
// Trigger onOutShape hook on player
|
|
658
|
+
player.execMethod('onOutShape', [player, shape]);
|
|
659
|
+
}
|
|
660
|
+
if (event) {
|
|
661
|
+
// Trigger onOutShape hook on event
|
|
662
|
+
event.execMethod('onOutShape', [shape, player || event]);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
return;
|
|
303
667
|
}
|
|
304
|
-
|
|
668
|
+
|
|
669
|
+
// Remove from active collisions so onPlayerTouch can be called again if they collide again
|
|
670
|
+
activeCollisions.delete(collisionKey);
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* Intercepts and modifies packets before they are sent to clients
|
|
676
|
+
*
|
|
677
|
+
* This method is automatically called by @signe/room for each packet sent to clients.
|
|
678
|
+
* It adds timestamp and acknowledgment information to sync packets for client-side
|
|
679
|
+
* prediction reconciliation. This helps with network synchronization and reduces
|
|
680
|
+
* perceived latency.
|
|
681
|
+
*
|
|
682
|
+
* ## Architecture
|
|
683
|
+
*
|
|
684
|
+
* Adds metadata to packets:
|
|
685
|
+
* - `timestamp`: Current server time for client-side prediction
|
|
686
|
+
* - `ack`: Acknowledgment info with last processed frame and authoritative position
|
|
687
|
+
*
|
|
688
|
+
* @param player - The player receiving the packet
|
|
689
|
+
* @param packet - The packet data to intercept
|
|
690
|
+
* @param conn - The connection object
|
|
691
|
+
* @returns Modified packet with timestamp and ack info, or null if player is invalid
|
|
692
|
+
*
|
|
693
|
+
* @example
|
|
694
|
+
* ```ts
|
|
695
|
+
* // This method is called automatically by the framework
|
|
696
|
+
* // You typically don't call it directly
|
|
697
|
+
* ```
|
|
698
|
+
*/
|
|
699
|
+
interceptorPacket(player: RpgPlayer, packet: any, conn: MockConnection) {
|
|
700
|
+
let obj: any = {}
|
|
701
|
+
let packetValue = packet?.value;
|
|
702
|
+
|
|
703
|
+
if (!player) {
|
|
704
|
+
return null
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// Add timestamp to sync packets for client-side prediction reconciliation
|
|
708
|
+
if (packet && typeof packet === 'object') {
|
|
709
|
+
obj.timestamp = Date.now();
|
|
710
|
+
|
|
711
|
+
// Add ack info: last processed frame and authoritative position.
|
|
712
|
+
// When the sync payload already contains this player's coordinates,
|
|
713
|
+
// prefer them to keep ack state aligned with the snapshot sent to the client.
|
|
714
|
+
if (player) {
|
|
715
|
+
const value = packet.value && typeof packet.value === "object" ? packet.value : undefined;
|
|
716
|
+
const packetPlayers = value?.players && typeof value.players === "object" ? value.players : undefined;
|
|
717
|
+
const playerSnapshot = packetPlayers?.[player.id];
|
|
718
|
+
const bodyPos = this.getBodyPosition(player.id, "top-left");
|
|
719
|
+
const ackX =
|
|
720
|
+
typeof playerSnapshot?.x === "number" ? playerSnapshot.x : bodyPos?.x ?? player.x();
|
|
721
|
+
const ackY =
|
|
722
|
+
typeof playerSnapshot?.y === "number" ? playerSnapshot.y : bodyPos?.y ?? player.y();
|
|
723
|
+
const lastFramePositions = player._lastFramePositions;
|
|
724
|
+
obj.ack = {
|
|
725
|
+
frame: lastFramePositions?.frame ?? 0,
|
|
726
|
+
serverTick: this.getTick(),
|
|
727
|
+
x: Math.round(ackX),
|
|
728
|
+
y: Math.round(ackY),
|
|
729
|
+
direction: playerSnapshot?.direction ?? player.direction(),
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
if (packetValue && typeof packetValue === "object" && packetValue.events && typeof packetValue.events === "object") {
|
|
735
|
+
const eventEntries = Object.entries(packetValue.events);
|
|
736
|
+
const filteredEntries = eventEntries.filter(([eventId]) => this.isEventVisibleForPlayer(eventId, player));
|
|
737
|
+
if (filteredEntries.length !== eventEntries.length) {
|
|
738
|
+
packetValue = { ...packetValue };
|
|
739
|
+
if (filteredEntries.length === 0) {
|
|
740
|
+
delete (packetValue as any).events;
|
|
741
|
+
}
|
|
742
|
+
else {
|
|
743
|
+
(packetValue as any).events = Object.fromEntries(filteredEntries);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
if (typeof packet.value == 'string') {
|
|
749
|
+
return packet
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
return {
|
|
753
|
+
...packet,
|
|
754
|
+
value: {
|
|
755
|
+
...packetValue,
|
|
756
|
+
...obj
|
|
757
|
+
}
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* Called when a player joins the map
|
|
763
|
+
*
|
|
764
|
+
* This method is automatically called by @signe/room when a player connects to the map.
|
|
765
|
+
* It initializes the player's connection, sets up the map context, and waits for
|
|
766
|
+
* the map data to be ready before playing sounds and triggering hooks.
|
|
767
|
+
*
|
|
768
|
+
* ## Architecture
|
|
769
|
+
*
|
|
770
|
+
* 1. Sets player's map reference and context
|
|
771
|
+
* 2. Initializes the player
|
|
772
|
+
* 3. Waits for map data to be ready
|
|
773
|
+
* 4. Plays map sounds for the player
|
|
774
|
+
* 5. Triggers `server-player-onJoinMap` hook
|
|
775
|
+
*
|
|
776
|
+
* @param player - The player joining the map
|
|
777
|
+
* @param conn - The connection object for the player
|
|
778
|
+
*
|
|
779
|
+
* @example
|
|
780
|
+
* ```ts
|
|
781
|
+
* // This method is called automatically by the framework
|
|
782
|
+
* // You can listen to the hook to perform custom logic
|
|
783
|
+
* server.addHook('server-player-onJoinMap', (player, map) => {
|
|
784
|
+
* console.log(`Player ${player.id} joined map ${map.id}`);
|
|
785
|
+
* });
|
|
786
|
+
* ```
|
|
787
|
+
*/
|
|
788
|
+
onJoin(player: RpgPlayer, conn: MockConnection) {
|
|
789
|
+
if (player.setMap) {
|
|
790
|
+
player.setMap(this);
|
|
791
|
+
} else {
|
|
792
|
+
player.map = this;
|
|
793
|
+
}
|
|
794
|
+
player.context = context;
|
|
795
|
+
player.conn = conn;
|
|
796
|
+
player.pendingInputs = [];
|
|
797
|
+
player.lastProcessedInputTs = 0;
|
|
798
|
+
player._lastFramePositions = null;
|
|
799
|
+
player._onInit()
|
|
800
|
+
this.dataIsReady$.pipe(
|
|
801
|
+
finalize(() => {
|
|
802
|
+
// Avoid unhandled promise rejections from async hook execution.
|
|
803
|
+
void (async () => {
|
|
804
|
+
try {
|
|
805
|
+
const hitbox = typeof player.hitbox === 'function' ? player.hitbox() : player.hitbox;
|
|
806
|
+
const width = hitbox?.w ?? 32;
|
|
807
|
+
const height = hitbox?.h ?? 32;
|
|
808
|
+
const body = this.getBody(player.id) as any;
|
|
809
|
+
if (body) {
|
|
810
|
+
// Ensure physics callbacks target the current player instance
|
|
811
|
+
// after session transfer/map return.
|
|
812
|
+
body.owner = player;
|
|
813
|
+
}
|
|
814
|
+
// Keep physics body aligned with restored snapshot coordinates on map join.
|
|
815
|
+
this.updateHitbox(player.id, player.x(), player.y(), width, height);
|
|
816
|
+
await this.spawnScenarioEventsForPlayer(player);
|
|
817
|
+
|
|
818
|
+
// Check if we should stop all sounds before playing new ones
|
|
819
|
+
if ((this as any).stopAllSoundsBeforeJoin) {
|
|
820
|
+
player.stopAllSounds();
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
this.sounds.forEach(sound => player.playSound(sound, { loop: true }));
|
|
824
|
+
player.emit("weatherState", this.getWeather());
|
|
825
|
+
|
|
826
|
+
// Execute global map hooks (from RpgServer.map)
|
|
827
|
+
await lastValueFrom(this.hooks.callHooks("server-map-onJoin", player, this));
|
|
828
|
+
|
|
829
|
+
// // Execute map-specific hooks (from @MapData or MapOptions)
|
|
830
|
+
if (typeof (this as any)._onJoin === 'function') {
|
|
831
|
+
await (this as any)._onJoin(player);
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// Execute player hooks
|
|
835
|
+
await lastValueFrom(this.hooks.callHooks("server-player-onJoinMap", player, this));
|
|
836
|
+
}
|
|
837
|
+
catch (error) {
|
|
838
|
+
if (isRpgLog(error)) {
|
|
839
|
+
console.warn(`[RpgLog:${error.id}] ${error.message}`);
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
console.error("[RPGJS] Error during map onJoin hooks:", error);
|
|
843
|
+
}
|
|
844
|
+
})();
|
|
845
|
+
})
|
|
846
|
+
).subscribe();
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* Called when a player leaves the map
|
|
851
|
+
*
|
|
852
|
+
* This method is automatically called by @signe/room when a player disconnects from the map.
|
|
853
|
+
* It cleans up the player's pending inputs and triggers the appropriate hooks.
|
|
854
|
+
*
|
|
855
|
+
* ## Architecture
|
|
856
|
+
*
|
|
857
|
+
* 1. Triggers `server-player-onLeaveMap` hook
|
|
858
|
+
* 2. Clears pending inputs to prevent processing after disconnection
|
|
859
|
+
*
|
|
860
|
+
* @param player - The player leaving the map
|
|
861
|
+
* @param conn - The connection object for the player
|
|
862
|
+
*
|
|
863
|
+
* @example
|
|
864
|
+
* ```ts
|
|
865
|
+
* // This method is called automatically by the framework
|
|
866
|
+
* // You can listen to the hook to perform custom cleanup
|
|
867
|
+
* server.addHook('server-player-onLeaveMap', (player, map) => {
|
|
868
|
+
* console.log(`Player ${player.id} left map ${map.id}`);
|
|
869
|
+
* });
|
|
870
|
+
* ```
|
|
871
|
+
*/
|
|
872
|
+
async onLeave(player: RpgPlayer, conn: MockConnection) {
|
|
873
|
+
// Execute global map hooks (from RpgServer.map)
|
|
874
|
+
await lastValueFrom(this.hooks.callHooks("server-map-onLeave", player, this));
|
|
875
|
+
|
|
876
|
+
// Execute map-specific hooks (from @MapData or MapOptions)
|
|
877
|
+
if (typeof (this as any)._onLeave === 'function') {
|
|
878
|
+
await (this as any)._onLeave(player);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// Execute player hooks
|
|
882
|
+
await lastValueFrom(this.hooks.callHooks("server-player-onLeaveMap", player, this));
|
|
883
|
+
this.removeScenarioEventsForPlayer(player.id);
|
|
884
|
+
player.pendingInputs = [];
|
|
885
|
+
player.lastProcessedInputTs = 0;
|
|
886
|
+
player._lastFramePositions = null;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* Get the hooks system for this map
|
|
891
|
+
*
|
|
892
|
+
* Returns the dependency-injected Hooks instance that allows you to trigger
|
|
893
|
+
* and listen to various game events.
|
|
894
|
+
*
|
|
895
|
+
* @returns The Hooks instance for this map
|
|
896
|
+
*
|
|
897
|
+
* @example
|
|
898
|
+
* ```ts
|
|
899
|
+
* // Trigger a custom hook
|
|
900
|
+
* map.hooks.callHooks('custom-event', data).subscribe();
|
|
901
|
+
* ```
|
|
902
|
+
*/
|
|
903
|
+
get hooks() {
|
|
904
|
+
return BaseRoom.prototype.hooks;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
async onSessionRestore(payload: { userSnapshot: any; user?: RpgPlayer }) {
|
|
908
|
+
return await BaseRoom.prototype.onSessionRestore.call(this, payload);
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* Handle GUI interaction from a player
|
|
913
|
+
*
|
|
914
|
+
* This method is called when a player interacts with a GUI element.
|
|
915
|
+
* It synchronizes the player's changes to ensure the client state is up to date.
|
|
916
|
+
*
|
|
917
|
+
* @param player - The player performing the interaction
|
|
918
|
+
* @param value - The interaction data from the client
|
|
919
|
+
*
|
|
920
|
+
* @example
|
|
921
|
+
* ```ts
|
|
922
|
+
* // This method is called automatically when a player interacts with a GUI
|
|
923
|
+
* // The interaction data is sent from the client
|
|
924
|
+
* ```
|
|
925
|
+
*/
|
|
926
|
+
@Action('gui.interaction')
|
|
927
|
+
async guiInteraction(player: RpgPlayer, value: { guiId: string, name: string, data: any }) {
|
|
928
|
+
const gui = player.getGui(value.guiId)
|
|
929
|
+
if (gui) {
|
|
930
|
+
await gui.emit(value.name, value.data)
|
|
931
|
+
}
|
|
932
|
+
player.syncChanges();
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* Handle GUI exit from a player
|
|
937
|
+
*
|
|
938
|
+
* This method is called when a player closes or exits a GUI.
|
|
939
|
+
* It removes the GUI from the player's active GUIs.
|
|
940
|
+
*
|
|
941
|
+
* @param player - The player exiting the GUI
|
|
942
|
+
* @param guiId - The ID of the GUI being exited
|
|
943
|
+
* @param data - Optional data associated with the GUI exit
|
|
944
|
+
*
|
|
945
|
+
* @example
|
|
946
|
+
* ```ts
|
|
947
|
+
* // This method is called automatically when a player closes a GUI
|
|
948
|
+
* // The GUI is removed from the player's active GUIs
|
|
949
|
+
* ```
|
|
950
|
+
*/
|
|
951
|
+
@Action('gui.exit')
|
|
952
|
+
guiExit(player: RpgPlayer, { guiId, data }) {
|
|
953
|
+
player.removeGui(guiId, data)
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/**
|
|
957
|
+
* Handle action input from a player
|
|
958
|
+
*
|
|
959
|
+
* This method is called when a player performs an action (like pressing a button).
|
|
960
|
+
* It checks for collisions with events and triggers the appropriate hooks.
|
|
961
|
+
*
|
|
962
|
+
* ## Architecture
|
|
963
|
+
*
|
|
964
|
+
* 1. Gets all entities colliding with the player
|
|
965
|
+
* 2. Triggers `onAction` hook on colliding events
|
|
966
|
+
* 3. Triggers `onInput` hook on the player
|
|
967
|
+
*
|
|
968
|
+
* @param player - The player performing the action
|
|
969
|
+
* @param action - The action data (button pressed, etc.)
|
|
970
|
+
*
|
|
971
|
+
* @example
|
|
972
|
+
* ```ts
|
|
973
|
+
* // This method is called automatically when a player presses an action button
|
|
974
|
+
* // Events near the player will have their onAction hook triggered
|
|
975
|
+
* ```
|
|
976
|
+
*/
|
|
977
|
+
@Action('action')
|
|
978
|
+
onAction(player: RpgPlayer, action: any) {
|
|
979
|
+
// Get collisions using the helper method from RpgCommonMap
|
|
980
|
+
const collisions = (this as any).getCollisions(player.id);
|
|
981
|
+
const events = collisions
|
|
982
|
+
.map(id => this.getEvent(id))
|
|
983
|
+
.filter((event): event is RpgEvent => !!event && this.isEventVisibleForPlayer(event, player));
|
|
984
|
+
if (events.length > 0) {
|
|
985
|
+
events.forEach(event => {
|
|
986
|
+
event.execMethod('onAction', [player, action]);
|
|
987
|
+
});
|
|
988
|
+
}
|
|
989
|
+
player.execMethod('onInput', [action]);
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
/**
|
|
993
|
+
* Handle movement input from a player
|
|
994
|
+
*
|
|
995
|
+
* This method is called when a player sends movement input from the client.
|
|
996
|
+
* It queues the input for processing by the game loop. Inputs are processed
|
|
997
|
+
* with frame numbers to ensure proper ordering and client-side prediction.
|
|
998
|
+
*
|
|
999
|
+
* ## Architecture
|
|
1000
|
+
*
|
|
1001
|
+
* - Inputs are queued in `player.pendingInputs`
|
|
1002
|
+
* - Duplicate frames are skipped to prevent processing the same input twice
|
|
1003
|
+
* - Inputs are processed asynchronously by the game loop
|
|
1004
|
+
*
|
|
1005
|
+
* @param player - The player sending the movement input
|
|
1006
|
+
* @param input - The input data containing frame number, input direction, and timestamp
|
|
1007
|
+
*
|
|
1008
|
+
* @example
|
|
1009
|
+
* ```ts
|
|
1010
|
+
* // This method is called automatically when a player moves
|
|
1011
|
+
* // The input is queued and processed by processInput()
|
|
1012
|
+
* ```
|
|
1013
|
+
*/
|
|
1014
|
+
@Action('move')
|
|
1015
|
+
async onInput(player: RpgPlayer, input: any) {
|
|
1016
|
+
const lastAckedFrame = player._lastFramePositions?.frame ?? 0;
|
|
1017
|
+
const now = Date.now();
|
|
1018
|
+
const candidates: Array<{
|
|
1019
|
+
input: any;
|
|
1020
|
+
frame: number;
|
|
1021
|
+
tick?: number;
|
|
1022
|
+
timestamp: number;
|
|
1023
|
+
clientState?: { x: number; y: number; direction?: Direction };
|
|
1024
|
+
}> = [];
|
|
1025
|
+
|
|
1026
|
+
const enqueueCandidate = (entry: any) => {
|
|
1027
|
+
if (typeof entry?.frame !== "number") {
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
if (!entry?.input) {
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
const candidate: {
|
|
1034
|
+
input: any;
|
|
1035
|
+
frame: number;
|
|
1036
|
+
tick?: number;
|
|
1037
|
+
timestamp: number;
|
|
1038
|
+
clientState?: { x: number; y: number; direction?: Direction };
|
|
1039
|
+
} = {
|
|
1040
|
+
input: entry.input,
|
|
1041
|
+
frame: entry.frame,
|
|
1042
|
+
tick: typeof entry.tick === "number" ? entry.tick : undefined,
|
|
1043
|
+
timestamp: typeof entry.timestamp === "number" ? entry.timestamp : now,
|
|
1044
|
+
};
|
|
1045
|
+
if (typeof entry.x === "number" && typeof entry.y === "number") {
|
|
1046
|
+
candidate.clientState = {
|
|
1047
|
+
x: entry.x,
|
|
1048
|
+
y: entry.y,
|
|
1049
|
+
direction: entry.direction,
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
1052
|
+
candidates.push(candidate);
|
|
1053
|
+
};
|
|
1054
|
+
|
|
1055
|
+
for (const trajectoryEntry of Array.isArray(input?.trajectory) ? input.trajectory : []) {
|
|
1056
|
+
enqueueCandidate(trajectoryEntry);
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
enqueueCandidate(input);
|
|
1060
|
+
|
|
1061
|
+
if (candidates.length === 0) {
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
candidates.sort((a, b) => a.frame - b.frame);
|
|
1066
|
+
const existingFrames = new Set<number>(
|
|
1067
|
+
player.pendingInputs
|
|
1068
|
+
.map((pending: any) => pending?.frame)
|
|
1069
|
+
.filter((frame: any): frame is number => typeof frame === "number"),
|
|
1070
|
+
);
|
|
1071
|
+
|
|
1072
|
+
for (const candidate of candidates) {
|
|
1073
|
+
if (candidate.frame <= lastAckedFrame) {
|
|
1074
|
+
continue;
|
|
1075
|
+
}
|
|
1076
|
+
if (existingFrames.has(candidate.frame)) {
|
|
1077
|
+
continue;
|
|
1078
|
+
}
|
|
1079
|
+
player.pendingInputs.push(candidate);
|
|
1080
|
+
existingFrames.add(candidate.frame);
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
@Action("ping")
|
|
1085
|
+
onPing(player: RpgPlayer, payload: { clientTime?: number; clientFrame?: number }) {
|
|
1086
|
+
player.emit("pong", {
|
|
1087
|
+
serverTick: this.getTick(),
|
|
1088
|
+
clientTime: typeof payload?.clientTime === "number" ? payload.clientTime : Date.now(),
|
|
1089
|
+
clientFrame: typeof payload?.clientFrame === "number" ? payload.clientFrame : 0,
|
|
1090
|
+
});
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
@Action('save.save')
|
|
1094
|
+
async saveSlot(player: RpgPlayer, value: { requestId: string; index: number; meta?: any }) {
|
|
1095
|
+
BaseRoom.prototype.saveSlot(player, value);
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
@Action('save.load')
|
|
1099
|
+
async loadSlot(player: RpgPlayer, value: { requestId: string; index: number }) {
|
|
1100
|
+
BaseRoom.prototype.loadSlot(player, value);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
@Action('save.list')
|
|
1104
|
+
async listSaveSlots(player: RpgPlayer, value: { requestId: string }) {
|
|
1105
|
+
return await BaseRoom.prototype.listSaveSlots(player, value);
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/**
|
|
1109
|
+
* Update the map configuration and data
|
|
1110
|
+
*
|
|
1111
|
+
* This endpoint receives map data from the client and initializes the map.
|
|
1112
|
+
* It loads the map configuration, damage formulas, events, and physics.
|
|
1113
|
+
*
|
|
1114
|
+
* ## Architecture
|
|
1115
|
+
*
|
|
1116
|
+
* 1. Validates the request body using MapUpdateSchema
|
|
1117
|
+
* 2. Updates map data, global config, and damage formulas
|
|
1118
|
+
* 3. Merges events and sounds from map configuration
|
|
1119
|
+
* 4. Triggers hooks for map loading
|
|
1120
|
+
* 5. Loads physics engine
|
|
1121
|
+
* 6. Creates all events on the map
|
|
1122
|
+
* 7. Completes the dataIsReady$ subject
|
|
1123
|
+
*
|
|
1124
|
+
* @param request - HTTP request containing map data
|
|
1125
|
+
* @returns Promise that resolves when the map is fully loaded
|
|
1126
|
+
*
|
|
1127
|
+
* @example
|
|
1128
|
+
* ```ts
|
|
1129
|
+
* // This endpoint is called automatically when a map is loaded
|
|
1130
|
+
* // POST /map/update
|
|
1131
|
+
* // Body: { id: string, width: number, height: number, config?: any, damageFormulas?: any }
|
|
1132
|
+
* ```
|
|
1133
|
+
*/
|
|
1134
|
+
@Request({
|
|
1135
|
+
path: "/map/update",
|
|
1136
|
+
method: "POST"
|
|
1137
|
+
}, MapUpdateSchema as any)
|
|
1138
|
+
async updateMap(request: Request) {
|
|
1139
|
+
const map = await request.json()
|
|
1140
|
+
this.data.set(map)
|
|
1141
|
+
this.globalConfig = map.config
|
|
1142
|
+
this.damageFormulas = map.damageFormulas || {};
|
|
1143
|
+
this.damageFormulas = {
|
|
1144
|
+
damageSkill: DAMAGE_SKILL,
|
|
1145
|
+
damagePhysic: DAMAGE_PHYSIC,
|
|
1146
|
+
damageCritical: DAMAGE_CRITICAL,
|
|
1147
|
+
coefficientElements: COEFFICIENT_ELEMENTS,
|
|
1148
|
+
...this.damageFormulas
|
|
1149
|
+
}
|
|
1150
|
+
await lastValueFrom(this.hooks.callHooks("server-maps-load", this))
|
|
1151
|
+
await lastValueFrom(this.hooks.callHooks("server-worldMaps-load", this))
|
|
1152
|
+
await lastValueFrom(this.hooks.callHooks("server-databaseHooks-load", this))
|
|
1153
|
+
|
|
1154
|
+
this.resolveTrustedMapDimensions(map)
|
|
1155
|
+
this.data.set(map)
|
|
1156
|
+
|
|
1157
|
+
map.events = map.events ?? []
|
|
1158
|
+
let initialWeather: WeatherState | null | undefined = this.globalConfig?.weather;
|
|
1159
|
+
|
|
1160
|
+
if (map.id) {
|
|
1161
|
+
const mapFound = this.maps.find(m => m.id === map.id)
|
|
1162
|
+
if (typeof mapFound?.weather !== "undefined") {
|
|
1163
|
+
initialWeather = mapFound.weather;
|
|
1164
|
+
}
|
|
1165
|
+
if (mapFound?.events) {
|
|
1166
|
+
map.events = [
|
|
1167
|
+
...mapFound.events,
|
|
1168
|
+
...map.events
|
|
1169
|
+
]
|
|
1170
|
+
}
|
|
1171
|
+
if (mapFound?.sounds) {
|
|
1172
|
+
this.sounds = [
|
|
1173
|
+
...(map.sounds ?? []),
|
|
1174
|
+
...mapFound.sounds
|
|
1175
|
+
]
|
|
1176
|
+
}
|
|
1177
|
+
else {
|
|
1178
|
+
this.sounds = map.sounds ?? []
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
// Attach map-specific hooks from MapOptions or @MapData
|
|
1182
|
+
if (mapFound?.onLoad) {
|
|
1183
|
+
(this as any)._onLoad = mapFound.onLoad;
|
|
1184
|
+
}
|
|
1185
|
+
if (mapFound?.onJoin) {
|
|
1186
|
+
(this as any)._onJoin = mapFound.onJoin;
|
|
1187
|
+
}
|
|
1188
|
+
if (mapFound?.onLeave) {
|
|
1189
|
+
(this as any)._onLeave = mapFound.onLeave;
|
|
1190
|
+
}
|
|
1191
|
+
if (mapFound?.stopAllSoundsBeforeJoin !== undefined) {
|
|
1192
|
+
(this as any).stopAllSoundsBeforeJoin = mapFound.stopAllSoundsBeforeJoin;
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
if (typeof initialWeather !== "undefined") {
|
|
1197
|
+
this.setWeather(initialWeather);
|
|
1198
|
+
} else {
|
|
1199
|
+
this.clearWeather();
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
await lastValueFrom(this.hooks.callHooks("server-map-onBeforeUpdate", map, this))
|
|
1203
|
+
|
|
1204
|
+
this._scenarioEventTemplates = [];
|
|
1205
|
+
this._eventModeById.clear();
|
|
1206
|
+
this._eventOwnerById.clear();
|
|
1207
|
+
this._scenarioEventIdsByPlayer.clear();
|
|
1208
|
+
|
|
1209
|
+
this.loadPhysic()
|
|
1210
|
+
|
|
1211
|
+
for (let event of map.events ?? []) {
|
|
1212
|
+
const normalizedEvent = this.normalizeEventObject(event);
|
|
1213
|
+
const mode = this.resolveEventMode(normalizedEvent);
|
|
1214
|
+
if (mode === EventMode.Scenario) {
|
|
1215
|
+
this._scenarioEventTemplates.push(this.cloneEventTemplate(normalizedEvent));
|
|
1216
|
+
continue;
|
|
1217
|
+
}
|
|
1218
|
+
await this.createDynamicEvent(normalizedEvent, { mode: EventMode.Shared });
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
for (const player of this.getPlayers()) {
|
|
1222
|
+
await this.spawnScenarioEventsForPlayer(player);
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
this.dataIsReady$.complete()
|
|
1226
|
+
|
|
1227
|
+
// Execute global map hooks (from RpgServer.map)
|
|
1228
|
+
await lastValueFrom(this.hooks.callHooks("server-map-onLoad", this))
|
|
1229
|
+
|
|
1230
|
+
// Execute map-specific hooks (from @MapData or MapOptions)
|
|
1231
|
+
if (typeof (this as any)._onLoad === 'function') {
|
|
1232
|
+
await (this as any)._onLoad();
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
// TODO: Update map
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
/**
|
|
1239
|
+
* Update (or create) a world configuration and propagate to all maps in that world
|
|
1240
|
+
*
|
|
1241
|
+
* This endpoint receives world map configuration data (typically from Tiled world import)
|
|
1242
|
+
* and creates or updates the world manager. The world ID is extracted from the URL path.
|
|
1243
|
+
*
|
|
1244
|
+
* ## Architecture
|
|
1245
|
+
*
|
|
1246
|
+
* 1. Extracts world ID from URL path parameter
|
|
1247
|
+
* 2. Normalizes input to array of WorldMapConfig
|
|
1248
|
+
* 3. Ensures all required map properties are present (width, height, tile sizes)
|
|
1249
|
+
* 4. Creates or updates the world manager
|
|
1250
|
+
*
|
|
1251
|
+
* Expected payload examples:
|
|
1252
|
+
* - `{ id: string, maps: WorldMapConfig[] }`
|
|
1253
|
+
* - `WorldMapConfig[]`
|
|
1254
|
+
*
|
|
1255
|
+
* @param request - HTTP request containing world configuration
|
|
1256
|
+
* @returns Promise resolving to `{ ok: true }` when complete
|
|
1257
|
+
*
|
|
1258
|
+
* @example
|
|
1259
|
+
* ```ts
|
|
1260
|
+
* // POST /world/my-world/update
|
|
1261
|
+
* // Body: [{ id: 'map1', worldX: 0, worldY: 0, width: 800, height: 600 }]
|
|
1262
|
+
*
|
|
1263
|
+
* // Or with nested structure
|
|
1264
|
+
* // Body: { id: 'my-world', maps: [{ id: 'map1', ... }] }
|
|
1265
|
+
* ```
|
|
1266
|
+
*/
|
|
1267
|
+
@Request({
|
|
1268
|
+
path: "/world/:id/update",
|
|
1269
|
+
method: "POST",
|
|
1270
|
+
})
|
|
1271
|
+
async updateWorld(request: Request) {
|
|
1272
|
+
// Extract world id from URL: /world/:id/update
|
|
1273
|
+
let worldId = '';
|
|
1274
|
+
try {
|
|
1275
|
+
const reqUrl = (request as any).url as string;
|
|
1276
|
+
const urlObj = new URL(reqUrl, 'http://localhost');
|
|
1277
|
+
const parts = urlObj.pathname.split('/');
|
|
1278
|
+
// ['', 'world', ':id', 'update'] → index 2
|
|
1279
|
+
worldId = parts[2] ?? '';
|
|
1280
|
+
} catch { }
|
|
1281
|
+
const payload = await request.json();
|
|
1282
|
+
|
|
1283
|
+
// Normalize input to array of WorldMapConfig
|
|
1284
|
+
const mapsConfig: WorldMapConfig[] = Array.isArray(payload)
|
|
1285
|
+
? payload
|
|
1286
|
+
: payload?.maps ?? [];
|
|
1287
|
+
|
|
1288
|
+
// Ensure map sizes are present; fallback to current map data when ID matches
|
|
1289
|
+
const normalized: WorldMapConfig[] = mapsConfig.map((m: any) => {
|
|
1290
|
+
return {
|
|
1291
|
+
id: m.id,
|
|
1292
|
+
worldX: m.worldX ?? m.x ?? 0,
|
|
1293
|
+
worldY: m.worldY ?? m.y ?? 0,
|
|
1294
|
+
width: m.width ?? m.widthPx ?? this.data()?.width ?? 0,
|
|
1295
|
+
height: m.height ?? m.heightPx ?? this.data()?.height ?? 0,
|
|
1296
|
+
tileWidth: m.tileWidth ?? this.tileWidth ?? 32,
|
|
1297
|
+
tileHeight: m.tileHeight ?? this.tileHeight ?? 32,
|
|
1298
|
+
} as WorldMapConfig;
|
|
1299
|
+
});
|
|
1300
|
+
|
|
1301
|
+
await this.updateWorldMaps(worldId, normalized);
|
|
1302
|
+
return { ok: true } as any;
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
/**
|
|
1306
|
+
* Process pending inputs for a player with anti-cheat validation
|
|
1307
|
+
*
|
|
1308
|
+
* This method processes pending inputs for a player while performing
|
|
1309
|
+
* anti-cheat validation to prevent time manipulation and frame skipping.
|
|
1310
|
+
* It validates the time deltas between inputs and ensures they are within
|
|
1311
|
+
* acceptable ranges. To preserve movement itinerary under network bursts,
|
|
1312
|
+
* the number of inputs processed per call is capped.
|
|
1313
|
+
*
|
|
1314
|
+
* ## Architecture
|
|
1315
|
+
*
|
|
1316
|
+
* **Important**: This method only updates entity velocities - it does NOT step
|
|
1317
|
+
* the physics engine. Physics simulation is handled centrally by the game loop
|
|
1318
|
+
* (`tick$` -> `runFixedTicks`). This ensures:
|
|
1319
|
+
* - Consistent physics timing (60fps fixed timestep)
|
|
1320
|
+
* - No double-stepping when multiple inputs are processed
|
|
1321
|
+
* - Deterministic physics regardless of input frequency
|
|
1322
|
+
*
|
|
1323
|
+
* @param playerId - The ID of the player to process inputs for
|
|
1324
|
+
* @param controls - Optional anti-cheat configuration
|
|
1325
|
+
* @returns Promise containing the player and processed input strings
|
|
1326
|
+
*
|
|
1327
|
+
* @example
|
|
1328
|
+
* ```ts
|
|
1329
|
+
* // Process inputs with default anti-cheat settings
|
|
1330
|
+
* const result = await map.processInput('player1');
|
|
1331
|
+
* console.log('Processed inputs:', result.inputs);
|
|
1332
|
+
*
|
|
1333
|
+
* // Process inputs with custom anti-cheat configuration
|
|
1334
|
+
* const result = await map.processInput('player1', {
|
|
1335
|
+
* maxTimeDelta: 100,
|
|
1336
|
+
* maxFrameDelta: 5,
|
|
1337
|
+
* minTimeBetweenInputs: 16,
|
|
1338
|
+
* enableAntiCheat: true
|
|
1339
|
+
* });
|
|
1340
|
+
* ```
|
|
1341
|
+
*/
|
|
1342
|
+
async processInput(playerId: string, controls?: Controls): Promise<{
|
|
1343
|
+
player: RpgPlayer,
|
|
1344
|
+
inputs: string[]
|
|
1345
|
+
}> {
|
|
1346
|
+
const player = this.getPlayer(playerId);
|
|
1347
|
+
if (!player) {
|
|
1348
|
+
throw new Error(`Player ${playerId} not found`);
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
if (!player.isConnected()) {
|
|
1352
|
+
player.pendingInputs = [];
|
|
1353
|
+
return {
|
|
1354
|
+
player,
|
|
1355
|
+
inputs: []
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
const processedInputs: string[] = [];
|
|
1360
|
+
const defaultControls: Required<Controls> = {
|
|
1361
|
+
maxTimeDelta: 1000, // 1 second max between inputs
|
|
1362
|
+
maxFrameDelta: 10, // Max 10 frames skipped
|
|
1363
|
+
minTimeBetweenInputs: 16, // ~60fps minimum
|
|
1364
|
+
enableAntiCheat: false,
|
|
1365
|
+
maxInputsPerTick: 1,
|
|
1366
|
+
};
|
|
1367
|
+
|
|
1368
|
+
const config = { ...defaultControls, ...controls };
|
|
1369
|
+
let lastProcessedTime = player.lastProcessedInputTs || 0;
|
|
1370
|
+
let lastProcessedFrame = player._lastFramePositions?.frame ?? 0;
|
|
1371
|
+
|
|
1372
|
+
// Sort inputs by frame number to ensure proper order
|
|
1373
|
+
player.pendingInputs.sort((a, b) => (a.frame || 0) - (b.frame || 0));
|
|
1374
|
+
|
|
1375
|
+
let hasProcessedInputs = false;
|
|
1376
|
+
let processedThisTick = 0;
|
|
1377
|
+
|
|
1378
|
+
// Process pending inputs progressively to preserve itinerary under latency.
|
|
1379
|
+
while (player.pendingInputs.length > 0 && processedThisTick < config.maxInputsPerTick) {
|
|
1380
|
+
const input = player.pendingInputs.shift();
|
|
1381
|
+
|
|
1382
|
+
if (!input || typeof input.frame !== 'number') {
|
|
1383
|
+
continue;
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
// Anti-cheat validation
|
|
1387
|
+
if (config.enableAntiCheat) {
|
|
1388
|
+
// Check frame delta
|
|
1389
|
+
if (input.frame > lastProcessedFrame + config.maxFrameDelta) {
|
|
1390
|
+
// Reset to last valid frame
|
|
1391
|
+
input.frame = lastProcessedFrame + 1;
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
// Check time delta if timestamp is available
|
|
1395
|
+
if (input.timestamp && lastProcessedTime > 0) {
|
|
1396
|
+
const timeDelta = input.timestamp - lastProcessedTime;
|
|
1397
|
+
if (timeDelta > config.maxTimeDelta) {
|
|
1398
|
+
input.timestamp = lastProcessedTime + config.minTimeBetweenInputs;
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
// Check minimum time between inputs
|
|
1403
|
+
if (input.timestamp && lastProcessedTime > 0) {
|
|
1404
|
+
const timeDelta = input.timestamp - lastProcessedTime;
|
|
1405
|
+
if (timeDelta < config.minTimeBetweenInputs) {
|
|
1406
|
+
continue;
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
// Skip if frame is too old (more than 10 frames behind)
|
|
1412
|
+
if (input.frame < lastProcessedFrame - 10) {
|
|
1413
|
+
continue;
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
// Process the input - update velocity based on the latest input
|
|
1417
|
+
if (input.input) {
|
|
1418
|
+
await this.movePlayer(player, input.input);
|
|
1419
|
+
processedInputs.push(input.input);
|
|
1420
|
+
hasProcessedInputs = true;
|
|
1421
|
+
lastProcessedTime = input.timestamp || Date.now();
|
|
1422
|
+
processedThisTick += 1;
|
|
1423
|
+
|
|
1424
|
+
const bodyPos = this.getBodyPosition(player.id, "top-left");
|
|
1425
|
+
const ackX =
|
|
1426
|
+
typeof input.clientState?.x === "number"
|
|
1427
|
+
? input.clientState.x
|
|
1428
|
+
: bodyPos?.x ?? player.x();
|
|
1429
|
+
const ackY =
|
|
1430
|
+
typeof input.clientState?.y === "number"
|
|
1431
|
+
? input.clientState.y
|
|
1432
|
+
: bodyPos?.y ?? player.y();
|
|
1433
|
+
player._lastFramePositions = {
|
|
1434
|
+
frame: input.frame,
|
|
1435
|
+
position: {
|
|
1436
|
+
x: Math.round(ackX),
|
|
1437
|
+
y: Math.round(ackY),
|
|
1438
|
+
direction: input.clientState?.direction ?? player.direction(),
|
|
1439
|
+
},
|
|
1440
|
+
serverTick: this.getTick(),
|
|
1441
|
+
};
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
// Update tracking variables
|
|
1445
|
+
lastProcessedFrame = input.frame;
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
// Physics is now handled by the main game loop (tick$ -> runFixedTicks)
|
|
1449
|
+
// We only update timestamps and handle idle timeout here
|
|
1450
|
+
// The physics step will be executed in the next tick cycle
|
|
1451
|
+
if (hasProcessedInputs) {
|
|
1452
|
+
player.lastProcessedInputTs = lastProcessedTime;
|
|
1453
|
+
} else {
|
|
1454
|
+
const idleTimeout = Math.max(config.minTimeBetweenInputs * 4, 50);
|
|
1455
|
+
const lastTs = player.lastProcessedInputTs || 0;
|
|
1456
|
+
if (lastTs > 0 && Date.now() - lastTs > idleTimeout) {
|
|
1457
|
+
(this as any).stopMovement(player);
|
|
1458
|
+
player.lastProcessedInputTs = 0;
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
return {
|
|
1463
|
+
player,
|
|
1464
|
+
inputs: processedInputs
|
|
1465
|
+
};
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
/**
|
|
1469
|
+
* Main game loop that processes player inputs
|
|
1470
|
+
*
|
|
1471
|
+
* This private method subscribes to tick$ and processes pending inputs
|
|
1472
|
+
* for all players on the map with a throttle of 50ms. It ensures inputs are
|
|
1473
|
+
* processed in order and prevents concurrent processing for the same player.
|
|
1474
|
+
*
|
|
1475
|
+
* ## Architecture
|
|
1476
|
+
*
|
|
1477
|
+
* - Subscribes to tick$ with throttleTime(50ms) for responsive input processing
|
|
1478
|
+
* - Processes inputs for each player with pending inputs
|
|
1479
|
+
* - Uses a flag to prevent concurrent processing for the same player
|
|
1480
|
+
* - Calls `processInput()` to handle anti-cheat validation and movement
|
|
1481
|
+
*
|
|
1482
|
+
* @example
|
|
1483
|
+
* ```ts
|
|
1484
|
+
* // This method is called automatically in the constructor if autoTick is enabled
|
|
1485
|
+
* // You typically don't call it directly
|
|
1486
|
+
* ```
|
|
1487
|
+
*/
|
|
1488
|
+
private loop() {
|
|
1489
|
+
if (this._inputLoopSubscription) {
|
|
1490
|
+
this._inputLoopSubscription.unsubscribe();
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
this._inputLoopSubscription = this.tick$.subscribe(() => {
|
|
1494
|
+
for (const player of this.getPlayers()) {
|
|
1495
|
+
const anyPlayer = player as any;
|
|
1496
|
+
const shouldProcess = player.pendingInputs.length > 0 || (player.lastProcessedInputTs || 0) > 0;
|
|
1497
|
+
if (!shouldProcess || anyPlayer._isProcessingInputs) {
|
|
1498
|
+
continue;
|
|
1499
|
+
}
|
|
1500
|
+
anyPlayer._isProcessingInputs = true;
|
|
1501
|
+
void this.processInput(player.id).finally(() => {
|
|
1502
|
+
anyPlayer._isProcessingInputs = false;
|
|
1503
|
+
});
|
|
1504
|
+
}
|
|
1505
|
+
});
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
/**
|
|
1509
|
+
* Enable or disable automatic tick processing
|
|
1510
|
+
*
|
|
1511
|
+
* When disabled, the input processing loop will not run automatically.
|
|
1512
|
+
* This is useful for unit tests where you want manual control over when
|
|
1513
|
+
* inputs are processed.
|
|
1514
|
+
*
|
|
1515
|
+
* @param enabled - Whether to enable automatic tick processing (default: true)
|
|
1516
|
+
*
|
|
1517
|
+
* @example
|
|
1518
|
+
* ```ts
|
|
1519
|
+
* // Disable auto tick for testing
|
|
1520
|
+
* map.setAutoTick(false);
|
|
1521
|
+
*
|
|
1522
|
+
* // Manually trigger tick processing
|
|
1523
|
+
* await map.processInput('player1');
|
|
1524
|
+
* ```
|
|
1525
|
+
*/
|
|
1526
|
+
setAutoTick(enabled: boolean): void {
|
|
1527
|
+
this._autoTickEnabled = enabled;
|
|
1528
|
+
if (enabled && !this._inputLoopSubscription) {
|
|
1529
|
+
this.loop();
|
|
1530
|
+
} else if (!enabled && this._inputLoopSubscription) {
|
|
1531
|
+
this._inputLoopSubscription.unsubscribe();
|
|
1532
|
+
this._inputLoopSubscription = undefined;
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
/**
|
|
1537
|
+
* Get a world manager by id
|
|
1538
|
+
*
|
|
1539
|
+
* Returns the world maps manager for the given world ID. Currently, only
|
|
1540
|
+
* one world manager is supported per map instance.
|
|
1541
|
+
*
|
|
1542
|
+
* @param id - The world ID (currently unused, returns the single manager)
|
|
1543
|
+
* @returns The WorldMapsManager instance, or null if not initialized
|
|
1544
|
+
*
|
|
1545
|
+
* @example
|
|
1546
|
+
* ```ts
|
|
1547
|
+
* const worldManager = map.getWorldMaps('my-world');
|
|
1548
|
+
* if (worldManager) {
|
|
1549
|
+
* const mapInfo = worldManager.getMapInfo('map1');
|
|
1550
|
+
* }
|
|
1551
|
+
* ```
|
|
1552
|
+
*/
|
|
1553
|
+
getWorldMaps(id: string): WorldMapsManager | null {
|
|
1554
|
+
if (!this.worldMapsManager) return null;
|
|
1555
|
+
return this.worldMapsManager;
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
/**
|
|
1559
|
+
* Delete a world manager by id
|
|
1560
|
+
*
|
|
1561
|
+
* Removes the world maps manager from this map instance. Currently, only
|
|
1562
|
+
* one world manager is supported, so this clears the single manager.
|
|
1563
|
+
*
|
|
1564
|
+
* @param id - The world ID (currently unused)
|
|
1565
|
+
* @returns true if the manager was deleted, false if it didn't exist
|
|
1566
|
+
*
|
|
1567
|
+
* @example
|
|
1568
|
+
* ```ts
|
|
1569
|
+
* const deleted = map.deleteWorldMaps('my-world');
|
|
1570
|
+
* if (deleted) {
|
|
1571
|
+
* console.log('World manager removed');
|
|
1572
|
+
* }
|
|
1573
|
+
* ```
|
|
1574
|
+
*/
|
|
1575
|
+
deleteWorldMaps(id: string): boolean {
|
|
1576
|
+
if (!this.worldMapsManager) return false;
|
|
1577
|
+
// For now, clear the single manager
|
|
1578
|
+
this.worldMapsManager = undefined;
|
|
1579
|
+
return true;
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
/**
|
|
1583
|
+
* Create a world manager dynamically
|
|
1584
|
+
*
|
|
1585
|
+
* Creates a new WorldMapsManager instance and configures it with the provided
|
|
1586
|
+
* map configurations. This is used when loading world data from Tiled or
|
|
1587
|
+
* other map editors.
|
|
1588
|
+
*
|
|
1589
|
+
* @param world - World configuration object
|
|
1590
|
+
* @param world.id - Optional world identifier
|
|
1591
|
+
* @param world.maps - Array of map configurations for the world
|
|
1592
|
+
* @returns The newly created WorldMapsManager instance
|
|
1593
|
+
*
|
|
1594
|
+
* @example
|
|
1595
|
+
* ```ts
|
|
1596
|
+
* const manager = map.createDynamicWorldMaps({
|
|
1597
|
+
* id: 'my-world',
|
|
1598
|
+
* maps: [
|
|
1599
|
+
* { id: 'map1', worldX: 0, worldY: 0, width: 800, height: 600 },
|
|
1600
|
+
* { id: 'map2', worldX: 800, worldY: 0, width: 800, height: 600 }
|
|
1601
|
+
* ]
|
|
1602
|
+
* });
|
|
1603
|
+
* ```
|
|
1604
|
+
*/
|
|
1605
|
+
createDynamicWorldMaps(world: { id?: string; maps: WorldMapConfig[] }): WorldMapsManager {
|
|
1606
|
+
const manager = new WorldMapsManager();
|
|
1607
|
+
manager.configure(world.maps);
|
|
1608
|
+
this.worldMapsManager = manager;
|
|
1609
|
+
return manager;
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
/**
|
|
1613
|
+
* Update world maps by id. Auto-create when missing.
|
|
1614
|
+
*
|
|
1615
|
+
* Updates the world maps configuration. If the world manager doesn't exist,
|
|
1616
|
+
* it is automatically created. This is useful for dynamically loading world
|
|
1617
|
+
* data or updating map positions.
|
|
1618
|
+
*
|
|
1619
|
+
* @param id - The world ID
|
|
1620
|
+
* @param maps - Array of map configurations to update
|
|
1621
|
+
* @returns Promise that resolves when the update is complete
|
|
1622
|
+
*
|
|
1623
|
+
* @example
|
|
1624
|
+
* ```ts
|
|
1625
|
+
* await map.updateWorldMaps('my-world', [
|
|
1626
|
+
* { id: 'map1', worldX: 0, worldY: 0, width: 800, height: 600 },
|
|
1627
|
+
* { id: 'map2', worldX: 800, worldY: 0, width: 800, height: 600 }
|
|
1628
|
+
* ]);
|
|
1629
|
+
* ```
|
|
1630
|
+
*/
|
|
1631
|
+
async updateWorldMaps(id: string, maps: WorldMapConfig[]) {
|
|
1632
|
+
let world = this.getWorldMaps(id);
|
|
1633
|
+
if (!world) {
|
|
1634
|
+
world = this.createDynamicWorldMaps({ id, maps });
|
|
1635
|
+
} else {
|
|
1636
|
+
world.configure(maps);
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
/**
|
|
1641
|
+
* Add data to the map's database
|
|
1642
|
+
*
|
|
1643
|
+
* This method delegates to BaseRoom's implementation to avoid code duplication.
|
|
1644
|
+
*
|
|
1645
|
+
* @param id - Unique identifier for the data
|
|
1646
|
+
* @param data - The data to store (can be a class, object, or any value)
|
|
1647
|
+
* @param options - Optional configuration
|
|
1648
|
+
* @param options.force - If true, overwrites existing data even if ID already exists (default: false)
|
|
1649
|
+
* @returns true if data was added, false if ignored (ID already exists)
|
|
1650
|
+
*
|
|
1651
|
+
* @example
|
|
1652
|
+
* ```ts
|
|
1653
|
+
* // Add an item class to the database
|
|
1654
|
+
* map.addInDatabase('Potion', PotionClass);
|
|
1655
|
+
*
|
|
1656
|
+
* // Add an item object to the database
|
|
1657
|
+
* map.addInDatabase('custom-item', {
|
|
1658
|
+
* name: 'Custom Item',
|
|
1659
|
+
* price: 100
|
|
1660
|
+
* });
|
|
1661
|
+
*
|
|
1662
|
+
* // Force overwrite existing data
|
|
1663
|
+
* map.addInDatabase('Potion', UpdatedPotionClass, { force: true });
|
|
1664
|
+
* ```
|
|
1665
|
+
*/
|
|
1666
|
+
addInDatabase(id: string, data: any, options?: { force?: boolean }): boolean {
|
|
1667
|
+
return BaseRoom.prototype.addInDatabase.call(this, id, data, options);
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
/**
|
|
1671
|
+
* Remove data from the map's database
|
|
1672
|
+
*
|
|
1673
|
+
* This method delegates to BaseRoom's implementation to avoid code duplication.
|
|
1674
|
+
*
|
|
1675
|
+
* @param id - Unique identifier of the data to remove
|
|
1676
|
+
* @returns true if data was removed, false if ID didn't exist
|
|
1677
|
+
*
|
|
1678
|
+
* @example
|
|
1679
|
+
* ```ts
|
|
1680
|
+
* // Remove an item from the database
|
|
1681
|
+
* map.removeInDatabase('Potion');
|
|
1682
|
+
*
|
|
1683
|
+
* // Check if removal was successful
|
|
1684
|
+
* const removed = map.removeInDatabase('custom-item');
|
|
1685
|
+
* if (removed) {
|
|
1686
|
+
* console.log('Item removed successfully');
|
|
1687
|
+
* }
|
|
1688
|
+
* ```
|
|
1689
|
+
*/
|
|
1690
|
+
removeInDatabase(id: string): boolean {
|
|
1691
|
+
return BaseRoom.prototype.removeInDatabase.call(this, id);
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
/**
|
|
1695
|
+
* Creates a dynamic event on the map
|
|
1696
|
+
*
|
|
1697
|
+
* This method handles both class-based events and object-based events with hooks.
|
|
1698
|
+
* For class-based events, it creates a new instance of the class.
|
|
1699
|
+
* For object-based events, it creates a dynamic class that extends RpgPlayer and
|
|
1700
|
+
* implements the hook methods from the object.
|
|
1701
|
+
*
|
|
1702
|
+
* @param eventObj - The event position and definition
|
|
1703
|
+
*
|
|
1704
|
+
* @example
|
|
1705
|
+
* // Using a class-based event
|
|
1706
|
+
* class MyEvent extends RpgPlayer {
|
|
1707
|
+
* onInit() {
|
|
1708
|
+
* console.log('Event initialized');
|
|
1709
|
+
* }
|
|
1710
|
+
* }
|
|
1711
|
+
*
|
|
1712
|
+
* map.createDynamicEvent({
|
|
1713
|
+
* x: 100,
|
|
1714
|
+
* y: 200,
|
|
1715
|
+
* event: MyEvent
|
|
1716
|
+
* });
|
|
1717
|
+
*
|
|
1718
|
+
* // Using an object-based event
|
|
1719
|
+
* map.createDynamicEvent({
|
|
1720
|
+
* x: 100,
|
|
1721
|
+
* y: 200,
|
|
1722
|
+
* event: {
|
|
1723
|
+
* onInit() {
|
|
1724
|
+
* console.log('Event initialized');
|
|
1725
|
+
* },
|
|
1726
|
+
* onPlayerTouch(player) {
|
|
1727
|
+
* console.log('Player touched event');
|
|
1728
|
+
* }
|
|
1729
|
+
* }
|
|
1730
|
+
* });
|
|
1731
|
+
*/
|
|
1732
|
+
async createDynamicEvent(eventObj: EventPosOption, options: CreateDynamicEventOptions = {}): Promise<string | undefined> {
|
|
1733
|
+
eventObj = this.normalizeEventObject(eventObj);
|
|
1734
|
+
|
|
1735
|
+
const value = await lastValueFrom(this.hooks.callHooks("server-event-onBeforeCreated", eventObj, this));
|
|
1736
|
+
value.filter(v => v).forEach(v => {
|
|
1737
|
+
eventObj = v;
|
|
1738
|
+
});
|
|
1739
|
+
|
|
1740
|
+
const event = eventObj.event;
|
|
1741
|
+
const x = typeof eventObj.x === "number" ? eventObj.x : 0;
|
|
1742
|
+
const y = typeof eventObj.y === "number" ? eventObj.y : 0;
|
|
1743
|
+
|
|
1744
|
+
const requestedMode = options.mode ?? this.resolveEventMode(eventObj);
|
|
1745
|
+
const mode = this.normalizeEventMode(requestedMode);
|
|
1746
|
+
const ownerFromData = options.scenarioOwnerId ?? this.resolveScenarioOwnerId(eventObj);
|
|
1747
|
+
const scenarioOwnerId = mode === EventMode.Scenario ? ownerFromData : undefined;
|
|
1748
|
+
const effectiveMode = mode === EventMode.Scenario && scenarioOwnerId
|
|
1749
|
+
? EventMode.Scenario
|
|
1750
|
+
: EventMode.Shared;
|
|
1751
|
+
|
|
1752
|
+
if (mode === EventMode.Scenario && !scenarioOwnerId) {
|
|
1753
|
+
console.warn("Scenario event created without owner id. Falling back to shared mode.");
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
const id = this.buildRuntimeEventId(eventObj.id, effectiveMode, scenarioOwnerId);
|
|
1757
|
+
let eventInstance: RpgPlayer;
|
|
1758
|
+
|
|
1759
|
+
if (this.events()[id]) {
|
|
1760
|
+
console.warn(`Event ${id} already exists on map`);
|
|
1761
|
+
return undefined;
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
// Check if event is a constructor function (class)
|
|
1765
|
+
if (typeof event === 'function') {
|
|
1766
|
+
eventInstance = new event();
|
|
1767
|
+
if (event.prototype.name) eventInstance.name.set(event.prototype.name);
|
|
1768
|
+
}
|
|
1769
|
+
// Handle event as an object with hooks
|
|
1770
|
+
else {
|
|
1771
|
+
// Create a new instance extending RpgPlayer with the hooks from the event object
|
|
1772
|
+
class DynamicEvent extends RpgEvent {
|
|
1773
|
+
onInit?: () => void;
|
|
1774
|
+
onChanges?: (player: RpgPlayer) => void;
|
|
1775
|
+
onAction?: (player: RpgPlayer) => void;
|
|
1776
|
+
onPlayerTouch?: (player: RpgPlayer) => void;
|
|
1777
|
+
onInShape?: (zone: RpgShape, player: RpgPlayer) => void;
|
|
1778
|
+
onOutShape?: (zone: RpgShape, player: RpgPlayer) => void;
|
|
1779
|
+
onDetectInShape?: (player: RpgPlayer, shape: RpgShape) => void;
|
|
1780
|
+
onDetectOutShape?: (player: RpgPlayer, shape: RpgShape) => void;
|
|
1781
|
+
|
|
1782
|
+
constructor() {
|
|
1783
|
+
super();
|
|
1784
|
+
|
|
1785
|
+
// Copy hooks from the event object
|
|
1786
|
+
const hookObj = event as EventHooks;
|
|
1787
|
+
if (hookObj.onInit) this.onInit = hookObj.onInit.bind(this);
|
|
1788
|
+
if (hookObj.onChanges) this.onChanges = hookObj.onChanges.bind(this);
|
|
1789
|
+
if (hookObj.onAction) this.onAction = hookObj.onAction.bind(this);
|
|
1790
|
+
if (hookObj.onPlayerTouch) this.onPlayerTouch = hookObj.onPlayerTouch.bind(this);
|
|
1791
|
+
if (hookObj.onInShape) this.onInShape = hookObj.onInShape.bind(this);
|
|
1792
|
+
if (hookObj.onOutShape) this.onOutShape = hookObj.onOutShape.bind(this);
|
|
1793
|
+
if (hookObj.onDetectInShape) this.onDetectInShape = hookObj.onDetectInShape.bind(this);
|
|
1794
|
+
if (hookObj.onDetectOutShape) this.onDetectOutShape = hookObj.onDetectOutShape.bind(this);
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
eventInstance = new DynamicEvent();
|
|
1799
|
+
if ((event as any).name) eventInstance.name.set((event as any).name);
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
(eventInstance as any).mode = effectiveMode;
|
|
1803
|
+
if (effectiveMode === EventMode.Scenario && scenarioOwnerId) {
|
|
1804
|
+
(eventInstance as any)._scenarioOwnerId = scenarioOwnerId;
|
|
1805
|
+
(eventInstance as any).scenarioOwnerId = scenarioOwnerId;
|
|
1806
|
+
}
|
|
1807
|
+
else {
|
|
1808
|
+
delete (eventInstance as any)._scenarioOwnerId;
|
|
1809
|
+
delete (eventInstance as any).scenarioOwnerId;
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
eventInstance.map = this;
|
|
1813
|
+
eventInstance.context = context;
|
|
1814
|
+
|
|
1815
|
+
await eventInstance.teleport({ x, y });
|
|
1816
|
+
|
|
1817
|
+
this.events()[id] = eventInstance;
|
|
1818
|
+
this.setEventRuntimeMetadata(id, effectiveMode, scenarioOwnerId);
|
|
1819
|
+
|
|
1820
|
+
await eventInstance.execMethod('onInit');
|
|
1821
|
+
return id;
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
/**
|
|
1825
|
+
* Get an event by its ID
|
|
1826
|
+
*
|
|
1827
|
+
* Returns the event with the specified ID, or undefined if not found.
|
|
1828
|
+
* The return type can be narrowed using TypeScript generics.
|
|
1829
|
+
*
|
|
1830
|
+
* @param eventId - The unique identifier of the event
|
|
1831
|
+
* @returns The event instance, or undefined if not found
|
|
1832
|
+
*
|
|
1833
|
+
* @example
|
|
1834
|
+
* ```ts
|
|
1835
|
+
* // Get any event
|
|
1836
|
+
* const event = map.getEvent('npc-1');
|
|
1837
|
+
*
|
|
1838
|
+
* // Get event with type narrowing
|
|
1839
|
+
* const npc = map.getEvent<MyNPC>('npc-1');
|
|
1840
|
+
* if (npc) {
|
|
1841
|
+
* npc.speak('Hello!');
|
|
1842
|
+
* }
|
|
1843
|
+
* ```
|
|
1844
|
+
*/
|
|
1845
|
+
getEvent<T extends RpgPlayer>(eventId: string): T | undefined {
|
|
1846
|
+
return this.events()[eventId] as T
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
/**
|
|
1850
|
+
* Get a player by their ID
|
|
1851
|
+
*
|
|
1852
|
+
* Returns the player with the specified ID, or undefined if not found.
|
|
1853
|
+
*
|
|
1854
|
+
* @param playerId - The unique identifier of the player
|
|
1855
|
+
* @returns The player instance, or undefined if not found
|
|
1856
|
+
*
|
|
1857
|
+
* @example
|
|
1858
|
+
* ```ts
|
|
1859
|
+
* const player = map.getPlayer('player-123');
|
|
1860
|
+
* if (player) {
|
|
1861
|
+
* console.log(`Player ${player.name} is on the map`);
|
|
1862
|
+
* }
|
|
1863
|
+
* ```
|
|
1864
|
+
*/
|
|
1865
|
+
getPlayer(playerId: string): RpgPlayer | undefined {
|
|
1866
|
+
return this.players()[playerId]
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
/**
|
|
1870
|
+
* Get all players currently on the map
|
|
1871
|
+
*
|
|
1872
|
+
* Returns an array of all players that are currently connected to this map.
|
|
1873
|
+
*
|
|
1874
|
+
* @returns Array of all RpgPlayer instances on the map
|
|
1875
|
+
*
|
|
1876
|
+
* @example
|
|
1877
|
+
* ```ts
|
|
1878
|
+
* const players = map.getPlayers();
|
|
1879
|
+
* console.log(`There are ${players.length} players on the map`);
|
|
1880
|
+
*
|
|
1881
|
+
* players.forEach(player => {
|
|
1882
|
+
* console.log(`- ${player.name}`);
|
|
1883
|
+
* });
|
|
1884
|
+
* ```
|
|
1885
|
+
*/
|
|
1886
|
+
getPlayers(): RpgPlayer[] {
|
|
1887
|
+
return Object.values(this.players())
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
/**
|
|
1891
|
+
* Get all events on the map
|
|
1892
|
+
*
|
|
1893
|
+
* Returns an array of all events (NPCs, objects, etc.) that are currently
|
|
1894
|
+
* on this map.
|
|
1895
|
+
*
|
|
1896
|
+
* @returns Array of all RpgEvent instances on the map
|
|
1897
|
+
*
|
|
1898
|
+
* @example
|
|
1899
|
+
* ```ts
|
|
1900
|
+
* const events = map.getEvents();
|
|
1901
|
+
* console.log(`There are ${events.length} events on the map`);
|
|
1902
|
+
*
|
|
1903
|
+
* events.forEach(event => {
|
|
1904
|
+
* console.log(`- ${event.name} at (${event.x}, ${event.y})`);
|
|
1905
|
+
* });
|
|
1906
|
+
* ```
|
|
1907
|
+
*/
|
|
1908
|
+
getEvents(): RpgEvent[] {
|
|
1909
|
+
return Object.values(this.events())
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
getEventsForPlayer(playerOrId: string | RpgPlayer): RpgEvent[] {
|
|
1913
|
+
return this.getEvents().filter(event => this.isEventVisibleForPlayer(event, playerOrId));
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
/**
|
|
1917
|
+
* Get the first event that matches a condition
|
|
1918
|
+
*
|
|
1919
|
+
* Searches through all events on the map and returns the first one that
|
|
1920
|
+
* matches the provided callback function.
|
|
1921
|
+
*
|
|
1922
|
+
* @param cb - Callback function that returns true for the desired event
|
|
1923
|
+
* @returns The first matching event, or undefined if none found
|
|
1924
|
+
*
|
|
1925
|
+
* @example
|
|
1926
|
+
* ```ts
|
|
1927
|
+
* // Find an event by name
|
|
1928
|
+
* const npc = map.getEventBy(event => event.name === 'Merchant');
|
|
1929
|
+
*
|
|
1930
|
+
* // Find an event at a specific position
|
|
1931
|
+
* const chest = map.getEventBy(event =>
|
|
1932
|
+
* event.x === 100 && event.y === 200
|
|
1933
|
+
* );
|
|
1934
|
+
* ```
|
|
1935
|
+
*/
|
|
1936
|
+
getEventBy(cb: (event: RpgEvent) => boolean): RpgEvent | undefined {
|
|
1937
|
+
return this.getEventsBy(cb)[0]
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
/**
|
|
1941
|
+
* Get all events that match a condition
|
|
1942
|
+
*
|
|
1943
|
+
* Searches through all events on the map and returns all events that
|
|
1944
|
+
* match the provided callback function.
|
|
1945
|
+
*
|
|
1946
|
+
* @param cb - Callback function that returns true for desired events
|
|
1947
|
+
* @returns Array of all matching events
|
|
1948
|
+
*
|
|
1949
|
+
* @example
|
|
1950
|
+
* ```ts
|
|
1951
|
+
* // Find all NPCs
|
|
1952
|
+
* const npcs = map.getEventsBy(event => event.name.startsWith('NPC-'));
|
|
1953
|
+
*
|
|
1954
|
+
* // Find all events in a specific area
|
|
1955
|
+
* const nearbyEvents = map.getEventsBy(event =>
|
|
1956
|
+
* event.x >= 0 && event.x <= 100 &&
|
|
1957
|
+
* event.y >= 0 && event.y <= 100
|
|
1958
|
+
* );
|
|
1959
|
+
* ```
|
|
1960
|
+
*/
|
|
1961
|
+
getEventsBy(cb: (event: RpgEvent) => boolean): RpgEvent[] {
|
|
1962
|
+
return this.getEvents().filter(cb)
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
/**
|
|
1966
|
+
* Remove an event from the map
|
|
1967
|
+
*
|
|
1968
|
+
* Removes the event with the specified ID from the map. The event will
|
|
1969
|
+
* be removed from the synchronized events signal, causing it to disappear
|
|
1970
|
+
* on all clients.
|
|
1971
|
+
*
|
|
1972
|
+
* @param eventId - The unique identifier of the event to remove
|
|
1973
|
+
*
|
|
1974
|
+
* @example
|
|
1975
|
+
* ```ts
|
|
1976
|
+
* // Remove an event
|
|
1977
|
+
* map.removeEvent('npc-1');
|
|
1978
|
+
*
|
|
1979
|
+
* // Remove event after interaction
|
|
1980
|
+
* const chest = map.getEvent('chest-1');
|
|
1981
|
+
* if (chest) {
|
|
1982
|
+
* // ... do something with chest ...
|
|
1983
|
+
* map.removeEvent('chest-1');
|
|
1984
|
+
* }
|
|
1985
|
+
* ```
|
|
1986
|
+
*/
|
|
1987
|
+
removeEvent(eventId: string) {
|
|
1988
|
+
const event = this.getEvent(eventId) as any;
|
|
1989
|
+
if (event) {
|
|
1990
|
+
try {
|
|
1991
|
+
event.stopMoveTo?.();
|
|
1992
|
+
}
|
|
1993
|
+
catch {
|
|
1994
|
+
// Ignore teardown race: the physics entity may already be gone.
|
|
1995
|
+
}
|
|
1996
|
+
try {
|
|
1997
|
+
event.breakRoutes?.(true);
|
|
1998
|
+
}
|
|
1999
|
+
catch {
|
|
2000
|
+
// Ignore teardown race in route manager.
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
this.clearEventRuntimeMetadata(eventId);
|
|
2004
|
+
delete this.events()[eventId]
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
/**
|
|
2008
|
+
* Display a component animation at a specific position on the map
|
|
2009
|
+
*
|
|
2010
|
+
* This method broadcasts a component animation to all clients connected to the map,
|
|
2011
|
+
* allowing temporary visual effects to be displayed at any location on the map.
|
|
2012
|
+
* Component animations are custom Canvas Engine components that can display
|
|
2013
|
+
* complex effects with custom logic and parameters.
|
|
2014
|
+
*
|
|
2015
|
+
* @param id - The ID of the component animation to display
|
|
2016
|
+
* @param position - The x, y coordinates where to display the animation
|
|
2017
|
+
* @param params - Parameters to pass to the component animation
|
|
2018
|
+
*
|
|
2019
|
+
* @example
|
|
2020
|
+
* ```ts
|
|
2021
|
+
* // Show explosion at specific coordinates
|
|
2022
|
+
* map.showComponentAnimation("explosion", { x: 300, y: 400 }, {
|
|
2023
|
+
* intensity: 2.5,
|
|
2024
|
+
* duration: 1500
|
|
2025
|
+
* });
|
|
2026
|
+
*
|
|
2027
|
+
* // Show area damage effect
|
|
2028
|
+
* map.showComponentAnimation("area-damage", { x: player.x, y: player.y }, {
|
|
2029
|
+
* radius: 100,
|
|
2030
|
+
* color: "red",
|
|
2031
|
+
* damage: 50
|
|
2032
|
+
* });
|
|
2033
|
+
*
|
|
2034
|
+
* // Show treasure spawn effect
|
|
2035
|
+
* map.showComponentAnimation("treasure-spawn", { x: 150, y: 200 }, {
|
|
2036
|
+
* sparkle: true,
|
|
2037
|
+
* sound: "treasure-appear"
|
|
2038
|
+
* });
|
|
2039
|
+
* ```
|
|
2040
|
+
*/
|
|
2041
|
+
showComponentAnimation(id: string, position: { x: number, y: number }, params: any) {
|
|
2042
|
+
this.$broadcast({
|
|
2043
|
+
type: "showComponentAnimation",
|
|
2044
|
+
value: {
|
|
2045
|
+
id,
|
|
2046
|
+
params,
|
|
2047
|
+
position,
|
|
2048
|
+
},
|
|
2049
|
+
});
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
/**
|
|
2053
|
+
* Display a spritesheet animation at a specific position on the map
|
|
2054
|
+
*
|
|
2055
|
+
* This method displays a temporary visual animation using a spritesheet at any
|
|
2056
|
+
* location on the map. It's a convenience method that internally uses showComponentAnimation
|
|
2057
|
+
* with the built-in 'animation' component. This is useful for spell effects, environmental
|
|
2058
|
+
* animations, or any visual feedback that uses predefined spritesheets.
|
|
2059
|
+
*
|
|
2060
|
+
* @param position - The x, y coordinates where to display the animation
|
|
2061
|
+
* @param graphic - The ID of the spritesheet to use for the animation
|
|
2062
|
+
* @param animationName - The name of the animation within the spritesheet (default: 'default')
|
|
2063
|
+
*
|
|
2064
|
+
* @example
|
|
2065
|
+
* ```ts
|
|
2066
|
+
* // Show explosion at specific coordinates
|
|
2067
|
+
* map.showAnimation({ x: 100, y: 200 }, "explosion");
|
|
2068
|
+
*
|
|
2069
|
+
* // Show spell effect at player position
|
|
2070
|
+
* const playerPos = { x: player.x, y: player.y };
|
|
2071
|
+
* map.showAnimation(playerPos, "spell-effects", "lightning");
|
|
2072
|
+
*
|
|
2073
|
+
* // Show environmental effect
|
|
2074
|
+
* map.showAnimation({ x: 300, y: 150 }, "nature-effects", "wind-gust");
|
|
2075
|
+
*
|
|
2076
|
+
* // Show portal opening animation
|
|
2077
|
+
* map.showAnimation({ x: 500, y: 400 }, "portals", "opening");
|
|
2078
|
+
* ```
|
|
2079
|
+
*/
|
|
2080
|
+
showAnimation(position: { x: number, y: number }, graphic: string, animationName: string = 'default') {
|
|
2081
|
+
this.showComponentAnimation('animation', position, {
|
|
2082
|
+
graphic,
|
|
2083
|
+
animationName,
|
|
2084
|
+
})
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
private cloneWeatherState(weather: WeatherState | null): WeatherState | null {
|
|
2088
|
+
if (!weather) {
|
|
2089
|
+
return null;
|
|
2090
|
+
}
|
|
2091
|
+
return {
|
|
2092
|
+
...weather,
|
|
2093
|
+
params: weather.params ? { ...weather.params } : undefined,
|
|
2094
|
+
};
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2097
|
+
/**
|
|
2098
|
+
* Get the current map weather state.
|
|
2099
|
+
*/
|
|
2100
|
+
getWeather(): WeatherState | null {
|
|
2101
|
+
return this.cloneWeatherState(this._weatherState);
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
/**
|
|
2105
|
+
* Set the full weather state for this map.
|
|
2106
|
+
*
|
|
2107
|
+
* When `sync` is true (default), all connected clients receive the new weather.
|
|
2108
|
+
*/
|
|
2109
|
+
setWeather(next: WeatherState | null, options: WeatherSetOptions = {}): WeatherState | null {
|
|
2110
|
+
const sync = options.sync !== false;
|
|
2111
|
+
if (next && !next.effect) {
|
|
2112
|
+
throw new Error("setWeather: 'effect' is required when weather is not null.");
|
|
2113
|
+
}
|
|
2114
|
+
this._weatherState = this.cloneWeatherState(next);
|
|
2115
|
+
if (sync) {
|
|
2116
|
+
this.$broadcast({
|
|
2117
|
+
type: "weatherState",
|
|
2118
|
+
value: this._weatherState,
|
|
2119
|
+
});
|
|
2120
|
+
}
|
|
2121
|
+
return this.getWeather();
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
/**
|
|
2125
|
+
* Patch the current weather state.
|
|
2126
|
+
*
|
|
2127
|
+
* Nested `params` values are merged.
|
|
2128
|
+
*/
|
|
2129
|
+
patchWeather(patch: Partial<WeatherState>, options: WeatherSetOptions = {}): WeatherState | null {
|
|
2130
|
+
const current = this._weatherState ?? null;
|
|
2131
|
+
if (!current && !patch.effect) {
|
|
2132
|
+
throw new Error("patchWeather: 'effect' is required when no weather is currently set.");
|
|
2133
|
+
}
|
|
2134
|
+
const next: WeatherState = {
|
|
2135
|
+
...(current ?? {}),
|
|
2136
|
+
...patch,
|
|
2137
|
+
params: {
|
|
2138
|
+
...(current?.params ?? {}),
|
|
2139
|
+
...(patch.params ?? {}),
|
|
2140
|
+
},
|
|
2141
|
+
} as WeatherState;
|
|
2142
|
+
return this.setWeather(next, options);
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2145
|
+
/**
|
|
2146
|
+
* Clear weather for this map.
|
|
2147
|
+
*/
|
|
2148
|
+
clearWeather(options: WeatherSetOptions = {}): void {
|
|
2149
|
+
this.setWeather(null, options);
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
/**
|
|
2153
|
+
* Configure runtime synchronized properties on the map
|
|
2154
|
+
*
|
|
2155
|
+
* This method allows you to dynamically add synchronized properties to the map
|
|
2156
|
+
* that will be automatically synced with clients. The schema follows the same
|
|
2157
|
+
* structure as module properties with `$initial`, `$syncWithClient`, and `$permanent` options.
|
|
2158
|
+
*
|
|
2159
|
+
* ## Architecture
|
|
2160
|
+
*
|
|
2161
|
+
* - Reads a schema object shaped like module props
|
|
2162
|
+
* - Creates typed sync signals with @signe/sync
|
|
2163
|
+
* - Properties are accessible as `map.propertyName`
|
|
2164
|
+
*
|
|
2165
|
+
* @param schema - Schema object defining the properties to sync
|
|
2166
|
+
* @param schema[key].$initial - Initial value for the property
|
|
2167
|
+
* @param schema[key].$syncWithClient - Whether to sync this property to clients
|
|
2168
|
+
* @param schema[key].$permanent - Whether to persist this property
|
|
2169
|
+
*
|
|
2170
|
+
* @example
|
|
2171
|
+
* ```ts
|
|
2172
|
+
* // Add synchronized properties to the map
|
|
2173
|
+
* map.setSync({
|
|
2174
|
+
* weather: {
|
|
2175
|
+
* $initial: 'sunny',
|
|
2176
|
+
* $syncWithClient: true,
|
|
2177
|
+
* $permanent: false
|
|
2178
|
+
* },
|
|
2179
|
+
* timeOfDay: {
|
|
2180
|
+
* $initial: 12,
|
|
2181
|
+
* $syncWithClient: true,
|
|
2182
|
+
* $permanent: false
|
|
2183
|
+
* }
|
|
2184
|
+
* });
|
|
2185
|
+
*
|
|
2186
|
+
* // Use the properties
|
|
2187
|
+
* map.weather.set('rainy');
|
|
2188
|
+
* const currentWeather = map.weather();
|
|
2189
|
+
* ```
|
|
2190
|
+
*/
|
|
2191
|
+
setSync(schema: Record<string, any>) {
|
|
2192
|
+
for (let key in schema) {
|
|
2193
|
+
const initial = typeof schema[key]?.$initial !== 'undefined' ? schema[key].$initial : null;
|
|
2194
|
+
// Use type() directly with a plain object holder to avoid signal type mismatch
|
|
2195
|
+
const holder: any = {};
|
|
2196
|
+
this[key] = type(signal(initial) as any, key, {
|
|
2197
|
+
syncToClient: schema[key]?.$syncWithClient,
|
|
2198
|
+
persist: schema[key]?.$permanent,
|
|
2199
|
+
}, holder);
|
|
2200
|
+
}
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
/**
|
|
2204
|
+
* Apply sync to the client
|
|
2205
|
+
*
|
|
2206
|
+
* This method applies sync to the client by calling the `$applySync()` method.
|
|
2207
|
+
*
|
|
2208
|
+
* @example
|
|
2209
|
+
* ```ts
|
|
2210
|
+
* map.applySyncToClient();
|
|
2211
|
+
* ```
|
|
2212
|
+
*/
|
|
2213
|
+
applySyncToClient() {
|
|
2214
|
+
this.$applySync();
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
/**
|
|
2218
|
+
* Create a shape dynamically on the map
|
|
2219
|
+
*
|
|
2220
|
+
* This method creates a static hitbox on the map that can be used for
|
|
2221
|
+
* collision detection, area triggers, or visual boundaries. The shape is
|
|
2222
|
+
* backed by the physics engine's static entity system for accurate collision detection.
|
|
2223
|
+
*
|
|
2224
|
+
* ## Architecture
|
|
2225
|
+
*
|
|
2226
|
+
* Creates a static entity (hitbox) in the physics engine at the specified position and size.
|
|
2227
|
+
* The shape is stored internally and can be retrieved by name. When players or events
|
|
2228
|
+
* collide with this hitbox, the `onInShape` and `onOutShape` hooks are automatically
|
|
2229
|
+
* triggered on both the player and the event.
|
|
2230
|
+
*
|
|
2231
|
+
* @param obj - Shape configuration object
|
|
2232
|
+
* @param obj.x - X position of the shape (top-left corner) (required)
|
|
2233
|
+
* @param obj.y - Y position of the shape (top-left corner) (required)
|
|
2234
|
+
* @param obj.width - Width of the shape in pixels (required)
|
|
2235
|
+
* @param obj.height - Height of the shape in pixels (required)
|
|
2236
|
+
* @param obj.name - Name of the shape (optional, auto-generated if not provided)
|
|
2237
|
+
* @param obj.z - Z position/depth for rendering (optional)
|
|
2238
|
+
* @param obj.color - Color in hexadecimal format, shared with client (optional)
|
|
2239
|
+
* @param obj.collision - Whether the shape has collision (optional)
|
|
2240
|
+
* @param obj.properties - Additional custom properties (optional)
|
|
2241
|
+
* @returns The created RpgShape instance
|
|
2242
|
+
*
|
|
2243
|
+
* @example
|
|
2244
|
+
* ```ts
|
|
2245
|
+
* // Create a simple rectangular shape
|
|
2246
|
+
* const shape = map.createShape({
|
|
2247
|
+
* x: 100,
|
|
2248
|
+
* y: 200,
|
|
2249
|
+
* width: 50,
|
|
2250
|
+
* height: 50,
|
|
2251
|
+
* name: "spawn-zone"
|
|
2252
|
+
* });
|
|
2253
|
+
*
|
|
2254
|
+
* // Create a shape with visual properties
|
|
2255
|
+
* const triggerZone = map.createShape({
|
|
2256
|
+
* x: 300,
|
|
2257
|
+
* y: 400,
|
|
2258
|
+
* width: 100,
|
|
2259
|
+
* height: 100,
|
|
2260
|
+
* name: "treasure-area",
|
|
2261
|
+
* color: "#FFD700",
|
|
2262
|
+
* z: 1,
|
|
2263
|
+
* collision: false,
|
|
2264
|
+
* properties: {
|
|
2265
|
+
* type: "treasure",
|
|
2266
|
+
* value: 100
|
|
2267
|
+
* }
|
|
2268
|
+
* });
|
|
2269
|
+
*
|
|
2270
|
+
* // Player hooks will be triggered automatically
|
|
2271
|
+
* const player: RpgPlayerHooks = {
|
|
2272
|
+
* onInShape(player: RpgPlayer, shape: RpgShape) {
|
|
2273
|
+
* console.log('in', player.name, shape.name);
|
|
2274
|
+
* },
|
|
2275
|
+
* onOutShape(player: RpgPlayer, shape: RpgShape) {
|
|
2276
|
+
* console.log('out', player.name, shape.name);
|
|
2277
|
+
* }
|
|
2278
|
+
* };
|
|
2279
|
+
* ```
|
|
2280
|
+
*/
|
|
2281
|
+
createShape(obj: {
|
|
2282
|
+
x: number;
|
|
2283
|
+
y: number;
|
|
2284
|
+
width: number;
|
|
2285
|
+
height: number;
|
|
2286
|
+
name?: string;
|
|
2287
|
+
z?: number;
|
|
2288
|
+
color?: string;
|
|
2289
|
+
collision?: boolean;
|
|
2290
|
+
properties?: Record<string, any>;
|
|
2291
|
+
}): RpgShape {
|
|
2292
|
+
const { x, y, width, height } = obj;
|
|
2293
|
+
|
|
2294
|
+
// Validate required parameters
|
|
2295
|
+
if (typeof x !== 'number' || typeof y !== 'number') {
|
|
2296
|
+
throw new Error('Shape x and y must be numbers');
|
|
2297
|
+
}
|
|
2298
|
+
if (typeof width !== 'number' || width <= 0) {
|
|
2299
|
+
throw new Error('Shape width must be a positive number');
|
|
2300
|
+
}
|
|
2301
|
+
if (typeof height !== 'number' || height <= 0) {
|
|
2302
|
+
throw new Error('Shape height must be a positive number');
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2305
|
+
// Generate name if not provided
|
|
2306
|
+
const name = obj.name || generateShortUUID();
|
|
2307
|
+
|
|
2308
|
+
// Check if shape with this name already exists
|
|
2309
|
+
if (this._shapes.has(name)) {
|
|
2310
|
+
throw new Error(`Shape with name "${name}" already exists`);
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2313
|
+
// Calculate center position for the static hitbox
|
|
2314
|
+
const centerX = x + width / 2;
|
|
2315
|
+
const centerY = y + height / 2;
|
|
2316
|
+
|
|
2317
|
+
// Create static entity (hitbox) in physics engine
|
|
2318
|
+
const entityId = `shape-${name}`;
|
|
2319
|
+
const entity = this.physic.createEntity({
|
|
2320
|
+
uuid: entityId,
|
|
2321
|
+
position: { x: centerX, y: centerY },
|
|
2322
|
+
width: width,
|
|
2323
|
+
height: height,
|
|
2324
|
+
mass: Infinity, // Static entity
|
|
2325
|
+
state: EntityState.Static,
|
|
2326
|
+
restitution: 0, // No bounce
|
|
2327
|
+
});
|
|
2328
|
+
entity.freeze(); // Ensure it's frozen
|
|
2329
|
+
|
|
2330
|
+
// Build properties object
|
|
2331
|
+
const properties: Record<string, any> = {
|
|
2332
|
+
...(obj.properties || {}),
|
|
2333
|
+
};
|
|
2334
|
+
if (obj.z !== undefined) properties.z = obj.z;
|
|
2335
|
+
if (obj.color !== undefined) properties.color = obj.color;
|
|
2336
|
+
if (obj.collision !== undefined) properties.collision = obj.collision;
|
|
2337
|
+
|
|
2338
|
+
// Create RpgShape instance
|
|
2339
|
+
// Note: We use entityId as physicZoneId for compatibility, but it's actually an entity UUID
|
|
2340
|
+
const shape = new RpgShape({
|
|
2341
|
+
name: name,
|
|
2342
|
+
positioning: 'default',
|
|
2343
|
+
width: width,
|
|
2344
|
+
height: height,
|
|
2345
|
+
x: centerX,
|
|
2346
|
+
y: centerY,
|
|
2347
|
+
properties: properties,
|
|
2348
|
+
playerOwner: undefined, // Static shapes are not attached to players
|
|
2349
|
+
physicZoneId: entityId, // Store entity UUID for reference
|
|
2350
|
+
map: this,
|
|
2351
|
+
});
|
|
2352
|
+
|
|
2353
|
+
// Store the shape
|
|
2354
|
+
this._shapes.set(name, shape);
|
|
2355
|
+
this._shapeEntities.set(entityId, shape);
|
|
2356
|
+
|
|
2357
|
+
return shape;
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
/**
|
|
2361
|
+
* Delete a shape from the map
|
|
2362
|
+
*
|
|
2363
|
+
* Removes a shape by its name and cleans up the associated static hitbox entity.
|
|
2364
|
+
* If the shape doesn't exist, the method does nothing.
|
|
2365
|
+
*
|
|
2366
|
+
* @param name - Name of the shape to remove
|
|
2367
|
+
* @returns void
|
|
2368
|
+
*
|
|
2369
|
+
* @example
|
|
2370
|
+
* ```ts
|
|
2371
|
+
* // Create and then remove a shape
|
|
2372
|
+
* const shape = map.createShape({
|
|
2373
|
+
* x: 100,
|
|
2374
|
+
* y: 200,
|
|
2375
|
+
* width: 50,
|
|
2376
|
+
* height: 50,
|
|
2377
|
+
* name: "temp-zone"
|
|
2378
|
+
* });
|
|
2379
|
+
*
|
|
2380
|
+
* // Later, remove it
|
|
2381
|
+
* map.removeShape("temp-zone");
|
|
2382
|
+
* ```
|
|
2383
|
+
*/
|
|
2384
|
+
removeShape(name: string): void {
|
|
2385
|
+
const shape = this._shapes.get(name);
|
|
2386
|
+
if (!shape) {
|
|
2387
|
+
return;
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
// Remove entity from physics engine
|
|
2391
|
+
const entityId = (shape as any)._physicZoneId;
|
|
2392
|
+
const entity = this.physic.getEntityByUUID(entityId);
|
|
2393
|
+
if (entity) {
|
|
2394
|
+
this.physic.removeEntity(entity);
|
|
2395
|
+
}
|
|
2396
|
+
|
|
2397
|
+
// Remove from internal storage
|
|
2398
|
+
this._shapes.delete(name);
|
|
2399
|
+
this._shapeEntities.delete(entityId);
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
/**
|
|
2403
|
+
* Get all shapes on the map
|
|
2404
|
+
*
|
|
2405
|
+
* Returns an array of all shapes that have been created on this map,
|
|
2406
|
+
* regardless of whether they are static shapes or player-attached shapes.
|
|
2407
|
+
*
|
|
2408
|
+
* @returns Array of RpgShape instances
|
|
2409
|
+
*
|
|
2410
|
+
* @example
|
|
2411
|
+
* ```ts
|
|
2412
|
+
* // Create multiple shapes
|
|
2413
|
+
* map.createShape({ x: 0, y: 0, width: 50, height: 50, name: "zone1" });
|
|
2414
|
+
* map.createShape({ x: 100, y: 100, width: 50, height: 50, name: "zone2" });
|
|
2415
|
+
*
|
|
2416
|
+
* // Get all shapes
|
|
2417
|
+
* const allShapes = map.getShapes();
|
|
2418
|
+
* console.log(allShapes.length); // 2
|
|
2419
|
+
* ```
|
|
2420
|
+
*/
|
|
2421
|
+
getShapes(): RpgShape[] {
|
|
2422
|
+
return Array.from(this._shapes.values());
|
|
2423
|
+
}
|
|
2424
|
+
|
|
2425
|
+
/**
|
|
2426
|
+
* Get a shape by its name
|
|
2427
|
+
*
|
|
2428
|
+
* Returns a shape with the specified name, or undefined if no shape
|
|
2429
|
+
* with that name exists on the map.
|
|
2430
|
+
*
|
|
2431
|
+
* @param name - Name of the shape to retrieve
|
|
2432
|
+
* @returns The RpgShape instance, or undefined if not found
|
|
2433
|
+
*
|
|
2434
|
+
* @example
|
|
2435
|
+
* ```ts
|
|
2436
|
+
* // Create a shape with a specific name
|
|
2437
|
+
* map.createShape({
|
|
2438
|
+
* x: 100,
|
|
2439
|
+
* y: 200,
|
|
2440
|
+
* width: 50,
|
|
2441
|
+
* height: 50,
|
|
2442
|
+
* name: "spawn-point"
|
|
2443
|
+
* });
|
|
2444
|
+
*
|
|
2445
|
+
* // Retrieve it later
|
|
2446
|
+
* const spawnZone = map.getShape("spawn-point");
|
|
2447
|
+
* if (spawnZone) {
|
|
2448
|
+
* console.log(`Spawn zone at (${spawnZone.x}, ${spawnZone.y})`);
|
|
2449
|
+
* }
|
|
2450
|
+
* ```
|
|
2451
|
+
*/
|
|
2452
|
+
getShape(name: string): RpgShape | undefined {
|
|
2453
|
+
return this._shapes.get(name);
|
|
2454
|
+
}
|
|
2455
|
+
|
|
2456
|
+
/**
|
|
2457
|
+
* Play a sound for all players on the map
|
|
2458
|
+
*
|
|
2459
|
+
* This method plays a sound for all players currently on the map by iterating
|
|
2460
|
+
* over each player and calling `player.playSound()`. The sound must be defined
|
|
2461
|
+
* on the client side (in the client module configuration).
|
|
2462
|
+
* This is ideal for environmental sounds, battle music, or map-wide events that
|
|
2463
|
+
* all players should hear simultaneously.
|
|
2464
|
+
*
|
|
2465
|
+
* ## Design
|
|
2466
|
+
*
|
|
2467
|
+
* Iterates over all players on the map and calls `player.playSound()` for each one.
|
|
2468
|
+
* This avoids code duplication and reuses the existing player sound logic.
|
|
2469
|
+
* For player-specific sounds, use `player.playSound()` directly.
|
|
2470
|
+
*
|
|
2471
|
+
* @param soundId - Sound identifier, defined on the client side
|
|
2472
|
+
* @param options - Optional sound configuration
|
|
2473
|
+
* @param options.volume - Volume level (0.0 to 1.0, default: 1.0)
|
|
2474
|
+
* @param options.loop - Whether the sound should loop (default: false)
|
|
2475
|
+
*
|
|
2476
|
+
* @example
|
|
2477
|
+
* ```ts
|
|
2478
|
+
* // Play a sound for all players on the map
|
|
2479
|
+
* map.playSound("explosion");
|
|
2480
|
+
*
|
|
2481
|
+
* // Play background music for everyone with volume and loop
|
|
2482
|
+
* map.playSound("battle-theme", {
|
|
2483
|
+
* volume: 0.7,
|
|
2484
|
+
* loop: true
|
|
2485
|
+
* });
|
|
2486
|
+
*
|
|
2487
|
+
* // Play a door opening sound at low volume
|
|
2488
|
+
* map.playSound("door-open", { volume: 0.4 });
|
|
2489
|
+
* ```
|
|
2490
|
+
*/
|
|
2491
|
+
playSound(soundId: string, options?: { volume?: number; loop?: boolean }): void {
|
|
2492
|
+
const players = this.getPlayers();
|
|
2493
|
+
players.forEach((player) => {
|
|
2494
|
+
player.playSound(soundId, options);
|
|
2495
|
+
});
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2498
|
+
/**
|
|
2499
|
+
* Stop a sound for all players on the map
|
|
2500
|
+
*
|
|
2501
|
+
* This method stops a sound that was previously started with `map.playSound()`
|
|
2502
|
+
* for all players on the map by iterating over each player and calling `player.stopSound()`.
|
|
2503
|
+
*
|
|
2504
|
+
* @param soundId - Sound identifier to stop
|
|
2505
|
+
*
|
|
2506
|
+
* @example
|
|
2507
|
+
* ```ts
|
|
2508
|
+
* // Start background music for everyone
|
|
2509
|
+
* map.playSound("battle-theme", { loop: true });
|
|
2510
|
+
*
|
|
2511
|
+
* // Later, stop it for everyone
|
|
2512
|
+
* map.stopSound("battle-theme");
|
|
2513
|
+
* ```
|
|
2514
|
+
*/
|
|
2515
|
+
stopSound(soundId: string): void {
|
|
2516
|
+
const players = this.getPlayers();
|
|
2517
|
+
players.forEach((player) => {
|
|
2518
|
+
player.stopSound(soundId);
|
|
2519
|
+
});
|
|
2520
|
+
}
|
|
2521
|
+
|
|
2522
|
+
/**
|
|
2523
|
+
* Shake the map for all players
|
|
2524
|
+
*
|
|
2525
|
+
* This method triggers a shake animation on the map for all players currently on the map.
|
|
2526
|
+
* The shake effect creates a visual feedback that can be used for earthquakes, explosions,
|
|
2527
|
+
* impacts, or any dramatic event that should affect the entire map visually.
|
|
2528
|
+
*
|
|
2529
|
+
* ## Architecture
|
|
2530
|
+
*
|
|
2531
|
+
* Broadcasts a shake event to all clients connected to the map. Each client receives
|
|
2532
|
+
* the shake configuration and triggers the shake animation on the map container using
|
|
2533
|
+
* Canvas Engine's shake directive.
|
|
2534
|
+
*
|
|
2535
|
+
* @param options - Optional shake configuration
|
|
2536
|
+
* @param options.intensity - Shake intensity in pixels (default: 10)
|
|
2537
|
+
* @param options.duration - Duration of the shake animation in milliseconds (default: 500)
|
|
2538
|
+
* @param options.frequency - Number of shake oscillations during the animation (default: 10)
|
|
2539
|
+
* @param options.direction - Direction of the shake - 'x', 'y', or 'both' (default: 'both')
|
|
2540
|
+
*
|
|
2541
|
+
* @example
|
|
2542
|
+
* ```ts
|
|
2543
|
+
* // Basic shake with default settings
|
|
2544
|
+
* map.shakeMap();
|
|
2545
|
+
*
|
|
2546
|
+
* // Intense earthquake effect
|
|
2547
|
+
* map.shakeMap({
|
|
2548
|
+
* intensity: 25,
|
|
2549
|
+
* duration: 1000,
|
|
2550
|
+
* frequency: 15,
|
|
2551
|
+
* direction: 'both'
|
|
2552
|
+
* });
|
|
2553
|
+
*
|
|
2554
|
+
* // Horizontal shake for side impact
|
|
2555
|
+
* map.shakeMap({
|
|
2556
|
+
* intensity: 15,
|
|
2557
|
+
* duration: 400,
|
|
2558
|
+
* direction: 'x'
|
|
2559
|
+
* });
|
|
2560
|
+
*
|
|
2561
|
+
* // Vertical shake for ground impact
|
|
2562
|
+
* map.shakeMap({
|
|
2563
|
+
* intensity: 20,
|
|
2564
|
+
* duration: 600,
|
|
2565
|
+
* direction: 'y'
|
|
2566
|
+
* });
|
|
2567
|
+
* ```
|
|
2568
|
+
*/
|
|
2569
|
+
shakeMap(options?: {
|
|
2570
|
+
intensity?: number;
|
|
2571
|
+
duration?: number;
|
|
2572
|
+
frequency?: number;
|
|
2573
|
+
direction?: 'x' | 'y' | 'both';
|
|
2574
|
+
}): void {
|
|
2575
|
+
this.$broadcast({
|
|
2576
|
+
type: "shakeMap",
|
|
2577
|
+
value: {
|
|
2578
|
+
intensity: options?.intensity ?? 10,
|
|
2579
|
+
duration: options?.duration ?? 500,
|
|
2580
|
+
frequency: options?.frequency ?? 10,
|
|
2581
|
+
direction: options?.direction ?? 'both',
|
|
2582
|
+
},
|
|
2583
|
+
});
|
|
2584
|
+
}
|
|
2585
|
+
|
|
2586
|
+
/**
|
|
2587
|
+
* Clear all server resources and reset state
|
|
2588
|
+
*
|
|
2589
|
+
* This method should be called to clean up all server-side resources when
|
|
2590
|
+
* shutting down or resetting the map. It stops the input processing loop
|
|
2591
|
+
* and ensures that all subscriptions are properly cleaned up.
|
|
2592
|
+
*
|
|
2593
|
+
* ## Design
|
|
2594
|
+
*
|
|
2595
|
+
* This method is used primarily in testing environments to ensure clean
|
|
2596
|
+
* state between tests. It stops the tick subscription to prevent memory leaks.
|
|
2597
|
+
*
|
|
2598
|
+
* @example
|
|
2599
|
+
* ```ts
|
|
2600
|
+
* // In test cleanup
|
|
2601
|
+
* afterEach(() => {
|
|
2602
|
+
* map.clear();
|
|
2603
|
+
* });
|
|
2604
|
+
* ```
|
|
2605
|
+
*/
|
|
2606
|
+
clear(): void {
|
|
2607
|
+
try {
|
|
2608
|
+
// Stop input processing loop
|
|
2609
|
+
if (this._inputLoopSubscription) {
|
|
2610
|
+
this._inputLoopSubscription.unsubscribe();
|
|
2611
|
+
this._inputLoopSubscription = undefined;
|
|
2612
|
+
}
|
|
2613
|
+
} catch (error) {
|
|
2614
|
+
console.warn('Error during map cleanup:', error);
|
|
2615
|
+
}
|
|
305
2616
|
}
|
|
306
2617
|
}
|
|
307
2618
|
|
|
308
|
-
export interface RpgMap {
|
|
309
|
-
$send: (conn: MockConnection, data: any) => void;
|
|
310
|
-
$broadcast: (data: any) => void;
|
|
311
|
-
}
|
|
2619
|
+
export interface RpgMap extends RoomMethods { }
|