@energy8platform/game-engine 0.35.1 → 0.37.0
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/host.cjs.js +159 -3
- package/dist/host.cjs.js.map +1 -1
- package/dist/host.d.ts +59 -2
- package/dist/host.esm.js +157 -4
- package/dist/host.esm.js.map +1 -1
- package/package.json +1 -1
- package/src/host/createSlotGame.ts +13 -0
- package/src/host/index.ts +2 -0
- package/src/host/settingsStore.ts +148 -0
- package/src/host/shellConfig.ts +12 -4
- package/src/host/types.ts +7 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import type { Shell } from '@energy8platform/shell/pixi';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Per-game persistence of the player's shell preferences — turbo level and every menu value
|
|
5
|
+
* (sound, music, sfx, and any custom row) — in `localStorage`.
|
|
6
|
+
*
|
|
7
|
+
* Opt-in via `createSlotGame({ persistSettings })`. A game that doesn't ask for it behaves exactly
|
|
8
|
+
* as before: nothing is read, nothing is written, no storage is touched.
|
|
9
|
+
*
|
|
10
|
+
* Scoped per game, because these are per-game preferences: a player who mutes one slot has not
|
|
11
|
+
* asked to mute the next one. The key is `e8:<gameId>:settings`.
|
|
12
|
+
*/
|
|
13
|
+
export interface PersistSettingsOptions {
|
|
14
|
+
/** Key namespace. Defaults to the game id, so two games never share a preference set. */
|
|
15
|
+
key?: string;
|
|
16
|
+
/** Backing store. Defaults to `window.localStorage`; pass one in tests, or `null` to disable. */
|
|
17
|
+
storage?: Storage | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** What we keep. `menu` covers presets and custom rows alike — `setMenuValue` routes both. */
|
|
21
|
+
export interface StoredSettings {
|
|
22
|
+
turbo?: number;
|
|
23
|
+
menu?: Record<string, boolean | number>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function settingsKey(gameId: string): string {
|
|
27
|
+
return `e8:${gameId}:settings`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* `localStorage` is not reliably there and not reliably usable: Safari's private mode, a browser
|
|
32
|
+
* configured to block storage, and a full quota can all make even a READ throw. A saved turbo level
|
|
33
|
+
* is never worth failing a game boot over, so every access degrades to "no persistence".
|
|
34
|
+
*/
|
|
35
|
+
function resolveStorage(explicit?: Storage | null): Storage | null {
|
|
36
|
+
if (explicit !== undefined) return explicit;
|
|
37
|
+
try {
|
|
38
|
+
return typeof localStorage === 'undefined' ? null : localStorage;
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A menu value is a boolean or a finite number — nothing else may reach the shell. */
|
|
45
|
+
function isMenuValue(v: unknown): v is boolean | number {
|
|
46
|
+
return typeof v === 'boolean' || (typeof v === 'number' && Number.isFinite(v));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Read and VALIDATE. Whatever is in `localStorage` is player-writable — anyone can open devtools
|
|
51
|
+
* and put a string, an object, or `turbo: 99` in there. It is parsed as untrusted input: a bad
|
|
52
|
+
* field is dropped, not coerced, and a bad blob yields nothing at all.
|
|
53
|
+
*/
|
|
54
|
+
export function readSettings(storage: Storage | null, key: string): StoredSettings {
|
|
55
|
+
if (!storage) return {};
|
|
56
|
+
let raw: string | null;
|
|
57
|
+
try {
|
|
58
|
+
raw = storage.getItem(key);
|
|
59
|
+
} catch {
|
|
60
|
+
return {};
|
|
61
|
+
}
|
|
62
|
+
if (!raw) return {};
|
|
63
|
+
let parsed: unknown;
|
|
64
|
+
try {
|
|
65
|
+
parsed = JSON.parse(raw);
|
|
66
|
+
} catch {
|
|
67
|
+
return {};
|
|
68
|
+
}
|
|
69
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
|
|
70
|
+
const src = parsed as Record<string, unknown>;
|
|
71
|
+
const out: StoredSettings = {};
|
|
72
|
+
if (typeof src.turbo === 'number' && Number.isInteger(src.turbo) && src.turbo >= 0) {
|
|
73
|
+
out.turbo = src.turbo;
|
|
74
|
+
}
|
|
75
|
+
if (src.menu && typeof src.menu === 'object' && !Array.isArray(src.menu)) {
|
|
76
|
+
const menu: Record<string, boolean | number> = {};
|
|
77
|
+
for (const [id, value] of Object.entries(src.menu as Record<string, unknown>)) {
|
|
78
|
+
if (isMenuValue(value)) menu[id] = value;
|
|
79
|
+
}
|
|
80
|
+
if (Object.keys(menu).length) out.menu = menu;
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function writeSettings(storage: Storage | null, key: string, value: StoredSettings): void {
|
|
86
|
+
if (!storage) return;
|
|
87
|
+
try {
|
|
88
|
+
storage.setItem(key, JSON.stringify(value));
|
|
89
|
+
} catch {
|
|
90
|
+
// Quota, private mode, or storage disabled mid-session. The player keeps playing; only the
|
|
91
|
+
// memory of their preference is lost, and that is not worth an error in their face.
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Restore the stored preferences onto a freshly created shell, then keep them in step.
|
|
97
|
+
*
|
|
98
|
+
* Returns an unsubscribe function.
|
|
99
|
+
*
|
|
100
|
+
* Two things are deliberate here:
|
|
101
|
+
*
|
|
102
|
+
* - **`maxTurbo` clamps the restored level.** `features.turbo` is the CEILING the shell offers
|
|
103
|
+
* (`applyJurisdiction` lowers it where super-turbo or turbo is forbidden), while `state.turbo`
|
|
104
|
+
* is what the player currently has. Restoring a saved 3 into a jurisdiction capped at 1 would
|
|
105
|
+
* hand back exactly what the restriction took away, so the stored value is clamped, never
|
|
106
|
+
* trusted.
|
|
107
|
+
* - **Subscription happens AFTER restoring.** `setMenuValue` emits `settingChange`, so subscribing
|
|
108
|
+
* first would have the restore write back what it just read. (`setTurbo` is quiet — only the
|
|
109
|
+
* bar tap emits `turboChange` — which is why restoring the level can't echo. Do not "fix" that
|
|
110
|
+
* asymmetry without moving this subscription.)
|
|
111
|
+
*/
|
|
112
|
+
export function attachSettingsStore(
|
|
113
|
+
shell: Shell,
|
|
114
|
+
opts: { key: string; storage?: Storage | null; maxTurbo?: number },
|
|
115
|
+
): () => void {
|
|
116
|
+
const storage = resolveStorage(opts.storage);
|
|
117
|
+
const key = settingsKey(opts.key);
|
|
118
|
+
const stored = readSettings(storage, key);
|
|
119
|
+
|
|
120
|
+
if (stored.turbo !== undefined) {
|
|
121
|
+
shell.setTurbo(Math.min(stored.turbo, opts.maxTurbo ?? stored.turbo));
|
|
122
|
+
}
|
|
123
|
+
for (const [id, value] of Object.entries(stored.menu ?? {})) {
|
|
124
|
+
// `setMenuValue` routes presets to their own homes (sound → setSound, music/sfx → setVolume)
|
|
125
|
+
// and clamps custom ranges, so one loop covers every row and the shell owns the bounds.
|
|
126
|
+
shell.setMenuValue(id, value);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const live: StoredSettings = { ...stored };
|
|
130
|
+
const flush = (): void => writeSettings(storage, key, live);
|
|
131
|
+
|
|
132
|
+
const onTurbo = (level: number): void => {
|
|
133
|
+
live.turbo = level;
|
|
134
|
+
flush();
|
|
135
|
+
};
|
|
136
|
+
const onSetting = ({ key: id, value }: { key: string; value: unknown }): void => {
|
|
137
|
+
if (!isMenuValue(value)) return;
|
|
138
|
+
live.menu = { ...live.menu, [id]: value };
|
|
139
|
+
flush();
|
|
140
|
+
};
|
|
141
|
+
shell.on('turboChange', onTurbo);
|
|
142
|
+
shell.on('settingChange', onSetting);
|
|
143
|
+
|
|
144
|
+
return () => {
|
|
145
|
+
shell.off('turboChange', onTurbo);
|
|
146
|
+
shell.off('settingChange', onSetting);
|
|
147
|
+
};
|
|
148
|
+
}
|
package/src/host/shellConfig.ts
CHANGED
|
@@ -225,8 +225,13 @@ function paytableSection(model: GameModel, t: (s: string) => string = (s) => s):
|
|
|
225
225
|
return { type: 'paytable', rows };
|
|
226
226
|
}
|
|
227
227
|
|
|
228
|
-
/** Build a "wins" illustration section sized to the grid; `kind` follows the spec mechanic hint.
|
|
229
|
-
|
|
228
|
+
/** Build a "wins" illustration section sized to the grid; `kind` follows the spec mechanic hint.
|
|
229
|
+
*
|
|
230
|
+
* Every mechanic that has an illustration names itself. An unrecognised one renders NOTHING
|
|
231
|
+
* rather than borrowing `anywhere`'s picture: the section exists to show the player how wins
|
|
232
|
+
* form, and a drawing of the wrong mechanic teaches them something false. Silence is the honest
|
|
233
|
+
* answer — the rest of the info screen (paytable, modes, controls) still renders. */
|
|
234
|
+
function winsSection(model: GameModel): GameInfoSection | null {
|
|
230
235
|
const { cols, rows } = model.spec.grid;
|
|
231
236
|
const grid = { cols, rows };
|
|
232
237
|
switch (model.spec.mechanic) {
|
|
@@ -234,8 +239,10 @@ function winsSection(model: GameModel): GameInfoSection {
|
|
|
234
239
|
return { type: 'wins', kind: 'cluster', minCount: 5, grid } as GameInfoSection;
|
|
235
240
|
case 'ways':
|
|
236
241
|
return { type: 'wins', kind: 'ways', grid } as GameInfoSection;
|
|
237
|
-
|
|
242
|
+
case 'anywhere':
|
|
238
243
|
return { type: 'wins', kind: 'anywhere', minCount: 3, grid } as GameInfoSection;
|
|
244
|
+
default:
|
|
245
|
+
return null;
|
|
239
246
|
}
|
|
240
247
|
}
|
|
241
248
|
|
|
@@ -290,7 +297,8 @@ export function defaultGameInfo(
|
|
|
290
297
|
tDisclaimer: (s: string) => string = t,
|
|
291
298
|
): GameInfoContent {
|
|
292
299
|
const sections: GameInfoSection[] = [];
|
|
293
|
-
|
|
300
|
+
const wins = winsSection(model);
|
|
301
|
+
if (wins) sections.push(wins);
|
|
294
302
|
const pay = paytableSection(model, t);
|
|
295
303
|
if (pay) sections.push(pay);
|
|
296
304
|
const modes = modesSection(model, t);
|
package/src/host/types.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type { AudioConfig, ScaleMode, Orientation, SceneConstructor } from '../t
|
|
|
7
7
|
import type { BookAdapter, AdapterModule, StakeBridge } from '@energy8platform/stake-bridge';
|
|
8
8
|
import type { GameApplication } from '../core';
|
|
9
9
|
import type { SlotShellOptions } from './shellConfig';
|
|
10
|
+
import type { PersistSettingsOptions } from './settingsStore';
|
|
10
11
|
import type {
|
|
11
12
|
SlotSpinResultBase,
|
|
12
13
|
SlotResultNormalizer,
|
|
@@ -148,6 +149,12 @@ export interface CreateSlotGameOptions<T extends SlotSpinResultBase = SlotSpinRe
|
|
|
148
149
|
* renderer can ignore `app`/`parent` and mount elsewhere (a DOM overlay, another canvas).
|
|
149
150
|
* Default: the built-in Pixi shell (`createPixiShell`). */
|
|
150
151
|
shellFactory?: ShellFactory;
|
|
152
|
+
/** Remember the player's turbo level and menu values (sound, music, sfx, custom rows) in
|
|
153
|
+
* `localStorage`, scoped to this game. Off by default — a game that doesn't ask touches no
|
|
154
|
+
* storage at all. `true` keys on the game id; pass an object to set the key or supply the
|
|
155
|
+
* store. Restored values are still subject to jurisdiction limits, and unreadable or tampered
|
|
156
|
+
* storage degrades to "don't persist" rather than failing the boot. */
|
|
157
|
+
persistSettings?: boolean | PersistSettingsOptions;
|
|
151
158
|
/** Double-tap on the play area to skip the current spin animation. Default `true`. Set `false`
|
|
152
159
|
* to disable the gesture (e.g. games where a tap means something else). */
|
|
153
160
|
skipGesture?: boolean;
|