@rpgjs/client 5.0.0-beta.24 → 5.0.0-beta.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.
- package/CHANGELOG.md +64 -0
- package/dist/Game/Map.d.ts +1 -0
- package/dist/Game/Map.js +5 -0
- package/dist/Game/Map.js.map +1 -1
- package/dist/Gui/Gui.js +5 -0
- package/dist/Gui/Gui.js.map +1 -1
- package/dist/RpgClient.d.ts +2 -1
- package/dist/RpgClientEngine.d.ts +76 -0
- package/dist/RpgClientEngine.js +59 -4
- package/dist/RpgClientEngine.js.map +1 -1
- package/dist/components/gui/dialogbox/index.ce.js +23 -2
- package/dist/components/gui/dialogbox/index.ce.js.map +1 -1
- package/dist/components/gui/index.d.ts +3 -1
- package/dist/components/gui/index.js +2 -0
- package/dist/components/gui/input-field.ce.js +123 -0
- package/dist/components/gui/input-field.ce.js.map +1 -0
- package/dist/components/gui/input.ce.js +40 -0
- package/dist/components/gui/input.ce.js.map +1 -0
- package/dist/components/scenes/draw-map.ce.js +5 -2
- package/dist/components/scenes/draw-map.ce.js.map +1 -1
- package/dist/components/scenes/weather-lifecycle-compat.d.ts +0 -0
- package/dist/components/scenes/weather-lifecycle-compat.js +11 -0
- package/dist/components/scenes/weather-lifecycle-compat.js.map +1 -0
- package/dist/components/scenes/weather-tick-lifecycle.d.ts +17 -0
- package/dist/components/scenes/weather-tick-lifecycle.js +44 -0
- package/dist/components/scenes/weather-tick-lifecycle.js.map +1 -0
- package/dist/components/scenes/weather-tick-lifecycle.spec.d.ts +1 -0
- package/dist/i18n.d.ts +12 -0
- package/dist/i18n.js +12 -0
- package/dist/i18n.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +15 -11
- package/dist/services/loadMap.d.ts +10 -2
- package/dist/services/loadMap.js +2 -1
- package/dist/services/loadMap.js.map +1 -1
- package/dist/services/mapStreaming.d.ts +85 -0
- package/dist/services/mapStreaming.js +210 -0
- package/dist/services/mapStreaming.js.map +1 -0
- package/dist/services/mapStreaming.spec.d.ts +1 -0
- package/dist/services/mmorpg.d.ts +6 -0
- package/dist/services/mmorpg.js +2 -2
- package/dist/services/mmorpg.js.map +1 -1
- package/package.json +4 -4
- package/src/Game/Map.ts +7 -0
- package/src/Gui/Gui.spec.ts +5 -0
- package/src/Gui/Gui.ts +5 -1
- package/src/RpgClient.ts +2 -1
- package/src/RpgClientEngine.ts +147 -6
- package/src/components/gui/dialogbox/index.ce +26 -1
- package/src/components/gui/index.ts +5 -1
- package/src/components/gui/input-field.ce +106 -0
- package/src/components/gui/input.ce +26 -0
- package/src/components/scenes/draw-map.ce +4 -1
- package/src/components/scenes/weather-lifecycle-compat.ts +8 -0
- package/src/components/scenes/weather-tick-lifecycle.spec.ts +60 -0
- package/src/components/scenes/weather-tick-lifecycle.ts +69 -0
- package/src/i18n.spec.ts +2 -1
- package/src/i18n.ts +12 -0
- package/src/index.ts +2 -0
- package/src/services/loadMap.ts +9 -2
- package/src/services/mapStreaming.spec.ts +240 -0
- package/src/services/mapStreaming.ts +275 -0
- package/src/services/mmorpg.ts +15 -2
- package/vite.config.ts +5 -2
package/src/Gui/Gui.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Context, inject } from "@signe/di";
|
|
2
2
|
import { signal, Signal, WritableSignal } from "canvasengine";
|
|
3
3
|
import { AbstractWebsocket, WebSocketToken } from "../services/AbstractSocket";
|
|
4
|
-
import { DialogboxComponent, ShopComponent, SaveLoadComponent, MainMenuComponent, NotificationComponent, TitleScreenComponent, GameoverComponent } from "../components/gui";
|
|
4
|
+
import { DialogboxComponent, ShopComponent, SaveLoadComponent, MainMenuComponent, NotificationComponent, TitleScreenComponent, GameoverComponent, InputComponent } from "../components/gui";
|
|
5
5
|
import { combineLatest, Subscription } from "rxjs";
|
|
6
6
|
import { PrebuiltGui } from "@rpgjs/common";
|
|
7
7
|
|
|
@@ -176,6 +176,10 @@ export class RpgGui {
|
|
|
176
176
|
name: PrebuiltGui.Gameover,
|
|
177
177
|
component: GameoverComponent,
|
|
178
178
|
});
|
|
179
|
+
this.add({
|
|
180
|
+
name: PrebuiltGui.Input,
|
|
181
|
+
component: InputComponent,
|
|
182
|
+
});
|
|
179
183
|
|
|
180
184
|
this.registerOptimisticReducer(PrebuiltGui.MainMenu, mainMenuOptimisticReducer);
|
|
181
185
|
}
|
package/src/RpgClient.ts
CHANGED
|
@@ -259,7 +259,8 @@ export interface RpgSceneHooks<Scene> {
|
|
|
259
259
|
onRemoveSprite?: (scene: Scene, sprite: RpgComponent) => any
|
|
260
260
|
|
|
261
261
|
/**
|
|
262
|
-
* Before the scene is loaded
|
|
262
|
+
* Before the scene is loaded. Async hooks are awaited while the previous
|
|
263
|
+
* scene is still mounted, allowing transition UI to cover it before teardown.
|
|
263
264
|
*
|
|
264
265
|
* @prop { (scene: RpgScene) => any } [onBeforeLoading]
|
|
265
266
|
* @memberof RpgSceneHooks
|
package/src/RpgClientEngine.ts
CHANGED
|
@@ -170,7 +170,40 @@ export class RpgClientEngine<T = any> {
|
|
|
170
170
|
componentAnimations: any[] = [];
|
|
171
171
|
clientVisuals = new ClientVisualRegistry();
|
|
172
172
|
projectiles: ProjectileManager;
|
|
173
|
+
/**
|
|
174
|
+
* Read the latest pointer position tracked by the client canvas. World
|
|
175
|
+
* coordinates are suitable for action payloads and map interactions.
|
|
176
|
+
*
|
|
177
|
+
* @title pointer
|
|
178
|
+
* @prop pointer: ClientPointerContext
|
|
179
|
+
* @memberof RpgClientEngine
|
|
180
|
+
* @example
|
|
181
|
+
* ```ts
|
|
182
|
+
* const target = engine.pointer.world()
|
|
183
|
+
* if (target) engine.processAction('projectile:shoot', { target })
|
|
184
|
+
* ```
|
|
185
|
+
*/
|
|
173
186
|
pointer: ClientPointerContext = createClientPointerContext();
|
|
187
|
+
/**
|
|
188
|
+
* Register client-only pointer behaviors for map sprites. Interactions remain
|
|
189
|
+
* local unless a behavior explicitly sends an action to the server.
|
|
190
|
+
*
|
|
191
|
+
* See the [client interactions guide](../../guide/interactions.md) for hover,
|
|
192
|
+
* selection, hit testing, drag-and-drop, overlays, and network rules.
|
|
193
|
+
*
|
|
194
|
+
* @title interactions
|
|
195
|
+
* @prop interactions: RpgClientInteractions
|
|
196
|
+
* @memberof RpgClientEngine
|
|
197
|
+
* @example
|
|
198
|
+
* ```ts
|
|
199
|
+
* engine.interactions.use('Guard', {
|
|
200
|
+
* cursor: 'pointer',
|
|
201
|
+
* click(ctx) {
|
|
202
|
+
* ctx.action('guard:talk', { eventId: ctx.target.id })
|
|
203
|
+
* }
|
|
204
|
+
* })
|
|
205
|
+
* ```
|
|
206
|
+
*/
|
|
174
207
|
interactions: RpgClientInteractions = new RpgClientInteractions(this);
|
|
175
208
|
private spritesheetResolver?: (id: string | number) => any | Promise<any>;
|
|
176
209
|
private soundResolver?: (id: string) => any | Promise<any>;
|
|
@@ -218,6 +251,8 @@ export class RpgClientEngine<T = any> {
|
|
|
218
251
|
private pingInterval: any = null;
|
|
219
252
|
private readonly PING_INTERVAL_MS = 5000; // Send ping every 5 seconds
|
|
220
253
|
private lastInputTime = 0;
|
|
254
|
+
private latestDirectionalInput?: RpgMovementInput;
|
|
255
|
+
private pendingMapTransferInput?: RpgMovementInput;
|
|
221
256
|
private readonly MOVE_PATH_RESEND_INTERVAL_MS = 120;
|
|
222
257
|
private readonly MAX_MOVE_TRAJECTORY_POINTS = 240;
|
|
223
258
|
private lastMovePathSentAt = 0;
|
|
@@ -231,6 +266,7 @@ export class RpgClientEngine<T = any> {
|
|
|
231
266
|
private sceneResetQueued = false;
|
|
232
267
|
private mapTransitionInProgress = false;
|
|
233
268
|
private currentMapRoomId?: string;
|
|
269
|
+
private activeMapStreamController?: { attach(map: RpgClientMap): void; detach(): void };
|
|
234
270
|
private socketListenersInitialized = false;
|
|
235
271
|
private clientReadyForMapChanges = false;
|
|
236
272
|
private pendingMapChanges: any[] = [];
|
|
@@ -383,6 +419,7 @@ export class RpgClientEngine<T = any> {
|
|
|
383
419
|
this.sceneMap.loadPhysic();
|
|
384
420
|
this.resolveSceneMapComponent();
|
|
385
421
|
|
|
422
|
+
this.loadMapService.initialize?.();
|
|
386
423
|
const saveClient = inject(SaveClientService);
|
|
387
424
|
saveClient.initialize();
|
|
388
425
|
this.initListeners();
|
|
@@ -888,11 +925,23 @@ export class RpgClientEngine<T = any> {
|
|
|
888
925
|
})
|
|
889
926
|
}
|
|
890
927
|
|
|
891
|
-
private beginMapTransfer(
|
|
928
|
+
private beginMapTransfer(
|
|
929
|
+
nextMapId?: string,
|
|
930
|
+
continueMovement = false,
|
|
931
|
+
) {
|
|
932
|
+
this.pendingMapTransferInput = continueMovement
|
|
933
|
+
? this.latestDirectionalInput
|
|
934
|
+
: undefined;
|
|
892
935
|
this.mapTransitionInProgress = true;
|
|
893
936
|
this.currentMapRoomId = nextMapId;
|
|
894
937
|
this.sceneResetQueued = false;
|
|
895
|
-
this.
|
|
938
|
+
this.clearMapTransferPredictionStates();
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
private resetSceneForMapTransfer(nextMapId?: string) {
|
|
942
|
+
// The before-loading hook has now covered the previous scene. Unmount it
|
|
943
|
+
// before reconnecting so stale map content cannot appear behind the loader.
|
|
944
|
+
this.sceneMap.data.set(null);
|
|
896
945
|
this.sceneMap.weatherState.set(null);
|
|
897
946
|
this.sceneMap.lightingState.set(null);
|
|
898
947
|
this.sceneMap.clearLightSpots();
|
|
@@ -936,7 +985,7 @@ export class RpgClientEngine<T = any> {
|
|
|
936
985
|
|
|
937
986
|
private handleChangeMap(data: any) {
|
|
938
987
|
const nextMapId = typeof data?.mapId === "string" ? data.mapId : undefined;
|
|
939
|
-
this.beginMapTransfer(nextMapId);
|
|
988
|
+
this.beginMapTransfer(nextMapId, data?.continueMovement === true);
|
|
940
989
|
const transferToken = typeof data?.transferToken === "string" ? data.transferToken : undefined;
|
|
941
990
|
this.loadScene(data.mapId, transferToken);
|
|
942
991
|
}
|
|
@@ -1067,10 +1116,25 @@ export class RpgClientEngine<T = any> {
|
|
|
1067
1116
|
}
|
|
1068
1117
|
|
|
1069
1118
|
private async loadScene(mapId: string, transferToken?: string) {
|
|
1119
|
+
// Keep the previous scene mounted while async before-loading hooks install
|
|
1120
|
+
// and animate their transition UI. The hook contract is awaited, allowing
|
|
1121
|
+
// modules to report when the old scene is fully covered.
|
|
1070
1122
|
await lastValueFrom(this.hooks.callHooks("client-sceneMap-onBeforeLoading", this.sceneMap));
|
|
1071
1123
|
|
|
1072
|
-
|
|
1073
|
-
this.
|
|
1124
|
+
this.activeMapStreamController?.detach();
|
|
1125
|
+
this.activeMapStreamController = undefined;
|
|
1126
|
+
if (this.mapTransitionInProgress) {
|
|
1127
|
+
this.resetSceneForMapTransfer(mapId);
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
// A session-transferred player keeps the last acknowledged input frame on
|
|
1131
|
+
// the server. Preserve the client's monotonic frame sequence so movement
|
|
1132
|
+
// sent in the destination room is not discarded as stale.
|
|
1133
|
+
if (this.mapTransitionInProgress) {
|
|
1134
|
+
this.clearMapTransferPredictionStates();
|
|
1135
|
+
} else {
|
|
1136
|
+
this.clearClientPredictionStates();
|
|
1137
|
+
}
|
|
1074
1138
|
|
|
1075
1139
|
// Reset all conditions for new map loading
|
|
1076
1140
|
this.mapLoadCompleted$.next(false);
|
|
@@ -1095,6 +1159,7 @@ export class RpgClientEngine<T = any> {
|
|
|
1095
1159
|
}
|
|
1096
1160
|
catch (error) {
|
|
1097
1161
|
this.mapTransitionInProgress = false;
|
|
1162
|
+
this.pendingMapTransferInput = undefined;
|
|
1098
1163
|
this.stopPingPong();
|
|
1099
1164
|
await this.callConnectError(error);
|
|
1100
1165
|
throw error;
|
|
@@ -1130,6 +1195,33 @@ export class RpgClientEngine<T = any> {
|
|
|
1130
1195
|
this.mapTransitionInProgress = false;
|
|
1131
1196
|
this.sceneMap.configureClientPrediction(this.predictionEnabled);
|
|
1132
1197
|
this.sceneMap.loadPhysic()
|
|
1198
|
+
if (res?.streamController) {
|
|
1199
|
+
const controller = res.streamController;
|
|
1200
|
+
this.activeMapStreamController = controller;
|
|
1201
|
+
controller.attach(this.sceneMap);
|
|
1202
|
+
}
|
|
1203
|
+
const transferInput = this.pendingMapTransferInput;
|
|
1204
|
+
this.pendingMapTransferInput = undefined;
|
|
1205
|
+
if (transferInput !== undefined) {
|
|
1206
|
+
void this.resumeMapTransferMovement(transferInput, mapId);
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
private async resumeMapTransferMovement(
|
|
1211
|
+
input: RpgMovementInput,
|
|
1212
|
+
mapId: string,
|
|
1213
|
+
): Promise<void> {
|
|
1214
|
+
const repeatCount = 4;
|
|
1215
|
+
const repeatIntervalMs = 50;
|
|
1216
|
+
for (let index = 0; index < repeatCount; index += 1) {
|
|
1217
|
+
if (this.mapTransitionInProgress || this.currentMapRoomId !== mapId) return;
|
|
1218
|
+
await this.processInput({ input });
|
|
1219
|
+
if (index < repeatCount - 1) {
|
|
1220
|
+
await new Promise<void>((resolve) =>
|
|
1221
|
+
setTimeout(resolve, repeatIntervalMs),
|
|
1222
|
+
);
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1133
1225
|
}
|
|
1134
1226
|
|
|
1135
1227
|
addSpriteSheet<T = any>(spritesheetClass: any, id?: string): any {
|
|
@@ -1860,6 +1952,9 @@ export class RpgClientEngine<T = any> {
|
|
|
1860
1952
|
? normalizeDashInput(input, currentPlayer?.direction?.())
|
|
1861
1953
|
: input;
|
|
1862
1954
|
if (!movementInput) return;
|
|
1955
|
+
if (!isDashInput(movementInput)) {
|
|
1956
|
+
this.latestDirectionalInput = movementInput;
|
|
1957
|
+
}
|
|
1863
1958
|
if (isDashInput(movementInput)) {
|
|
1864
1959
|
const cooldown = movementInput.cooldown ?? DEFAULT_DASH_COOLDOWN_MS;
|
|
1865
1960
|
if (timestamp < this.dashLockedUntil) return;
|
|
@@ -1897,7 +1992,26 @@ export class RpgClientEngine<T = any> {
|
|
|
1897
1992
|
: Date.now();
|
|
1898
1993
|
}
|
|
1899
1994
|
|
|
1900
|
-
|
|
1995
|
+
/**
|
|
1996
|
+
* Start a predicted dash for the current player and send it through the
|
|
1997
|
+
* authoritative movement channel.
|
|
1998
|
+
*
|
|
1999
|
+
* @title processDash
|
|
2000
|
+
* @method processDash(input?: Partial<RpgDashInput>): Promise<void>
|
|
2001
|
+
* @param input - Optional direction, speed, duration, and cooldown overrides.
|
|
2002
|
+
* @returns A promise resolved after the dash input has been processed locally.
|
|
2003
|
+
* @memberof RpgClientEngine
|
|
2004
|
+
* @example
|
|
2005
|
+
* ```ts
|
|
2006
|
+
* await engine.processDash({
|
|
2007
|
+
* direction: { x: 1, y: 0 },
|
|
2008
|
+
* additionalSpeed: 10,
|
|
2009
|
+
* duration: 220,
|
|
2010
|
+
* cooldown: 600,
|
|
2011
|
+
* })
|
|
2012
|
+
* ```
|
|
2013
|
+
*/
|
|
2014
|
+
async processDash(input: Partial<RpgDashInput> = {}): Promise<void> {
|
|
1901
2015
|
const currentPlayer = this.sceneMap.getCurrentPlayer() as any;
|
|
1902
2016
|
const fallbackDirection =
|
|
1903
2017
|
typeof currentPlayer?.direction === "function"
|
|
@@ -1908,6 +2022,24 @@ export class RpgClientEngine<T = any> {
|
|
|
1908
2022
|
await this.processInput({ input: dashInput });
|
|
1909
2023
|
}
|
|
1910
2024
|
|
|
2025
|
+
/**
|
|
2026
|
+
* Send an action intent to the authoritative server. Client-provided data
|
|
2027
|
+
* must be validated by the receiving player input handler or action.
|
|
2028
|
+
*
|
|
2029
|
+
* @title processAction
|
|
2030
|
+
* @method processAction(action: RpgActionName | RpgActionInput, data?: any): void
|
|
2031
|
+
* @param action - Action name/control value, or a normalized action object.
|
|
2032
|
+
* @param data - Optional serializable context sent with an action name.
|
|
2033
|
+
* @returns Nothing.
|
|
2034
|
+
* @memberof RpgClientEngine
|
|
2035
|
+
* @example
|
|
2036
|
+
* ```ts
|
|
2037
|
+
* engine.processAction('projectile:shoot', {
|
|
2038
|
+
* target: engine.pointer.world(),
|
|
2039
|
+
* source: 'map-click',
|
|
2040
|
+
* })
|
|
2041
|
+
* ```
|
|
2042
|
+
*/
|
|
1911
2043
|
processAction(action: RpgActionName, data?: any): void;
|
|
1912
2044
|
processAction(action: RpgActionInput): void;
|
|
1913
2045
|
processAction(action: RpgActionName | RpgActionInput, data?: any): void {
|
|
@@ -2298,6 +2430,15 @@ export class RpgClientEngine<T = any> {
|
|
|
2298
2430
|
this.lastMovePathSentFrame = 0;
|
|
2299
2431
|
}
|
|
2300
2432
|
|
|
2433
|
+
private clearMapTransferPredictionStates(): void {
|
|
2434
|
+
this.prediction?.clearPendingInputs();
|
|
2435
|
+
this.frameOffset = 0;
|
|
2436
|
+
this.pendingPredictionFrames = [];
|
|
2437
|
+
this.lastClientPhysicsStepAt = 0;
|
|
2438
|
+
this.lastMovePathSentAt = 0;
|
|
2439
|
+
this.lastMovePathSentFrame = this.inputFrameCounter;
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2301
2442
|
/**
|
|
2302
2443
|
* Stop local movement immediately and discard pending predicted movement.
|
|
2303
2444
|
*
|
|
@@ -31,6 +31,15 @@
|
|
|
31
31
|
</div>
|
|
32
32
|
</Navigation>
|
|
33
33
|
}
|
|
34
|
+
@if (hasInput()) {
|
|
35
|
+
<InputField
|
|
36
|
+
data={dialogInput}
|
|
37
|
+
layout="dialog"
|
|
38
|
+
showMessage={false}
|
|
39
|
+
submit={submitInput}
|
|
40
|
+
cancel={cancelInput}
|
|
41
|
+
/>
|
|
42
|
+
}
|
|
34
43
|
</div>
|
|
35
44
|
@if (hasFace()) {
|
|
36
45
|
<div class="rpg-ui-dialog-face">
|
|
@@ -57,6 +66,7 @@
|
|
|
57
66
|
import { RpgClientEngine } from "../../../RpgClientEngine";
|
|
58
67
|
import { delay } from "@rpgjs/common";
|
|
59
68
|
import { getKeyboardControlBind } from "../../../services/actionInput";
|
|
69
|
+
import InputField from "../input-field.ce";
|
|
60
70
|
|
|
61
71
|
const engine = inject(RpgClientEngine);
|
|
62
72
|
const currentPlayer = engine.scene.currentPlayer
|
|
@@ -82,6 +92,8 @@
|
|
|
82
92
|
const position = computed(() => dialogData().position);
|
|
83
93
|
const typewriterEffect = computed(() => dialogData().typewriterEffect);
|
|
84
94
|
const fullWidth = computed(() => dialogData().fullWidth || false);
|
|
95
|
+
const dialogInput = computed(() => dialogData().input || null);
|
|
96
|
+
const hasInput = computed(() => !!dialogInput());
|
|
85
97
|
|
|
86
98
|
const resolveProp = (value) => typeof value === "function" ? value() : value;
|
|
87
99
|
const normalizeOpenId = (value) => {
|
|
@@ -149,7 +161,7 @@
|
|
|
149
161
|
|
|
150
162
|
|
|
151
163
|
const hasChoices = computed(() => dialogChoices().length > 0);
|
|
152
|
-
const showIndicator = computed(() => !hasChoices() && !isTyping());
|
|
164
|
+
const showIndicator = computed(() => !hasChoices() && !hasInput() && !isTyping());
|
|
153
165
|
const nav = createTabindexNavigator(selectedItem, { count: () => dialogChoices().length }, 'wrap');
|
|
154
166
|
|
|
155
167
|
function selectChoice(index) {
|
|
@@ -168,6 +180,10 @@
|
|
|
168
180
|
};
|
|
169
181
|
|
|
170
182
|
const closeDialog = () => {
|
|
183
|
+
if (hasInput()) {
|
|
184
|
+
onInteraction?.("cancel");
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
171
187
|
_onFinish();
|
|
172
188
|
};
|
|
173
189
|
|
|
@@ -180,6 +196,14 @@
|
|
|
180
196
|
_onFinish(index);
|
|
181
197
|
};
|
|
182
198
|
|
|
199
|
+
const submitInput = (payload) => {
|
|
200
|
+
onInteraction?.("submit", payload);
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const cancelInput = () => {
|
|
204
|
+
onInteraction?.("cancel");
|
|
205
|
+
};
|
|
206
|
+
|
|
183
207
|
const canAcceptAction = () => Date.now() - openedAt >= ACTION_OPEN_GUARD_MS;
|
|
184
208
|
|
|
185
209
|
const controls = signal({
|
|
@@ -231,6 +255,7 @@
|
|
|
231
255
|
finishTyping();
|
|
232
256
|
return;
|
|
233
257
|
}
|
|
258
|
+
if (hasInput()) return;
|
|
234
259
|
if (hasChoices()) return;
|
|
235
260
|
_onFinish();
|
|
236
261
|
}
|
|
@@ -12,6 +12,8 @@ import ExitMenuComponent from "./menu/exit-menu.ce";
|
|
|
12
12
|
import NotificationComponent from "./notification/notification.ce";
|
|
13
13
|
import TitleScreenComponent from "./title-screen.ce";
|
|
14
14
|
import GameoverComponent from "./gameover.ce";
|
|
15
|
+
import InputComponent from "./input.ce";
|
|
16
|
+
import InputFieldComponent from "./input-field.ce";
|
|
15
17
|
|
|
16
18
|
export {
|
|
17
19
|
DialogboxComponent,
|
|
@@ -27,5 +29,7 @@ export {
|
|
|
27
29
|
ExitMenuComponent,
|
|
28
30
|
NotificationComponent,
|
|
29
31
|
TitleScreenComponent,
|
|
30
|
-
GameoverComponent
|
|
32
|
+
GameoverComponent,
|
|
33
|
+
InputComponent,
|
|
34
|
+
InputFieldComponent
|
|
31
35
|
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
<form
|
|
2
|
+
class="rpg-ui-input-form"
|
|
3
|
+
data-layout={layout()}
|
|
4
|
+
submit={submitForm}
|
|
5
|
+
keydown={handleKeydown}
|
|
6
|
+
>
|
|
7
|
+
@if (showMessage()) {
|
|
8
|
+
<label class="rpg-ui-input-message" for={fieldId()}>{formData().message}</label>
|
|
9
|
+
}
|
|
10
|
+
@if (formData().control === "textarea") {
|
|
11
|
+
<textarea
|
|
12
|
+
id={fieldId()}
|
|
13
|
+
class="rpg-ui-input rpg-ui-input-dialog-field"
|
|
14
|
+
name="value"
|
|
15
|
+
value={value}
|
|
16
|
+
placeholder={formData().placeholder || ""}
|
|
17
|
+
rows={formData().rows || 4}
|
|
18
|
+
minlength={formData().minLength}
|
|
19
|
+
maxlength={formData().maxLength}
|
|
20
|
+
aria-required={formData().required ? "true" : "false"}
|
|
21
|
+
></textarea>
|
|
22
|
+
}
|
|
23
|
+
@else {
|
|
24
|
+
<input
|
|
25
|
+
id={fieldId()}
|
|
26
|
+
class="rpg-ui-input rpg-ui-input-dialog-field"
|
|
27
|
+
name="value"
|
|
28
|
+
type={formData().type || "text"}
|
|
29
|
+
value={value}
|
|
30
|
+
placeholder={formData().placeholder || ""}
|
|
31
|
+
min={formData().min}
|
|
32
|
+
max={formData().max}
|
|
33
|
+
step={formData().step}
|
|
34
|
+
minlength={formData().minLength}
|
|
35
|
+
maxlength={formData().maxLength}
|
|
36
|
+
aria-required={formData().required ? "true" : "false"}
|
|
37
|
+
/>
|
|
38
|
+
}
|
|
39
|
+
@if (errorText()) {
|
|
40
|
+
<div class="rpg-ui-input-error" role="alert">{errorText()}</div>
|
|
41
|
+
}
|
|
42
|
+
<div class="rpg-ui-input-actions">
|
|
43
|
+
@if (showCancelButton()) {
|
|
44
|
+
<button class="rpg-ui-btn rpg-ui-input-button rpg-ui-input-button-cancel" type="button" click={cancelForm}>
|
|
45
|
+
{formData().cancelText || t("rpg.input.cancel")}
|
|
46
|
+
</button>
|
|
47
|
+
}
|
|
48
|
+
<button
|
|
49
|
+
class="rpg-ui-btn rpg-ui-input-button rpg-ui-input-button-confirm"
|
|
50
|
+
data-variant="primary"
|
|
51
|
+
type="submit"
|
|
52
|
+
>
|
|
53
|
+
{formData().confirmText || t("rpg.input.confirm")}
|
|
54
|
+
</button>
|
|
55
|
+
</div>
|
|
56
|
+
</form>
|
|
57
|
+
|
|
58
|
+
<script>
|
|
59
|
+
import { computed, effect, signal } from "canvasengine";
|
|
60
|
+
import { inject } from "../../core/inject";
|
|
61
|
+
import { RpgClientEngine } from "../../RpgClientEngine";
|
|
62
|
+
|
|
63
|
+
const engine = inject(RpgClientEngine);
|
|
64
|
+
const { t } = engine.i18n();
|
|
65
|
+
const { data, layout, showMessage } = defineProps({
|
|
66
|
+
layout: "standalone",
|
|
67
|
+
showMessage: true,
|
|
68
|
+
});
|
|
69
|
+
const { submit, cancel } = defineEmits();
|
|
70
|
+
const formData = computed(() => data() || {});
|
|
71
|
+
const value = signal(String(formData().defaultValue ?? ""));
|
|
72
|
+
const fieldId = computed(() => `rpg-input-value-${layout()}`);
|
|
73
|
+
const errorText = computed(() => formData().errorKey
|
|
74
|
+
? t(formData().errorKey, formData().errorParams || {})
|
|
75
|
+
: ""
|
|
76
|
+
);
|
|
77
|
+
const showCancelButton = computed(() => formData().cancelButton !== false);
|
|
78
|
+
let focused = false;
|
|
79
|
+
|
|
80
|
+
function submitForm(event, collectedData) {
|
|
81
|
+
const submittedValue = collectedData && Object.prototype.hasOwnProperty.call(collectedData, "value")
|
|
82
|
+
? collectedData.value
|
|
83
|
+
: event?.currentTarget?.elements?.value?.value ?? value();
|
|
84
|
+
value.set(String(submittedValue ?? ""));
|
|
85
|
+
submit({ value: submittedValue ?? "" });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function cancelForm() {
|
|
89
|
+
cancel();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function handleKeydown(event) {
|
|
93
|
+
if (event?.key !== "Escape") return;
|
|
94
|
+
event.preventDefault?.();
|
|
95
|
+
event.stopPropagation?.();
|
|
96
|
+
cancelForm();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
effect(() => {
|
|
100
|
+
if (focused) return;
|
|
101
|
+
focused = true;
|
|
102
|
+
setTimeout(() => {
|
|
103
|
+
document.getElementById(fieldId())?.focus?.();
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
</script>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<DOMContainer width="100%" height="100%">
|
|
2
|
+
<div class="rpg-ui-input-layer">
|
|
3
|
+
<div class="rpg-ui-input-dialog rpg-ui-window rpg-anim-fade-in">
|
|
4
|
+
@if (title()) {
|
|
5
|
+
<div class="rpg-ui-window-title">{title()}</div>
|
|
6
|
+
}
|
|
7
|
+
<InputField data={data} layout="standalone" submit={submit} cancel={cancel} />
|
|
8
|
+
</div>
|
|
9
|
+
</div>
|
|
10
|
+
</DOMContainer>
|
|
11
|
+
|
|
12
|
+
<script>
|
|
13
|
+
import { computed } from "canvasengine";
|
|
14
|
+
import InputField from "./input-field.ce";
|
|
15
|
+
|
|
16
|
+
const { data, onInteraction } = defineProps();
|
|
17
|
+
const title = computed(() => data().title || "");
|
|
18
|
+
|
|
19
|
+
function submit(payload) {
|
|
20
|
+
onInteraction?.("submit", payload);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function cancel() {
|
|
24
|
+
onInteraction?.("cancel");
|
|
25
|
+
}
|
|
26
|
+
</script>
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
<Container>
|
|
11
11
|
@if (map() && sceneComponent()) {
|
|
12
|
-
<sceneComponent() data={map()
|
|
12
|
+
<sceneComponent() data={map()?.data ?? emptySceneData} params={map()?.params ?? emptySceneParams} />
|
|
13
13
|
}
|
|
14
14
|
</Container>
|
|
15
15
|
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
|
|
39
39
|
<script>
|
|
40
40
|
import { computed, effect } from 'canvasengine'
|
|
41
|
+
import './weather-lifecycle-compat'
|
|
41
42
|
import { inject } from "../../core/inject";
|
|
42
43
|
import { RpgClientEngine } from "../../RpgClientEngine";
|
|
43
44
|
import { Weather } from '@canvasengine/presets'
|
|
@@ -47,6 +48,8 @@
|
|
|
47
48
|
const componentAnimations = engine.componentAnimations
|
|
48
49
|
const projectiles = engine.projectiles.current
|
|
49
50
|
const map = engine.sceneMap?.data
|
|
51
|
+
const emptySceneData = { params: {} }
|
|
52
|
+
const emptySceneParams = {}
|
|
50
53
|
const sceneComponent = computed(() => map()?.component)
|
|
51
54
|
const weather = engine.sceneMap.weather
|
|
52
55
|
const backgroundMusic = computed(() => {
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import "@canvasengine/presets";
|
|
2
|
+
import { createComponent } from "canvasengine";
|
|
3
|
+
import { patchWeatherTickLifecycle } from "./weather-tick-lifecycle";
|
|
4
|
+
|
|
5
|
+
for (const tag of ["RainTextureLayer", "RainImpactLayer"]) {
|
|
6
|
+
const probe = createComponent(tag);
|
|
7
|
+
patchWeatherTickLifecycle(Object.getPrototypeOf(probe.componentInstance));
|
|
8
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { patchWeatherTickLifecycle } from "./weather-tick-lifecycle";
|
|
3
|
+
|
|
4
|
+
function deferred() {
|
|
5
|
+
let resolve!: () => void;
|
|
6
|
+
const promise = new Promise<void>((done) => {
|
|
7
|
+
resolve = done;
|
|
8
|
+
});
|
|
9
|
+
return { promise, resolve };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe("patchWeatherTickLifecycle", () => {
|
|
13
|
+
it("stops a tick subscription created by a mount that finishes after destroy", async () => {
|
|
14
|
+
const started = deferred();
|
|
15
|
+
const mounted = deferred();
|
|
16
|
+
const unsubscribe = vi.fn();
|
|
17
|
+
const prototype = {
|
|
18
|
+
async onMount(this: any) {
|
|
19
|
+
started.resolve();
|
|
20
|
+
await mounted.promise;
|
|
21
|
+
this.tickSubscription = { unsubscribe };
|
|
22
|
+
},
|
|
23
|
+
onDestroy() {},
|
|
24
|
+
};
|
|
25
|
+
patchWeatherTickLifecycle(prototype);
|
|
26
|
+
|
|
27
|
+
const instance = Object.create(prototype);
|
|
28
|
+
const mounting = instance.onMount();
|
|
29
|
+
await started.promise;
|
|
30
|
+
instance.onDestroy();
|
|
31
|
+
mounted.resolve();
|
|
32
|
+
await mounting;
|
|
33
|
+
|
|
34
|
+
expect(unsubscribe).toHaveBeenCalledOnce();
|
|
35
|
+
expect(instance.tickSubscription).toBeUndefined();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("serializes remounts and replaces the previous tick subscription", async () => {
|
|
39
|
+
const subscriptions = [
|
|
40
|
+
{ unsubscribe: vi.fn() },
|
|
41
|
+
{ unsubscribe: vi.fn() },
|
|
42
|
+
];
|
|
43
|
+
const prototype = {
|
|
44
|
+
mountCount: 0,
|
|
45
|
+
async onMount(this: any) {
|
|
46
|
+
await Promise.resolve();
|
|
47
|
+
this.tickSubscription = subscriptions[this.mountCount++];
|
|
48
|
+
},
|
|
49
|
+
onDestroy() {},
|
|
50
|
+
};
|
|
51
|
+
patchWeatherTickLifecycle(prototype);
|
|
52
|
+
|
|
53
|
+
const instance = Object.create(prototype);
|
|
54
|
+
await Promise.all([instance.onMount(), instance.onMount()]);
|
|
55
|
+
|
|
56
|
+
expect(subscriptions[0].unsubscribe).toHaveBeenCalledOnce();
|
|
57
|
+
expect(subscriptions[1].unsubscribe).not.toHaveBeenCalled();
|
|
58
|
+
expect(instance.tickSubscription).toBe(subscriptions[1]);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
const PATCHED = Symbol.for("@rpgjs/client/weather-tick-lifecycle-patched");
|
|
2
|
+
const DISPOSED = Symbol("weather-tick-lifecycle-disposed");
|
|
3
|
+
const MOUNT_QUEUE = Symbol("weather-tick-lifecycle-mount-queue");
|
|
4
|
+
|
|
5
|
+
type TickSubscription = {
|
|
6
|
+
unsubscribe?: () => void;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
type TickSubscriptionInstance = {
|
|
10
|
+
destroyed?: boolean;
|
|
11
|
+
tickSubscription?: TickSubscription;
|
|
12
|
+
[DISPOSED]?: boolean;
|
|
13
|
+
[MOUNT_QUEUE]?: Promise<void>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type TickSubscriptionPrototype = {
|
|
17
|
+
[PATCHED]?: boolean;
|
|
18
|
+
onMount?: (...args: any[]) => unknown;
|
|
19
|
+
onDestroy?: (...args: any[]) => unknown;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function stopTickSubscription(instance: TickSubscriptionInstance) {
|
|
23
|
+
instance.tickSubscription?.unsubscribe?.();
|
|
24
|
+
instance.tickSubscription = undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* CanvasEngine presets 2.0.0 can finish mounting a rain layer after that layer
|
|
29
|
+
* has already been destroyed. The late mount then leaves a tick subscription
|
|
30
|
+
* operating on a destroyed Pixi object. It can also overwrite an existing
|
|
31
|
+
* subscription when mounts overlap.
|
|
32
|
+
*
|
|
33
|
+
* Keep this compatibility patch local to the affected preset prototypes. It
|
|
34
|
+
* can be removed once the preset owns the same lifecycle guarantees.
|
|
35
|
+
*/
|
|
36
|
+
export function patchWeatherTickLifecycle(prototype: TickSubscriptionPrototype) {
|
|
37
|
+
if (!prototype || prototype[PATCHED]) return;
|
|
38
|
+
|
|
39
|
+
const originalOnMount = prototype.onMount;
|
|
40
|
+
const originalOnDestroy = prototype.onDestroy;
|
|
41
|
+
if (!originalOnMount || !originalOnDestroy) return;
|
|
42
|
+
|
|
43
|
+
Object.defineProperty(prototype, PATCHED, { value: true });
|
|
44
|
+
|
|
45
|
+
prototype.onMount = function (this: TickSubscriptionInstance, ...args: any[]) {
|
|
46
|
+
const mount = async () => {
|
|
47
|
+
if (this[DISPOSED] || this.destroyed) return;
|
|
48
|
+
|
|
49
|
+
stopTickSubscription(this);
|
|
50
|
+
await originalOnMount.apply(this, args);
|
|
51
|
+
|
|
52
|
+
if (this[DISPOSED] || this.destroyed) {
|
|
53
|
+
stopTickSubscription(this);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const pendingMount = (this[MOUNT_QUEUE] ?? Promise.resolve())
|
|
58
|
+
.catch(() => undefined)
|
|
59
|
+
.then(mount);
|
|
60
|
+
this[MOUNT_QUEUE] = pendingMount;
|
|
61
|
+
return pendingMount;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
prototype.onDestroy = function (this: TickSubscriptionInstance, ...args: any[]) {
|
|
65
|
+
this[DISPOSED] = true;
|
|
66
|
+
stopTickSubscription(this);
|
|
67
|
+
return originalOnDestroy.apply(this, args);
|
|
68
|
+
};
|
|
69
|
+
}
|
package/src/i18n.spec.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { describe, expect, test } from "vitest";
|
|
|
4
4
|
import { Context, injector } from "@signe/di";
|
|
5
5
|
import { getOrCreateI18nService } from "@rpgjs/common";
|
|
6
6
|
import { provideClientModules } from "./module";
|
|
7
|
-
import { provideI18n } from "./i18n";
|
|
7
|
+
import { provideI18n, RpgClientBuiltinI18n } from "./i18n";
|
|
8
8
|
|
|
9
9
|
describe("client i18n", () => {
|
|
10
10
|
test("merges client module translations with game overrides", async () => {
|
|
@@ -35,5 +35,6 @@ describe("client i18n", () => {
|
|
|
35
35
|
|
|
36
36
|
expect(service.t("menu.title", undefined, "fr")).toBe("Titre du jeu");
|
|
37
37
|
expect(service.t("menu.module-only", undefined, "fr")).toBe("Module");
|
|
38
|
+
expect(RpgClientBuiltinI18n.en["rpg.transition.loading"]).toBe("Loading area…");
|
|
38
39
|
});
|
|
39
40
|
});
|