@rpgjs/server 5.0.0-alpha.25 → 5.0.0-alpha.26

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.
@@ -91,10 +91,69 @@ export class RpgPlayer extends BasicPlayerMixins(RpgCommonPlayer) {
91
91
  context?: Context;
92
92
  conn: MockConnection | null = null;
93
93
  touchSide: boolean = false; // Protection against map change loops
94
-
94
+
95
+ /**
96
+ * Computed signal for world X position
97
+ *
98
+ * Calculates the absolute world X position from the map's world position
99
+ * plus the player's local X position. Returns 0 if no map is assigned.
100
+ *
101
+ * @example
102
+ * ```ts
103
+ * const worldX = player.worldX();
104
+ * console.log(`Player is at world X: ${worldX}`);
105
+ * ```
106
+ */
107
+ get worldPositionX() {
108
+ return this._getComputedWorldPosition('x');
109
+ }
110
+
111
+ /**
112
+ * Computed signal for world Y position
113
+ *
114
+ * Calculates the absolute world Y position from the map's world position
115
+ * plus the player's local Y position. Returns 0 if no map is assigned.
116
+ *
117
+ * @example
118
+ * ```ts
119
+ * const worldY = player.worldY();
120
+ * console.log(`Player is at world Y: ${worldY}`);
121
+ * ```
122
+ */
123
+ get worldPositionY() {
124
+ return this._getComputedWorldPosition('y');
125
+ }
126
+
127
+ private _worldPositionSignals = new WeakMap<any, any>();
128
+
129
+ private _getComputedWorldPosition(axis: 'x' | 'y') {
130
+ // We use a WeakMap to cache the computed signal per instance
131
+ // This ensures that if the player object is copied (e.g. in tests),
132
+ // the new instance gets its own signal bound to itself.
133
+ if (!this._worldPositionSignals) {
134
+ this._worldPositionSignals = new WeakMap();
135
+ }
136
+
137
+ const key = axis;
138
+ let signals = this._worldPositionSignals.get(this);
139
+ if (!signals) {
140
+ signals = {};
141
+ this._worldPositionSignals.set(this, signals);
142
+ }
143
+
144
+ if (!signals[key]) {
145
+ signals[key] = computed(() => {
146
+ const map = this.map as RpgMap | null;
147
+ const mapWorldPos = map ? (map[axis === 'x' ? 'worldX' : 'worldY'] ?? 0) : 0;
148
+ return mapWorldPos + (this[axis] as any)();
149
+ });
150
+ }
151
+ return signals[key];
152
+ }
153
+
95
154
  /** Internal: Shapes attached to this player */
96
155
  private _attachedShapes: Map<string, RpgShape> = new Map();
97
-
156
+
98
157
  /** Internal: Shapes where this player is currently located */
99
158
  private _inShapes: Set<RpgShape> = new Set();
100
159
  /** Last processed client input timestamp for reconciliation */
@@ -118,10 +177,10 @@ export class RpgPlayer extends BasicPlayerMixins(RpgCommonPlayer) {
118
177
  super();
119
178
  // Use type assertion to access mixin properties
120
179
  (this as any).expCurve = {
121
- basis: 30,
122
- extra: 20,
123
- accelerationA: 30,
124
- accelerationB: 30
180
+ basis: 30,
181
+ extra: 20,
182
+ accelerationA: 30,
183
+ accelerationB: 30
125
184
  };
126
185
 
127
186
  (this as any).addParameter(MAXHP, MAXHP_CURVE);
@@ -139,7 +198,7 @@ export class RpgPlayer extends BasicPlayerMixins(RpgCommonPlayer) {
139
198
  combineLatest([this.x.observable, this.y.observable])
140
199
  .subscribe(([x, y]) => {
141
200
  pendingUpdate = { x, y };
142
-
201
+
143
202
  // Schedule a synchronous update using queueMicrotask
144
203
  // This groups multiple rapid changes (x and y in the same tick) into a single frame
145
204
  if (!updateScheduled) {
@@ -163,7 +222,7 @@ export class RpgPlayer extends BasicPlayerMixins(RpgCommonPlayer) {
163
222
  }
164
223
  })
165
224
  }
166
-
225
+
167
226
  _onInit() {
168
227
  this.hooks.callHooks("server-playerProps-load", this).subscribe();
169
228
  }
@@ -177,6 +236,10 @@ export class RpgPlayer extends BasicPlayerMixins(RpgCommonPlayer) {
177
236
  return this.map
178
237
  }
179
238
 
239
+ setMap(map: RpgMap) {
240
+ this.map = map;
241
+ }
242
+
180
243
  applyFrames() {
181
244
  this._frames.set(this.frames)
182
245
  this.frames = []
@@ -219,12 +282,12 @@ export class RpgPlayer extends BasicPlayerMixins(RpgCommonPlayer) {
219
282
  ): Promise<any | null | boolean> {
220
283
  const realMapId = 'map-' + mapId;
221
284
  const room = this.getCurrentMap();
222
-
285
+
223
286
  const canChange: boolean[] = await lastValueFrom(this.hooks.callHooks("server-player-canChangeMap", this, {
224
287
  id: mapId,
225
288
  }));
226
289
  if (canChange.some(v => v === false)) return false;
227
-
290
+
228
291
  if (positions && typeof positions === 'object') {
229
292
  this.teleport(positions)
230
293
  }
@@ -236,118 +299,81 @@ export class RpgPlayer extends BasicPlayerMixins(RpgCommonPlayer) {
236
299
  return true;
237
300
  }
238
301
 
239
- /**
240
- * Auto change map when player touches map borders
241
- *
242
- * This method checks if the player touches the current map borders
243
- * and automatically performs a change to the adjacent map if it exists.
244
- *
245
- * @param nextPosition - The next position of the player
246
- * @returns Promise<boolean> - true if a map change occurred
247
- *
248
- * @example
249
- * ```ts
250
- * // Called automatically by the movement system
251
- * const changed = await player.autoChangeMap({ x: newX, y: newY });
252
- * if (changed) {
253
- * console.log('Player changed map automatically');
254
- * }
255
- * ```
256
- */
257
- async autoChangeMap(nextPosition: { x: number; y: number }, forcedDirection?: any): Promise<boolean> {
258
- const map = this.getCurrentMap() as RpgMap; // Cast to access extended properties
259
- if (!map) return false;
260
-
261
- const worldMaps = map.getWorldMapsManager?.();
262
- let ret: boolean = false;
263
-
302
+ async autoChangeMap(nextPosition: Vector2): Promise<boolean> {
303
+ const map = this.getCurrentMap()
304
+ const worldMaps = map?.getInWorldMaps()
305
+ let ret: boolean = false
264
306
  if (worldMaps && map) {
265
- const direction = forcedDirection ?? this.getDirection();
266
- const marginLeftRight = (map.tileWidth ?? 32) / 2;
267
- const marginTopDown = (map.tileHeight ?? 32) / 2;
268
-
269
- // Current world position of the player
270
- const worldPositionX = (map.worldX ?? 0) + this.x();
271
- const worldPositionY = (map.worldY ?? 0) + this.y();
307
+ const direction = this.getDirection()
308
+ const marginLeftRight = map.tileWidth / 2
309
+ const marginTopDown = map.tileHeight / 2
272
310
 
273
- const changeMap = async (directionNumber: number, positionCalculator: (nextMapInfo: any) => {x: number, y: number}) => {
274
- if (this.touchSide) {
275
- return false;
311
+ const changeMap = async (adjacent, to) => {
312
+ if (this.touchSide) {
313
+ return false
314
+ }
315
+ this.touchSide = true
316
+ const [nextMap] = worldMaps.getAdjacentMaps(map, adjacent)
317
+ if (!nextMap) return false
318
+ const id = nextMap.id as string
319
+ const nextMapInfo = worldMaps.getMapInfo(id)
320
+ return !!(await this.changeMap(id, to(nextMapInfo)))
276
321
  }
277
- this.touchSide = true;
278
322
 
279
- const [nextMap] = worldMaps.getAdjacentMaps(map, directionNumber);
280
- if (!nextMap) {
281
- this.touchSide = false;
282
- return false;
323
+ if (nextPosition.x < marginLeftRight && direction == Direction.Left) {
324
+ ret = await changeMap({
325
+ x: map.worldX - 1,
326
+ y: this.worldPositionY() + 1
327
+ }, nextMapInfo => ({
328
+ x: (nextMapInfo.width) - this.hitbox().w - marginLeftRight,
329
+ y: map.worldY - nextMapInfo.y + nextPosition.y
330
+ }))
283
331
  }
284
-
285
- const id = nextMap.id as string;
286
- const nextMapInfo = worldMaps.getMapInfo(id);
287
- if (!nextMapInfo) {
288
- this.touchSide = false;
289
- return false;
332
+ else if (nextPosition.x > map.widthPx - this.hitbox().w - marginLeftRight && direction == Direction.Right) {
333
+ ret = await changeMap({
334
+ x: map.worldX + map.widthPx + 1,
335
+ y: this.worldPositionY() + 1
336
+ }, nextMapInfo => ({
337
+ x: marginLeftRight,
338
+ y: map.worldY - nextMapInfo.y + nextPosition.y
339
+ }))
340
+ }
341
+ else if (nextPosition.y < marginTopDown && direction == Direction.Up) {
342
+ ret = await changeMap({
343
+ x: this.worldPositionX() + 1,
344
+ y: map.worldY - 1
345
+ }, nextMapInfo => ({
346
+ x: map.worldX - nextMapInfo.x + nextPosition.x,
347
+ y: (nextMapInfo.height) - this.hitbox().h - marginTopDown,
348
+ }))
349
+ }
350
+ else if (nextPosition.y > map.heightPx - this.hitbox().h - marginTopDown && direction == Direction.Down) {
351
+ ret = await changeMap({
352
+ x: this.worldPositionX() + 1,
353
+ y: map.worldY + map.heightPx + 1
354
+ }, nextMapInfo => ({
355
+ x: map.worldX - nextMapInfo.x + nextPosition.x,
356
+ y: marginTopDown,
357
+ }))
358
+ }
359
+ else {
360
+ this.touchSide = false
290
361
  }
291
-
292
- const newPosition = positionCalculator(nextMapInfo);
293
- const success = await this.changeMap(id, newPosition);
294
-
295
- // Reset touchSide after a delay to allow the change
296
- setTimeout(() => {
297
- this.touchSide = false;
298
- }, 100);
299
-
300
- return !!success;
301
- };
302
- // Check left border
303
- if (nextPosition.x < marginLeftRight && direction === Direction.Left) {
304
- ret = await changeMap(2, nextMapInfo => ({
305
- x: nextMapInfo.width - (this.hitbox().w) - marginLeftRight,
306
- y: (map.worldY ?? 0) - (nextMapInfo.y ?? 0) + nextPosition.y
307
- }));
308
- }
309
- // Check right border
310
- else if (nextPosition.x > map.widthPx - this.hitbox().w - marginLeftRight && direction === Direction.Right) {
311
- ret = await changeMap(3, nextMapInfo => ({
312
- x: marginLeftRight,
313
- y: (map.worldY ?? 0) - (nextMapInfo.y ?? 0) + nextPosition.y
314
- }));
315
- }
316
- // Check top border
317
- else if (nextPosition.y < marginTopDown && direction === Direction.Up) {
318
- ret = await changeMap(0, nextMapInfo => ({
319
- x: (map.worldX ?? 0) - (nextMapInfo.x ?? 0) + nextPosition.x,
320
- y: nextMapInfo.height - this.hitbox().h - marginTopDown
321
- }));
322
- }
323
- // Check bottom border
324
- else if (nextPosition.y > map.heightPx - this.hitbox().h - marginTopDown && direction === Direction.Down) {
325
- ret = await changeMap(1, nextMapInfo => ({
326
- x: (map.worldX ?? 0) - (nextMapInfo.x ?? 0) + nextPosition.x,
327
- y: marginTopDown
328
- }));
329
- }
330
- else {
331
- this.touchSide = false;
332
- }
333
362
  }
334
-
335
- return ret;
336
- }
363
+ return ret
364
+ }
337
365
 
338
366
  async teleport(positions: { x: number; y: number }) {
339
367
  if (!this.map) return false;
340
- if (this.map.physic) {
368
+ if (this.map && this.map.physic) {
341
369
  // Skip collision check for teleportation (allow teleporting through walls)
342
370
  const entity = this.map.physic.getEntityByUUID(this.id);
343
371
  if (entity) {
344
372
  this.map.physic.teleport(entity, { x: positions.x, y: positions.y });
345
373
  }
346
374
  }
347
- else {
348
- this.x.set(positions.x)
349
- this.y.set(positions.y)
350
- }
375
+ this.x.set(positions.x)
376
+ this.y.set(positions.y)
351
377
  // Wait for the frame to be added before applying frames
352
378
  // This ensures the frame is added before applyFrames() is called
353
379
  queueMicrotask(() => {
@@ -440,8 +466,9 @@ export class RpgPlayer extends BasicPlayerMixins(RpgCommonPlayer) {
440
466
  }
441
467
 
442
468
  databaseById(id: string) {
443
- const map = this.getCurrentMap();
444
- if (!map) return;
469
+ // Use this.map directly to support both RpgMap and LobbyRoom
470
+ const map = this.map as any;
471
+ if (!map || !map.database) return;
445
472
  const data = map.database()[id];
446
473
  if (!data)
447
474
  throw new Error(
@@ -504,7 +531,7 @@ export class RpgPlayer extends BasicPlayerMixins(RpgCommonPlayer) {
504
531
  // Handle overloaded signature: attachShape(options) or attachShape(id, options)
505
532
  let zoneId: string;
506
533
  let shapeOptions: AttachShapeOptions;
507
-
534
+
508
535
  if (typeof idOrOptions === 'string') {
509
536
  zoneId = idOrOptions;
510
537
  if (!options) {
@@ -542,7 +569,7 @@ export class RpgPlayer extends BasicPlayerMixins(RpgCommonPlayer) {
542
569
  if (shapeOptions.positioning) {
543
570
  const playerWidth = playerEntity.width || playerEntity.radius * 2 || 32;
544
571
  const playerHeight = playerEntity.height || playerEntity.radius * 2 || 32;
545
-
572
+
546
573
  switch (shapeOptions.positioning) {
547
574
  case 'top':
548
575
  offset = new Vector2(0, -playerHeight / 2);
@@ -565,7 +592,7 @@ export class RpgPlayer extends BasicPlayerMixins(RpgCommonPlayer) {
565
592
 
566
593
  // Get zone manager and create attached zone
567
594
  const zoneManager = map.physic.getZoneManager();
568
-
595
+
569
596
  // Convert direction from Direction enum to string if needed
570
597
  // Direction enum values are already strings ("up", "down", "left", "right")
571
598
  let direction: 'up' | 'down' | 'left' | 'right' = 'down';
@@ -606,7 +633,7 @@ export class RpgPlayer extends BasicPlayerMixins(RpgCommonPlayer) {
606
633
  entities.forEach((entity) => {
607
634
  const event = map.getEvent<RpgEvent>(entity.uuid);
608
635
  const player = map.getPlayer(entity.uuid);
609
-
636
+
610
637
  if (event) {
611
638
  event.execMethod("onInShape", [shape, this]);
612
639
  // Track that this event is in the shape
@@ -627,7 +654,7 @@ export class RpgPlayer extends BasicPlayerMixins(RpgCommonPlayer) {
627
654
  entities.forEach((entity) => {
628
655
  const event = map.getEvent<RpgEvent>(entity.uuid);
629
656
  const player = map.getPlayer(entity.uuid);
630
-
657
+
631
658
  if (event) {
632
659
  event.execMethod("onOutShape", [shape, this]);
633
660
  // Remove from tracking
@@ -664,10 +691,10 @@ export class RpgPlayer extends BasicPlayerMixins(RpgCommonPlayer) {
664
691
  // Store mapping from zoneId to physicZoneId for future reference
665
692
  (this as any)._zoneIdMap = (this as any)._zoneIdMap || new Map();
666
693
  (this as any)._zoneIdMap.set(zoneId, physicZoneId);
667
-
694
+
668
695
  // Store the shape
669
696
  this._attachedShapes.set(zoneId, shape);
670
-
697
+
671
698
  // Update shape position when player moves
672
699
  const updateShapePosition = () => {
673
700
  const currentEntity = map.physic.getEntityByUUID(this.id);
@@ -678,7 +705,7 @@ export class RpgPlayer extends BasicPlayerMixins(RpgCommonPlayer) {
678
705
  }
679
706
  }
680
707
  };
681
-
708
+
682
709
  // Listen to position changes to update shape position
683
710
  playerEntity.onPositionChange(() => {
684
711
  updateShapePosition();
@@ -686,7 +713,7 @@ export class RpgPlayer extends BasicPlayerMixins(RpgCommonPlayer) {
686
713
 
687
714
  return shape;
688
715
  }
689
-
716
+
690
717
  /**
691
718
  * Get all shapes attached to this player
692
719
  *
@@ -706,7 +733,7 @@ export class RpgPlayer extends BasicPlayerMixins(RpgCommonPlayer) {
706
733
  getShapes(): RpgShape[] {
707
734
  return Array.from(this._attachedShapes.values());
708
735
  }
709
-
736
+
710
737
  /**
711
738
  * Get all shapes where this player is currently located
712
739
  *
@@ -1053,13 +1080,13 @@ export class RpgPlayer extends BasicPlayerMixins(RpgCommonPlayer) {
1053
1080
  if (typeof height !== 'number' || height <= 0) {
1054
1081
  throw new Error('setHitbox: height must be a positive number');
1055
1082
  }
1056
-
1083
+
1057
1084
  // Update hitbox signal
1058
1085
  this.hitbox.set({
1059
1086
  w: width,
1060
1087
  h: height,
1061
1088
  });
1062
-
1089
+
1063
1090
  // Update physics entity if map exists
1064
1091
  const map = this.getCurrentMap();
1065
1092
  if (map && map.physic) {
@@ -1108,18 +1135,17 @@ export class RpgEvent extends RpgPlayer {
1108
1135
  * Extends the RpgPlayer class with additional interfaces from mixins.
1109
1136
  * This provides proper TypeScript support for all mixin methods and properties.
1110
1137
  */
1111
- export interface RpgPlayer extends
1112
- IVariableManager,
1113
- IMoveManager,
1114
- IGoldManager,
1115
- IComponentManager,
1116
- IGuiManager,
1117
- IItemManager,
1118
- IEffectManager,
1119
- IParameterManager,
1120
- IElementManager,
1121
- ISkillManager,
1122
- IBattleManager,
1123
- IClassManager,
1124
- IStateManager
1125
- {}
1138
+ export interface RpgPlayer extends
1139
+ IVariableManager,
1140
+ IMoveManager,
1141
+ IGoldManager,
1142
+ IComponentManager,
1143
+ IGuiManager,
1144
+ IItemManager,
1145
+ IEffectManager,
1146
+ IParameterManager,
1147
+ IElementManager,
1148
+ ISkillManager,
1149
+ IBattleManager,
1150
+ IClassManager,
1151
+ IStateManager { }
package/src/module.ts CHANGED
@@ -127,6 +127,19 @@ export function provideServerModules(modules: RpgServerModule[]): FactoryProvide
127
127
  }
128
128
  };
129
129
  }
130
+ if (module.database && typeof module.database === 'object') {
131
+ const database = {...module.database};
132
+ module = {
133
+ ...module,
134
+ databaseHooks: {
135
+ load: (engine: RpgMap) => {
136
+ for (const key in database) {
137
+ engine.addInDatabase(key, database[key]);
138
+ }
139
+ },
140
+ }
141
+ };
142
+ }
130
143
  return module;
131
144
  })
132
145
  return modules
@@ -0,0 +1,120 @@
1
+ import { signal } from "@signe/reactive";
2
+
3
+ /**
4
+ * Base class for rooms that need database functionality
5
+ *
6
+ * This class provides common database management functionality that is shared
7
+ * between RpgMap and LobbyRoom. It includes methods for adding and managing
8
+ * items, classes, and other game data in the room's database.
9
+ *
10
+ * ## Architecture
11
+ *
12
+ * Both RpgMap and LobbyRoom need to store game entities (items, classes, skills, etc.)
13
+ * in a database. This base class provides the common implementation to avoid code duplication.
14
+ *
15
+ * @example
16
+ * ```ts
17
+ * class MyCustomRoom extends BaseRoom {
18
+ * // Your custom room implementation
19
+ * }
20
+ * ```
21
+ */
22
+ export abstract class BaseRoom {
23
+ /**
24
+ * Signal containing the room's database of items, classes, and other game data
25
+ *
26
+ * This database can be dynamically populated using `addInDatabase()` and
27
+ * `removeInDatabase()` methods. It's used to store game entities like items,
28
+ * classes, skills, etc. that are available in this room.
29
+ *
30
+ * @example
31
+ * ```ts
32
+ * // Add data to database
33
+ * room.addInDatabase('Potion', PotionClass);
34
+ *
35
+ * // Access database
36
+ * const potion = room.database()['Potion'];
37
+ * ```
38
+ */
39
+ database = signal({});
40
+
41
+ /**
42
+ * Add data to the room's database
43
+ *
44
+ * Adds an item, class, or other game entity to the room's database.
45
+ * If the ID already exists and `force` is not enabled, the addition is ignored.
46
+ *
47
+ * ## Architecture
48
+ *
49
+ * This method is used by the item management system to store item definitions
50
+ * in the room's database. When a player adds an item, the system first checks
51
+ * if the item exists in the database, and if not, adds it using this method.
52
+ *
53
+ * @param id - Unique identifier for the data
54
+ * @param data - The data to add (can be a class, object, etc.)
55
+ * @param options - Optional configuration
56
+ * @param options.force - If true, overwrites existing data with the same ID
57
+ * @returns `true` if data was added, `false` if it was ignored (ID already exists)
58
+ *
59
+ * @example
60
+ * ```ts
61
+ * // Add a class to the database
62
+ * room.addInDatabase('Potion', PotionClass);
63
+ *
64
+ * // Add an item object to the database
65
+ * room.addInDatabase('custom-item', {
66
+ * name: 'Custom Item',
67
+ * price: 100
68
+ * });
69
+ *
70
+ * // Force overwrite existing data
71
+ * room.addInDatabase('Potion', UpdatedPotionClass, { force: true });
72
+ * ```
73
+ */
74
+ addInDatabase(id: string, data: any, options?: { force?: boolean }): boolean {
75
+ const database = this.database();
76
+
77
+ // Check if ID already exists
78
+ if (database[id] !== undefined && !options?.force) {
79
+ // Ignore the addition if ID exists and force is not enabled
80
+ return false;
81
+ }
82
+
83
+ // Add or overwrite the data
84
+ database[id] = data;
85
+ this.database.set(database);
86
+ return true;
87
+ }
88
+
89
+ /**
90
+ * Remove data from the room's database
91
+ *
92
+ * This method allows you to remove items or data from the room's database.
93
+ *
94
+ * @param id - Unique identifier of the data to remove
95
+ * @returns `true` if data was removed, `false` if ID didn't exist
96
+ *
97
+ * @example
98
+ * ```ts
99
+ * // Remove an item from the database
100
+ * room.removeInDatabase('Potion');
101
+ *
102
+ * // Check if removal was successful
103
+ * const removed = room.removeInDatabase('custom-item');
104
+ * if (removed) {
105
+ * console.log('Item removed successfully');
106
+ * }
107
+ * ```
108
+ */
109
+ removeInDatabase(id: string): boolean {
110
+ const database = this.database();
111
+
112
+ if (database[id] === undefined) {
113
+ return false;
114
+ }
115
+
116
+ delete database[id];
117
+ this.database.set(database);
118
+ return true;
119
+ }
120
+ }
@@ -5,12 +5,22 @@ import { context } from "../core/context";
5
5
  import { users } from "@signe/sync";
6
6
  import { signal } from "@signe/reactive";
7
7
  import { RpgPlayer } from "../Player/Player";
8
+ import { BaseRoom } from "./BaseRoom";
8
9
 
9
10
  @Room({
10
11
  path: "lobby-{id}",
11
12
  })
12
- export class LobbyRoom {
13
+ export class LobbyRoom extends BaseRoom {
13
14
  @users(RpgPlayer) players = signal({});
15
+ autoSync: boolean = true;
16
+
17
+ constructor(room) {
18
+ super();
19
+ const isTest = room.env.TEST === 'true' ? true : false;
20
+ if (isTest) {
21
+ this.autoSync = false;
22
+ }
23
+ }
14
24
 
15
25
  onJoin(player: RpgPlayer, conn: MockConnection) {
16
26
  player.map = this;