@rpgjs/server 5.0.0-alpha.29 → 5.0.0-alpha.30
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 +1 -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 +24 -3
- package/dist/Gui/TitleGui.d.ts +23 -0
- package/dist/Gui/index.d.ts +9 -1
- package/dist/Player/GuiManager.d.ts +86 -3
- package/dist/Player/Player.d.ts +24 -2
- package/dist/RpgServer.d.ts +7 -0
- package/dist/RpgServerEngine.d.ts +2 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.js +1621 -336
- package/dist/index.js.map +1 -1
- package/dist/presets/index.d.ts +0 -9
- package/dist/rooms/BaseRoom.d.ts +37 -0
- package/dist/rooms/lobby.d.ts +6 -1
- package/dist/rooms/map.d.ts +22 -1
- 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 +10 -10
- package/src/Gui/DialogGui.ts +12 -2
- 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 +145 -16
- package/src/Gui/TitleGui.ts +39 -0
- package/src/Gui/index.ts +13 -2
- package/src/Player/BattleManager.ts +1 -1
- package/src/Player/ClassManager.ts +57 -2
- package/src/Player/GuiManager.ts +125 -14
- package/src/Player/ItemManager.ts +160 -41
- package/src/Player/ParameterManager.ts +1 -1
- package/src/Player/Player.ts +87 -12
- package/src/Player/SkillManager.ts +145 -66
- package/src/Player/StateManager.ts +70 -1
- package/src/Player/VariableManager.ts +10 -7
- package/src/RpgServer.ts +8 -0
- package/src/index.ts +5 -2
- package/src/presets/index.ts +1 -10
- package/src/rooms/BaseRoom.ts +112 -0
- package/src/rooms/lobby.ts +13 -6
- package/src/rooms/map.ts +31 -4
- package/src/services/save.ts +147 -0
- package/src/storage/index.ts +1 -0
- package/src/storage/localStorage.ts +76 -0
|
@@ -46,6 +46,75 @@ export function WithStateManager<TBase extends PlayerCtor>(Base: TBase) {
|
|
|
46
46
|
return class extends Base {
|
|
47
47
|
_statesEfficiency = signal<any[]>([]);
|
|
48
48
|
|
|
49
|
+
private _getStateMap(required: boolean = true) {
|
|
50
|
+
// Use this.map directly to support both RpgMap and LobbyRoom
|
|
51
|
+
const map = (this as any).getCurrentMap?.() || (this as any).map;
|
|
52
|
+
if (required && (!map || !map.database)) {
|
|
53
|
+
throw new Error('Player must be on a map to resolve states');
|
|
54
|
+
}
|
|
55
|
+
return map;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private _resolveStateInput(
|
|
59
|
+
stateInput: StateClass | string,
|
|
60
|
+
databaseByIdOverride?: (id: string) => any
|
|
61
|
+
) {
|
|
62
|
+
if (isString(stateInput)) {
|
|
63
|
+
return databaseByIdOverride
|
|
64
|
+
? databaseByIdOverride(stateInput)
|
|
65
|
+
: (this as any).databaseById(stateInput);
|
|
66
|
+
}
|
|
67
|
+
return stateInput;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private _createStateInstance(stateClass: StateClass) {
|
|
71
|
+
return new (stateClass as StateClass)();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Create a state instance without side effects.
|
|
76
|
+
*/
|
|
77
|
+
createStateInstance(stateInput: StateClass | string) {
|
|
78
|
+
const stateClass = this._resolveStateInput(stateInput);
|
|
79
|
+
const instance = this._createStateInstance(stateClass as StateClass);
|
|
80
|
+
return { stateClass, instance };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Resolve state snapshot entries into state instances without side effects.
|
|
85
|
+
*/
|
|
86
|
+
resolveStatesSnapshot(snapshot: { states?: any[] }, mapOverride?: any) {
|
|
87
|
+
if (!snapshot || !Array.isArray(snapshot.states)) {
|
|
88
|
+
return snapshot;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const map = mapOverride ?? this._getStateMap(false);
|
|
92
|
+
if (!map || !map.database) {
|
|
93
|
+
return snapshot;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const databaseByIdOverride = (id: string) => {
|
|
97
|
+
const data = map.database()[id];
|
|
98
|
+
if (!data) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`The ID=${id} data is not found in the database. Add the data in the property "database"`
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
return data;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const states = snapshot.states.map((entry: any) => {
|
|
107
|
+
const stateId = isString(entry) ? entry : entry?.id;
|
|
108
|
+
if (!stateId) {
|
|
109
|
+
return entry;
|
|
110
|
+
}
|
|
111
|
+
const stateClass = this._resolveStateInput(stateId, databaseByIdOverride);
|
|
112
|
+
return this._createStateInstance(stateClass as StateClass);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
return { ...snapshot, states };
|
|
116
|
+
}
|
|
117
|
+
|
|
49
118
|
get statesDefense(): { rate: number; state: any }[] {
|
|
50
119
|
return (this as any).getFeature("statesDefense", "state");
|
|
51
120
|
}
|
|
@@ -94,7 +163,7 @@ export function WithStateManager<TBase extends PlayerCtor>(Base: TBase) {
|
|
|
94
163
|
throw StateLog.addFailed(stateClass);
|
|
95
164
|
}
|
|
96
165
|
//const efficiency = this.findStateEfficiency(stateClass)
|
|
97
|
-
const instance =
|
|
166
|
+
const instance = this._createStateInstance(stateClass as StateClass);
|
|
98
167
|
this.states().push(instance);
|
|
99
168
|
this.applyStates(<any>this, instance);
|
|
100
169
|
return instance;
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { Constructor, PlayerCtor } from "@rpgjs/common";
|
|
2
|
+
import { signal } from "@signe/reactive";
|
|
3
|
+
import { type } from "@signe/sync";
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* Variable Manager Mixin
|
|
@@ -25,30 +27,31 @@ import { Constructor, PlayerCtor } from "@rpgjs/common";
|
|
|
25
27
|
*/
|
|
26
28
|
export function WithVariableManager<TBase extends PlayerCtor>(Base: TBase) {
|
|
27
29
|
return class extends Base {
|
|
28
|
-
variables
|
|
30
|
+
variables = type(signal<Record<string, any>>({}) as any, 'variables', { persist: true }, this as any);
|
|
29
31
|
|
|
30
32
|
setVariable(key: string, val: any): void {
|
|
31
|
-
this.variables
|
|
33
|
+
this.variables()[key] = val;
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
getVariable<U = any>(key: string): U | undefined {
|
|
35
|
-
return this.variables
|
|
37
|
+
return this.variables()[key];
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
removeVariable(key: string): boolean {
|
|
39
|
-
|
|
41
|
+
delete this.variables()[key];
|
|
42
|
+
return true;
|
|
40
43
|
}
|
|
41
44
|
|
|
42
45
|
hasVariable(key: string): boolean {
|
|
43
|
-
return this.variables
|
|
46
|
+
return key in this.variables();
|
|
44
47
|
}
|
|
45
48
|
|
|
46
49
|
getVariableKeys(): string[] {
|
|
47
|
-
return
|
|
50
|
+
return Object.keys(this.variables());
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
clearVariables(): void {
|
|
51
|
-
this.variables.
|
|
54
|
+
this.variables.set({});
|
|
52
55
|
}
|
|
53
56
|
} as unknown as TBase;
|
|
54
57
|
}
|
package/src/RpgServer.ts
CHANGED
|
@@ -178,6 +178,14 @@ export interface RpgPlayerHooks {
|
|
|
178
178
|
*/
|
|
179
179
|
onConnected?: (player: RpgPlayer) => any
|
|
180
180
|
|
|
181
|
+
/**
|
|
182
|
+
* When the player starts the game from the lobby
|
|
183
|
+
*
|
|
184
|
+
* @prop { (player: RpgPlayer) => any } [onStart]
|
|
185
|
+
* @memberof RpgPlayerHooks
|
|
186
|
+
*/
|
|
187
|
+
onStart?: (player: RpgPlayer) => any
|
|
188
|
+
|
|
181
189
|
/**
|
|
182
190
|
* When the player presses a key on the client side
|
|
183
191
|
*
|
package/src/index.ts
CHANGED
|
@@ -10,7 +10,10 @@ export * from "./rooms/map";
|
|
|
10
10
|
export * from "./presets";
|
|
11
11
|
export * from "@signe/reactive";
|
|
12
12
|
export * from "./Gui";
|
|
13
|
-
export
|
|
13
|
+
export * from "./services/save";
|
|
14
|
+
export * from "./storage";
|
|
15
|
+
export { RpgShape, RpgModule, MAXHP, MAXSP, ATK, PDEF, SDEF, STR, AGI, INT, DEX } from "@rpgjs/common";
|
|
14
16
|
export * from "./decorators/event";
|
|
15
17
|
export * from "./decorators/map";
|
|
16
|
-
export * from "./Player/MoveManager";
|
|
18
|
+
export * from "./Player/MoveManager";
|
|
19
|
+
export * from "./presets";
|
package/src/presets/index.ts
CHANGED
|
@@ -1,14 +1,5 @@
|
|
|
1
1
|
import { random } from "@rpgjs/common"
|
|
2
|
-
|
|
3
|
-
export const MAXHP: string = 'maxHp'
|
|
4
|
-
export const MAXSP: string = 'maxSp'
|
|
5
|
-
export const ATK: string = 'atk'
|
|
6
|
-
export const PDEF: string = 'pdef'
|
|
7
|
-
export const SDEF: string = 'sdef'
|
|
8
|
-
export const STR: string = 'str'
|
|
9
|
-
export const AGI: string = 'agi'
|
|
10
|
-
export const INT: string = 'int'
|
|
11
|
-
export const DEX: string = 'dex'
|
|
2
|
+
import { DEX, AGI, ATK, PDEF, SDEF, STR, INT } from "@rpgjs/common"
|
|
12
3
|
|
|
13
4
|
export const MAXHP_CURVE = {
|
|
14
5
|
start: 741,
|
package/src/rooms/BaseRoom.ts
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import { signal } from "@signe/reactive";
|
|
2
|
+
import { inject } from "../core/inject";
|
|
3
|
+
import { context } from "../core/context";
|
|
4
|
+
import { Hooks, ModulesToken } from "@rpgjs/common";
|
|
5
|
+
import { Action } from "@signe/room";
|
|
6
|
+
import { RpgPlayer } from "../Player/Player";
|
|
7
|
+
import { resolveSaveStorageStrategy } from "../services/save";
|
|
8
|
+
import { lastValueFrom } from "rxjs";
|
|
2
9
|
|
|
3
10
|
/**
|
|
4
11
|
* Base class for rooms that need database functionality
|
|
@@ -20,6 +27,7 @@ import { signal } from "@signe/reactive";
|
|
|
20
27
|
* ```
|
|
21
28
|
*/
|
|
22
29
|
export abstract class BaseRoom {
|
|
30
|
+
|
|
23
31
|
/**
|
|
24
32
|
* Signal containing the room's database of items, classes, and other game data
|
|
25
33
|
*
|
|
@@ -38,6 +46,11 @@ export abstract class BaseRoom {
|
|
|
38
46
|
*/
|
|
39
47
|
database = signal({});
|
|
40
48
|
|
|
49
|
+
|
|
50
|
+
async onStart() {
|
|
51
|
+
await lastValueFrom(this.hooks.callHooks("server-databaseHooks-load", this))
|
|
52
|
+
}
|
|
53
|
+
|
|
41
54
|
/**
|
|
42
55
|
* Add data to the room's database
|
|
43
56
|
*
|
|
@@ -117,4 +130,103 @@ export abstract class BaseRoom {
|
|
|
117
130
|
this.database.set(database);
|
|
118
131
|
return true;
|
|
119
132
|
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Get the hooks system for this map
|
|
136
|
+
*
|
|
137
|
+
* Returns the dependency-injected Hooks instance that allows you to trigger
|
|
138
|
+
* and listen to various game events.
|
|
139
|
+
*
|
|
140
|
+
* @returns The Hooks instance for this map
|
|
141
|
+
*
|
|
142
|
+
* @example
|
|
143
|
+
* ```ts
|
|
144
|
+
* // Trigger a custom hook
|
|
145
|
+
* map.hooks.callHooks('custom-event', data).subscribe();
|
|
146
|
+
* ```
|
|
147
|
+
*/
|
|
148
|
+
get hooks() {
|
|
149
|
+
return inject<Hooks>(ModulesToken, context);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Resolve complex snapshot entries (e.g. inventory items) before load.
|
|
154
|
+
*/
|
|
155
|
+
async onSessionRestore({ userSnapshot, user }: { userSnapshot: any; user?: RpgPlayer }) {
|
|
156
|
+
if (!userSnapshot) {
|
|
157
|
+
return userSnapshot;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
let resolvedSnapshot = userSnapshot;
|
|
161
|
+
if (user && typeof (user as any).resolveItemsSnapshot === 'function') {
|
|
162
|
+
resolvedSnapshot = (user as any).resolveItemsSnapshot(resolvedSnapshot, this);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (user && typeof (user as any).resolveSkillsSnapshot === 'function') {
|
|
166
|
+
resolvedSnapshot = (user as any).resolveSkillsSnapshot(resolvedSnapshot, this);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (user && typeof (user as any).resolveStatesSnapshot === 'function') {
|
|
170
|
+
resolvedSnapshot = (user as any).resolveStatesSnapshot(resolvedSnapshot, this);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (user && typeof (user as any).resolveClassSnapshot === 'function') {
|
|
174
|
+
resolvedSnapshot = (user as any).resolveClassSnapshot(resolvedSnapshot, this);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (user && typeof (user as any).resolveEquipmentsSnapshot === 'function') {
|
|
178
|
+
resolvedSnapshot = (user as any).resolveEquipmentsSnapshot(resolvedSnapshot, this);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return resolvedSnapshot;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
@Action('save.list')
|
|
185
|
+
async listSaveSlots(player: RpgPlayer, value: { requestId: string }) {
|
|
186
|
+
const storage = resolveSaveStorageStrategy();
|
|
187
|
+
try {
|
|
188
|
+
const slots = await storage.list(player);
|
|
189
|
+
player.emit('save.list.result', { requestId: value?.requestId, slots });
|
|
190
|
+
return slots;
|
|
191
|
+
} catch (error: any) {
|
|
192
|
+
player.showNotification(error?.message || 'save.list failed');
|
|
193
|
+
return [];
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
@Action('save.save')
|
|
198
|
+
async saveSlot(player: RpgPlayer, value: { requestId: string; index: number; meta?: any }) {
|
|
199
|
+
const storage = resolveSaveStorageStrategy();
|
|
200
|
+
try {
|
|
201
|
+
if (typeof value?.index !== 'number') {
|
|
202
|
+
throw new Error('save.save requires an index');
|
|
203
|
+
}
|
|
204
|
+
const result = await player.save(value.index, value?.meta, { reason: "manual", source: "gui" });
|
|
205
|
+
if (!result) {
|
|
206
|
+
throw new Error('save.save is not allowed');
|
|
207
|
+
}
|
|
208
|
+
const slots = await storage.list(player);
|
|
209
|
+
player.emit('save.save.result', { requestId: value?.requestId, index: result.index, slots });
|
|
210
|
+
} catch (error: any) {
|
|
211
|
+
player.emit('save.error', { requestId: value?.requestId, message: error?.message || 'save.save failed' });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
@Action('save.load')
|
|
216
|
+
async loadSlot(player: RpgPlayer, value: { requestId: string; index: number }) {
|
|
217
|
+
try {
|
|
218
|
+
if (typeof value?.index !== 'number') {
|
|
219
|
+
throw new Error('save.load requires an index');
|
|
220
|
+
}
|
|
221
|
+
const result = await player.load(value.index, { reason: "load", source: "gui" }, { changeMap: true });
|
|
222
|
+
player.emit('save.load.result', {
|
|
223
|
+
requestId: value?.requestId,
|
|
224
|
+
index: value.index,
|
|
225
|
+
ok: result.ok,
|
|
226
|
+
slot: result.slot
|
|
227
|
+
});
|
|
228
|
+
} catch (error: any) {
|
|
229
|
+
player.emit('save.error', { requestId: value?.requestId, message: error?.message || 'save.load failed' });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
120
232
|
}
|
package/src/rooms/lobby.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { inject } from "@signe/di";
|
|
2
|
-
import { MockConnection, Room } from "@signe/room";
|
|
2
|
+
import { Action, MockConnection, Room } from "@signe/room";
|
|
3
3
|
import { Hooks, ModulesToken } from "@rpgjs/common";
|
|
4
4
|
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
8
|
import { BaseRoom } from "./BaseRoom";
|
|
9
|
+
import { buildSaveSlotMeta, resolveSaveStorageStrategy } from "../services/save";
|
|
10
|
+
import { lastValueFrom } from "rxjs";
|
|
9
11
|
|
|
10
12
|
@Room({
|
|
11
13
|
path: "lobby-{id}",
|
|
@@ -22,13 +24,18 @@ export class LobbyRoom extends BaseRoom {
|
|
|
22
24
|
}
|
|
23
25
|
}
|
|
24
26
|
|
|
25
|
-
onJoin(player: RpgPlayer, conn: MockConnection) {
|
|
27
|
+
async onJoin(player: RpgPlayer, conn: MockConnection) {
|
|
26
28
|
player.map = this;
|
|
27
29
|
player.context = context;
|
|
28
30
|
player.conn = conn;
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
this.hooks.callHooks("server-player-onConnected", player).subscribe();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
@Action('gui.interaction')
|
|
35
|
+
async guiInteraction(player: RpgPlayer, value: { guiId: string, name: string, data: any }) {
|
|
36
|
+
const id = value.data.id
|
|
37
|
+
if (id === 'start') {
|
|
38
|
+
this.hooks.callHooks("server-player-onStart", player).subscribe();
|
|
39
|
+
}
|
|
33
40
|
}
|
|
34
41
|
}
|
package/src/rooms/map.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { z } from "zod";
|
|
|
14
14
|
import { EntityState } from "@rpgjs/physic";
|
|
15
15
|
import { MapOptions } from "../decorators/map";
|
|
16
16
|
import { BaseRoom } from "./BaseRoom";
|
|
17
|
+
import { buildSaveSlotMeta, resolveSaveStorageStrategy } from "../services/save";
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* Interface for input controls configuration
|
|
@@ -237,6 +238,10 @@ export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
|
|
|
237
238
|
}
|
|
238
239
|
}
|
|
239
240
|
|
|
241
|
+
onStart() {
|
|
242
|
+
return BaseRoom.prototype.onStart.call(this)
|
|
243
|
+
}
|
|
244
|
+
|
|
240
245
|
/**
|
|
241
246
|
* Setup collision detection between players, events, and shapes
|
|
242
247
|
*
|
|
@@ -581,7 +586,11 @@ export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
|
|
|
581
586
|
* ```
|
|
582
587
|
*/
|
|
583
588
|
get hooks() {
|
|
584
|
-
return
|
|
589
|
+
return BaseRoom.prototype.hooks;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
async onSessionRestore(payload: { userSnapshot: any; user?: RpgPlayer }) {
|
|
593
|
+
return await BaseRoom.prototype.onSessionRestore.call(this, payload);
|
|
585
594
|
}
|
|
586
595
|
|
|
587
596
|
/**
|
|
@@ -600,8 +609,11 @@ export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
|
|
|
600
609
|
* ```
|
|
601
610
|
*/
|
|
602
611
|
@Action('gui.interaction')
|
|
603
|
-
guiInteraction(player: RpgPlayer, value) {
|
|
604
|
-
|
|
612
|
+
async guiInteraction(player: RpgPlayer, value: { guiId: string, name: string, data: any }) {
|
|
613
|
+
const gui = player.getGui(value.guiId)
|
|
614
|
+
if (gui) {
|
|
615
|
+
await gui.emit(value.name, value.data)
|
|
616
|
+
}
|
|
605
617
|
player.syncChanges();
|
|
606
618
|
}
|
|
607
619
|
|
|
@@ -699,6 +711,21 @@ export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
|
|
|
699
711
|
}
|
|
700
712
|
}
|
|
701
713
|
|
|
714
|
+
@Action('save.save')
|
|
715
|
+
async saveSlot(player: RpgPlayer, value: { requestId: string; index: number; meta?: any }) {
|
|
716
|
+
BaseRoom.prototype.saveSlot(player, value);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
@Action('save.load')
|
|
720
|
+
async loadSlot(player: RpgPlayer, value: { requestId: string; index: number }) {
|
|
721
|
+
BaseRoom.prototype.loadSlot(player, value);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
@Action('save.list')
|
|
725
|
+
async listSaveSlots(player: RpgPlayer, value: { requestId: string }) {
|
|
726
|
+
return await BaseRoom.prototype.listSaveSlots(player, value);
|
|
727
|
+
}
|
|
728
|
+
|
|
702
729
|
/**
|
|
703
730
|
* Update the map configuration and data
|
|
704
731
|
*
|
|
@@ -2056,4 +2083,4 @@ export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
|
|
|
2056
2083
|
}
|
|
2057
2084
|
}
|
|
2058
2085
|
|
|
2059
|
-
export interface RpgMap extends RoomMethods { }
|
|
2086
|
+
export interface RpgMap extends RoomMethods { }
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { SaveSlot, SaveSlotEntries, SaveSlotList, SaveSlotMeta } from "@rpgjs/common";
|
|
2
|
+
import { inject } from "../core/inject";
|
|
3
|
+
import { RpgPlayer } from "../Player/Player";
|
|
4
|
+
|
|
5
|
+
export const SaveStorageToken = "SaveStorageToken";
|
|
6
|
+
|
|
7
|
+
export type SaveSlotIndex = number | "auto";
|
|
8
|
+
|
|
9
|
+
export interface SaveRequestContext {
|
|
10
|
+
reason?: "manual" | "auto" | "load";
|
|
11
|
+
source?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface AutoSaveStrategy {
|
|
15
|
+
canSave?: (player: RpgPlayer, context?: SaveRequestContext) => boolean;
|
|
16
|
+
canLoad?: (player: RpgPlayer, context?: SaveRequestContext) => boolean;
|
|
17
|
+
shouldAutoSave?: (player: RpgPlayer, context?: SaveRequestContext) => boolean;
|
|
18
|
+
getDefaultSlot?: (player: RpgPlayer, context?: SaveRequestContext) => number | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface SaveStorageStrategy {
|
|
22
|
+
list(player: RpgPlayer): Promise<SaveSlotList>;
|
|
23
|
+
get(player: RpgPlayer, index: number): Promise<SaveSlot | null>;
|
|
24
|
+
save(player: RpgPlayer, index: number, snapshot: string, meta: SaveSlotMeta): Promise<void>;
|
|
25
|
+
delete?(player: RpgPlayer, index: number): Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type PlayerSlots = Map<string, SaveSlotEntries>;
|
|
29
|
+
|
|
30
|
+
export class InMemorySaveStorageStrategy implements SaveStorageStrategy {
|
|
31
|
+
private slotsByPlayer: PlayerSlots = new Map();
|
|
32
|
+
|
|
33
|
+
async list(player: RpgPlayer): Promise<SaveSlotList> {
|
|
34
|
+
return this.stripSnapshots(this.getSlots(player));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async get(player: RpgPlayer, index: number): Promise<SaveSlot | null> {
|
|
38
|
+
const slots = this.getSlots(player);
|
|
39
|
+
const slot = slots[index];
|
|
40
|
+
return slot ?? null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async save(player: RpgPlayer, index: number, snapshot: string, meta: SaveSlotMeta): Promise<void> {
|
|
44
|
+
const slots = this.getSlots(player);
|
|
45
|
+
const existing = slots[index];
|
|
46
|
+
slots[index] = {
|
|
47
|
+
...(existing ?? {}),
|
|
48
|
+
...meta,
|
|
49
|
+
snapshot,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async delete(player: RpgPlayer, index: number): Promise<void> {
|
|
54
|
+
const slots = this.getSlots(player);
|
|
55
|
+
slots[index] = null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private getSlots(player: RpgPlayer): SaveSlotEntries {
|
|
59
|
+
const key = player.id ?? "unknown";
|
|
60
|
+
if (!this.slotsByPlayer.has(key)) {
|
|
61
|
+
this.slotsByPlayer.set(key, []);
|
|
62
|
+
}
|
|
63
|
+
return this.slotsByPlayer.get(key)!;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private stripSnapshots(slots: SaveSlotEntries): SaveSlotList {
|
|
67
|
+
return slots.map((slot) => {
|
|
68
|
+
if (!slot) return null;
|
|
69
|
+
const { snapshot, ...meta } = slot;
|
|
70
|
+
return meta;
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let cachedSaveStorage: SaveStorageStrategy | null = null;
|
|
76
|
+
let cachedAutoSave: AutoSaveStrategy | null = null;
|
|
77
|
+
|
|
78
|
+
export const AutoSaveToken = "AutoSaveToken";
|
|
79
|
+
|
|
80
|
+
export function resolveSaveStorageStrategy(): SaveStorageStrategy {
|
|
81
|
+
if (cachedSaveStorage) return cachedSaveStorage;
|
|
82
|
+
try {
|
|
83
|
+
cachedSaveStorage = inject<SaveStorageStrategy>(SaveStorageToken);
|
|
84
|
+
} catch {
|
|
85
|
+
cachedSaveStorage = new InMemorySaveStorageStrategy();
|
|
86
|
+
}
|
|
87
|
+
return cachedSaveStorage;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function resolveAutoSaveStrategy(): AutoSaveStrategy {
|
|
91
|
+
if (cachedAutoSave) return cachedAutoSave;
|
|
92
|
+
try {
|
|
93
|
+
cachedAutoSave = inject<AutoSaveStrategy>(AutoSaveToken);
|
|
94
|
+
} catch {
|
|
95
|
+
cachedAutoSave = null;
|
|
96
|
+
}
|
|
97
|
+
cachedAutoSave ||= {
|
|
98
|
+
canSave: () => true,
|
|
99
|
+
canLoad: () => true,
|
|
100
|
+
shouldAutoSave: () => false,
|
|
101
|
+
getDefaultSlot: () => 0,
|
|
102
|
+
};
|
|
103
|
+
return cachedAutoSave;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function resolveSaveSlot(
|
|
107
|
+
slot: SaveSlotIndex | undefined,
|
|
108
|
+
policy: AutoSaveStrategy,
|
|
109
|
+
player: RpgPlayer,
|
|
110
|
+
context?: SaveRequestContext
|
|
111
|
+
): number | null {
|
|
112
|
+
if (typeof slot === "number") return slot;
|
|
113
|
+
const resolver = policy.getDefaultSlot;
|
|
114
|
+
if (!resolver) return null;
|
|
115
|
+
return resolver(player, context);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function shouldAutoSave(player: RpgPlayer, context?: SaveRequestContext): boolean {
|
|
119
|
+
const strategy = resolveAutoSaveStrategy();
|
|
120
|
+
if (!strategy.shouldAutoSave) return false;
|
|
121
|
+
return strategy.shouldAutoSave(player, context);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function buildSaveSlotMeta(player: RpgPlayer, overrides: SaveSlotMeta = {}): SaveSlotMeta {
|
|
125
|
+
const mapId = player.getCurrentMap()?.id;
|
|
126
|
+
const base: SaveSlotMeta = {
|
|
127
|
+
level: typeof player.level === "number" ? player.level : undefined,
|
|
128
|
+
exp: typeof player.exp === "number" ? player.exp : undefined,
|
|
129
|
+
map: typeof mapId === "string" ? mapId : undefined,
|
|
130
|
+
date: new Date().toISOString(),
|
|
131
|
+
};
|
|
132
|
+
return { ...base, ...overrides };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function provideSaveStorage(strategy: SaveStorageStrategy) {
|
|
136
|
+
return {
|
|
137
|
+
provide: SaveStorageToken,
|
|
138
|
+
useValue: strategy,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function provideAutoSave(strategy: AutoSaveStrategy) {
|
|
143
|
+
return {
|
|
144
|
+
provide: AutoSaveToken,
|
|
145
|
+
useValue: strategy,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./localStorage";
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { SaveSlot, SaveSlotEntries, SaveSlotList, SaveSlotMeta } from "@rpgjs/common";
|
|
2
|
+
import type { SaveStorageStrategy } from "../services/save";
|
|
3
|
+
import { RpgPlayer } from "../Player/Player";
|
|
4
|
+
|
|
5
|
+
export interface LocalStorageSaveStorageOptions {
|
|
6
|
+
key?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Save storage strategy backed by browser localStorage.
|
|
11
|
+
*
|
|
12
|
+
* Intended for standalone mode where the server runs in the browser
|
|
13
|
+
* and localStorage is available.
|
|
14
|
+
*/
|
|
15
|
+
export class LocalStorageSaveStorageStrategy implements SaveStorageStrategy {
|
|
16
|
+
private key: string;
|
|
17
|
+
|
|
18
|
+
constructor(options: LocalStorageSaveStorageOptions = {}) {
|
|
19
|
+
this.key = options.key ?? "rpgjs-save-slots";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async list(_player: RpgPlayer): Promise<SaveSlotList> {
|
|
23
|
+
return this.stripSnapshots(this.readSlots());
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async get(_player: RpgPlayer, index: number): Promise<SaveSlot | null> {
|
|
27
|
+
const slots = this.readSlots();
|
|
28
|
+
return slots[index] ?? null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async save(_player: RpgPlayer, index: number, snapshot: string, meta: SaveSlotMeta): Promise<void> {
|
|
32
|
+
const slots = this.readSlots();
|
|
33
|
+
const existing = slots[index];
|
|
34
|
+
slots[index] = {
|
|
35
|
+
...(existing ?? {}),
|
|
36
|
+
...meta,
|
|
37
|
+
snapshot,
|
|
38
|
+
};
|
|
39
|
+
this.writeSlots(slots);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async delete(_player: RpgPlayer, index: number): Promise<void> {
|
|
43
|
+
const slots = this.readSlots();
|
|
44
|
+
slots[index] = null;
|
|
45
|
+
this.writeSlots(slots);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private readSlots(): SaveSlotEntries {
|
|
49
|
+
if (typeof localStorage === "undefined") {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
const raw = localStorage.getItem(this.key);
|
|
53
|
+
if (!raw) return [];
|
|
54
|
+
try {
|
|
55
|
+
const parsed = JSON.parse(raw);
|
|
56
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
57
|
+
} catch {
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private writeSlots(slots: SaveSlotEntries) {
|
|
63
|
+
if (typeof localStorage === "undefined") {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
localStorage.setItem(this.key, JSON.stringify(slots));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private stripSnapshots(slots: SaveSlotEntries): SaveSlotList {
|
|
70
|
+
return slots.map((slot) => {
|
|
71
|
+
if (!slot) return null;
|
|
72
|
+
const { snapshot, ...meta } = slot;
|
|
73
|
+
return meta;
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|