@energy8platform/game-engine 0.36.0 → 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 +147 -0
- package/dist/host.cjs.js.map +1 -1
- package/dist/host.d.ts +59 -2
- package/dist/host.esm.js +145 -1
- 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/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/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;
|