@gamedevland/engine 0.0.9 → 0.0.10

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 CHANGED
@@ -1,5 +1,47 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.0.10 — 2026-11-07
4
+
5
+ ### Localization and text
6
+
7
+ - Expand `LocaleCatalog` with Spanish, Brazilian Portuguese, German, French, Italian,
8
+ Turkish, Simplified Chinese, Japanese, and Korean, including regional-code normalization.
9
+ - Add `LocalesService.initialize()` with an explicit supported subset and default language.
10
+ Resolve saved manual preferences before device/platform hints, browser preferences, and
11
+ the configured default. Automatic detection does not write a manual preference.
12
+ - Persist explicit `setLang()` choices when storage is configured, tolerate unavailable
13
+ browser storage, and expose locale revisions and disposable change subscriptions.
14
+ - Preserve the English/Russian default subset for existing consumers and support partial
15
+ locale-specific text-style maps.
16
+ - Add measured `Text.fitTo(width, height?)` font fitting without changing node transforms,
17
+ including proportional line-height adjustment and validation of fitting bounds.
18
+
19
+ ### Audio
20
+
21
+ - Attempt music autoplay and recover blocked playback on the first trusted canvas
22
+ pointer/touch interaction or eligible keyboard interaction. Engine-owned capture listeners
23
+ run before scene input, work during boot, and are removed on audio disposal.
24
+ - Retry Web Audio resume on each user gesture even if an earlier automatic attempt remains
25
+ pending. Verify context state and handle rejected resume attempts and unavailable audio.
26
+ - Retain blocked HTML media music requests and reuse their sound IDs on native activation
27
+ or Howler unlock. Report confirmed playback separately from pending handles.
28
+ - Deduplicate active/loading music in both `AudioManager` and the default backend. Repeated
29
+ requests and scene changes preserve the handle and playback position; replacing a pending
30
+ track cancels its old Howler queue to prevent stale stops and orphaned playback.
31
+ - Preserve manual pause/stop, music/SFX preferences, channel/platform/background mute gates,
32
+ and terminal listener cleanup. Drop blocked one-shot SFX rather than replaying a backlog.
33
+ - Add optional `AudioBackend.isHandlePlaying()` and `isPlaybackReady()` hooks; existing
34
+ custom backends remain compatible.
35
+ - Reject unknown `channel.*` keys in the existing `system.audio.volumeConfigId` JSON contract.
36
+ Document centrally editable music/SFX channel and per-asset volumes in engine guidelines.
37
+
38
+ ### Verification
39
+
40
+ - Add deterministic locale preference/text fitting and browser audio activation regressions,
41
+ plus tests using real Howler queue/event code with a simulated HTML media device.
42
+ - Run these checks in the npm publishing workflow. Simulated browser/media tests do not
43
+ constitute physical iOS/Android or portal-device verification.
44
+
3
45
  ## 0.0.9 — 2026-11-05
4
46
 
5
47
  - Add opt-in `screen.dpr: 'native'` to target the viewport's physical pixels.
package/README.md CHANGED
@@ -12,8 +12,10 @@ Source files are available in [the SDK repository](https://github.com/gamedevlan
12
12
 
13
13
  ## Changelog
14
14
 
15
- **0.0.9** adds opt-in viewport-native rendering and a configurable canvas pixel
16
- budget. Existing DPR defaults stay the same.
15
+ **0.0.10** adds configurable language preferences, observable locale changes,
16
+ measured text fitting, automatic audio activation recovery, and music deduplication
17
+ across loading, user gestures, and scene changes. The default locale subset and
18
+ existing custom audio backends remain compatible.
17
19
  [Full engine changelog](https://github.com/gamedevland/sdk/blob/main/packages/engine/CHANGELOG.md).
18
20
 
19
21
  ## Install
@@ -132,3 +134,70 @@ type declarations may conflict with newer TypeScript DOM library declarations in
132
134
  MIT. See [LICENSE](./LICENSE). Source code and contributions: [gamedevland/sdk](https://github.com/gamedevland/sdk).
133
135
 
134
136
  Earlier npm releases retain the license included in their respective archives. Third-party dependencies retain their own licenses.
137
+
138
+ ## Language preferences
139
+
140
+ `LocaleCatalog` recognizes `en`, `ru`, `es`, `pt-BR`, `de`, `fr`, `it`, `tr`,
141
+ `zh-CN`, `ja`, and `ko`, including regional browser codes. Games opt into their
142
+ own subset with `LocalesService.initialize({ supportedLocales, defaultLocale,
143
+ deviceLocale, storageKey })`. Existing games retain their English/Russian subset.
144
+ The priority is a saved manual choice, a device/platform hint, browser language
145
+ preferences, then the configured default. Initialization never persists an
146
+ automatic choice. `setLang()` persists manual choices, including selection of the
147
+ current language; unavailable browser storage does not prevent play. Dictionaries
148
+ must already be present in ConfigStore. `getRevision()` and `subscribe()` support
149
+ live UI updates; `subscribe()` returns an unsubscribe function. Text-style locale
150
+ overrides accept partial maps of supported languages.
151
+
152
+ `Text.fitTo(width, height?)` shrinks the current font to fit measured local bounds
153
+ without changing node transforms. Set the desired font, wrapping and content
154
+ before fitting; invoke it again after a language/content change.
155
+
156
+ ### Autoplay and first-interaction recovery
157
+
158
+ Request music normally with `audio.playMusic(id)` when assets and player preferences
159
+ are ready. Do not wait for a scene-specific tap. Engine initialization attaches
160
+ native capture listeners to its canvas before scene input is routed. The audio
161
+ manager attempts Web Audio context resume automatically; a rejected or pending
162
+ attempt is retained for the first trusted pointer/touch interaction or keyboard
163
+ interaction while game/body has focus. Gesture retries call `resume()` before any
164
+ await, independently of any pending automatic attempt. Synthetic events, secondary
165
+ pointers/buttons, repeated/modifier key input, and hidden documents do not activate
166
+ recovery. Listeners belong to `AudioManager` and are removed by `audio.dispose()`.
167
+
168
+ The Howler backend checks the actual context state. HTML media `playerror` preserves
169
+ requested music and retries its existing sound id from native activation or Howler's
170
+ `unlock` event. Repeated taps cannot create layered music. `isMusicPlaying()` reports
171
+ confirmed playback rather than a queued handle. Stale blocked one-shot SFX are
172
+ suppressed/dropped, not replayed after unlock. User/channel disabled flags, manual
173
+ pause/stop, and platform/background mute remain authoritative.
174
+
175
+ `AudioBackend` optionally exposes `isHandlePlaying(handle)` and `isPlaybackReady()`
176
+ for precise state and one-shot suppression; existing custom backends remain compatible.
177
+ The default backend implements both. `AudioManager.attachInteractionTarget(element)`
178
+ is idempotent and is called by Engine; standalone AudioManager hosts should attach
179
+ it to their own game canvas and dispose the manager on teardown.
180
+
181
+ All authored levels belong in a manually editable JSON file selected by
182
+ `system.audio.volumeConfigId`, including `channel.music`, `channel.sfx`, and per-asset
183
+ levels. The SDK validates finite values in `[0, 1]` and rejects unknown channel keys.
184
+ Do not duplicate these values in consumer playback calls. Per-play volume options
185
+ remain available for deliberate runtime envelopes/custom backend integrations.
186
+
187
+ Run `node scripts/verify-audio-activation.mjs` from the SDK workspace to exercise the
188
+ real audio classes with simulated browser contexts/media events. These are deterministic
189
+ unit checks; they do not replace Safari/iOS/Android and embedded-portal device checks.
190
+
191
+ ### Music request idempotency
192
+
193
+ Repeated `playMusic()` requests for the same active or loading track preserve its
194
+ handle and seek position. The backend independently deduplicates pending/playing
195
+ music and limits its music channel to one source. Music is engine-scoped and does
196
+ not restart when changing scenes. Source-wide stops must not be queued ahead of
197
+ an undecoded play: Howler can reuse a sound id and later stop the newer request.
198
+ Cancelling undecoded music unloads the old Howl queue and recreates a clean source.
199
+
200
+ Run `node scripts/verify-music-single-instance.mjs` to check the actual bundled
201
+ Howler queue/events against a simulated HTML media device, including cold load,
202
+ boot/main duplicates, unlock races, preserved seek, mute/unmute during decoding,
203
+ and cancellation. This supplements `verify-audio-activation.mjs`.
package/dist/sdk.d.ts CHANGED
@@ -343,6 +343,10 @@ export interface AudioBackend {
343
343
  createSource(source: AudioSourceDescriptor): void;
344
344
  hasSource(id: AudioId): boolean;
345
345
  isHandleActive(handle: AudioHandle): boolean;
346
+ /** Actual playback state, excluding queued/blocked requests. */
347
+ isHandlePlaying?(handle: AudioHandle): boolean;
348
+ /** False while Web Audio is suspended; used to discard stale one-shot SFX. */
349
+ isPlaybackReady?(): boolean;
346
350
  play(id: AudioId, options?: PlayAudioOptions): AudioHandle;
347
351
  pause(handle: AudioHandle): void;
348
352
  stop(handle: AudioHandle): void;
@@ -371,16 +375,13 @@ export interface IStateStore<T> {
371
375
  save(value: T): void;
372
376
  clear(): void;
373
377
  }
374
- export type SupportedLocaleCode = "en" | "ru";
375
- /**
376
- * Canonical supported locale codes used by runtime, build validation, and platform hints.
377
- */
378
+ export type SupportedLocaleCode = "en" | "ru" | "es" | "pt-BR" | "de" | "fr" | "it" | "tr" | "zh-CN" | "ja" | "ko";
379
+ /** Canonical locale codes shared by runtime, configs and build tools. */
378
380
  export declare class LocaleCatalog {
379
- static readonly EN: SupportedLocaleCode;
380
- static readonly RU: SupportedLocaleCode;
381
- static readonly DEFAULT: SupportedLocaleCode;
381
+ static readonly EN = "en";
382
+ static readonly RU = "ru";
383
+ static readonly DEFAULT = "en";
382
384
  private static readonly SUPPORTED_LIST;
383
- private static readonly SUPPORTED;
384
385
  static isSupported(locale: string): locale is SupportedLocaleCode;
385
386
  static getSupportedList(): ReadonlyArray<SupportedLocaleCode>;
386
387
  static normalize(input: string | null | undefined): SupportedLocaleCode | null;
@@ -418,7 +419,7 @@ export interface TextStyleDef {
418
419
  readonly dropShadow?: TextStyleDropShadowDef;
419
420
  readonly wordWrap?: boolean;
420
421
  readonly wordWrapWidth?: number;
421
- readonly byLocale?: Readonly<Record<"en" | "ru", TextStyleDef>>;
422
+ readonly byLocale?: Readonly<Partial<Record<SupportedLocaleCode, TextStyleDef>>>;
422
423
  }
423
424
  /** Map of style name to definition. */
424
425
  export type TextStylesConfig = Record<string, TextStyleDef>;
@@ -556,14 +557,32 @@ export declare class IndexedDbStorageAdapter {
556
557
  private openDatabase;
557
558
  private logStorageError;
558
559
  }
559
- /** Simple localization access service. */
560
+ export interface LocalePreferences {
561
+ readonly supportedLocales: readonly SupportedLocaleCode[];
562
+ readonly defaultLocale?: SupportedLocaleCode;
563
+ readonly deviceLocale?: string | null;
564
+ readonly preferredLocales?: readonly string[];
565
+ readonly storage?: IStorageAdapter;
566
+ readonly storageKey?: string;
567
+ }
568
+ /** Dictionary lookup and persistent, observable language preferences. */
560
569
  export declare class LocalesService {
561
570
  private readonly configStore;
562
571
  private current;
572
+ private supported;
573
+ private defaultLocale;
574
+ private storage;
575
+ private storageKey;
576
+ private revision;
577
+ private readonly listeners;
563
578
  private readonly fallback;
564
579
  private readonly loadedDictionaries;
565
580
  constructor(configStore: ConfigStore, fallback?: readonly string[]);
581
+ initialize(options: LocalePreferences): void;
566
582
  setLang(lang: string): void;
583
+ getRevision(): number;
584
+ subscribe(listener: (locale: SupportedLocaleCode) => void): () => void;
585
+ private apply;
567
586
  getLang(): SupportedLocaleCode;
568
587
  private hasKey;
569
588
  t(key: string, params?: Readonly<Record<string, string | number>>): string;
@@ -867,8 +886,12 @@ export declare class AudioManager {
867
886
  private musicVolumeInitialized;
868
887
  private musicVolumeOverridden;
869
888
  private disposed;
889
+ private autoplayAttempt;
890
+ private readonly interactionUnlock;
870
891
  static createDefault(assets: AssetManager, options?: AudioManagerOptions): AudioManager;
871
892
  constructor(assets: AssetManager, backend: AudioBackend, options?: AudioManagerOptions);
893
+ /** Engine-owned, scene-independent activation on the game's native input target. */
894
+ attachInteractionTarget(target: HTMLElement): void;
872
895
  playMusic(assetId: AudioId, options?: PlayAudioOptions): AudioHandle | undefined;
873
896
  playSfx(assetId: AudioId, options?: PlayAudioOptions): AudioHandle | undefined;
874
897
  preloadInBackground(assetId: AudioId): void;
@@ -902,6 +925,7 @@ export declare class AudioManager {
902
925
  private rememberMusicRequest;
903
926
  private restoreRememberedMusicIfNeeded;
904
927
  private clearActiveMusic;
928
+ private tryAutomaticUnlock;
905
929
  private clearRememberedMusicRequest;
906
930
  private ensureRememberedMusicSourceInBackground;
907
931
  private isChannelEnabled;
@@ -1587,6 +1611,8 @@ declare class Text$1 extends NodeBase implements TextNode {
1587
1611
  }): DisplayNode;
1588
1612
  get view(): PIXI.Text;
1589
1613
  setText(value: string): this;
1614
+ /** Shrink the current font to fit local bounds, preserving node transforms. */
1615
+ fitTo(width: number, height?: number): this;
1590
1616
  protected applyAnchor(anchorX: number, anchorY: number): this;
1591
1617
  }
1592
1618
  export declare abstract class BaseComponent<TNode extends NodeBase = NodeBase> {
@@ -4904,7 +4930,7 @@ export declare abstract class BaseTypedCommand<TPayload> extends BaseCommand {
4904
4930
  protected abstract execute(payload: TPayload): Promise<void> | void;
4905
4931
  run(data?: Record<string, unknown>): Promise<void> | void;
4906
4932
  }
4907
- export type EngineErrorCodeValue = "CORE_INVALID_ARGUMENT" | "CORE_MISSING_REQUIRED" | "CORE_INVALID_CONFIG" | "CORE_INVARIANT_VIOLATION" | "SCENE_INVALID_ARGUMENT" | "SCENE_INVALID_STATE" | "SCENE_LIFECYCLE_FAILURE" | "DI_INVALID_CONFIG" | "DI_PROVIDER_NOT_FOUND" | "DI_INVALID_PROVIDER" | "DI_DUPLICATE_PROVIDER" | "DI_FORBIDDEN_OVERRIDE" | "DI_UNRESOLVABLE_TOKEN" | "ASSET_MISSING_REQUIRED" | "ASSET_INVALID_CONFIG" | "ASSET_CONFLICT" | "ASSET_LOAD_FAILURE" | "LAYOUT_MISSING_REQUIRED" | "LAYOUT_INVALID_CONFIG" | "LAYOUT_CONFLICT" | "LAYOUT_INVARIANT_VIOLATION" | "AUDIO_MISSING_REQUIRED" | "AUDIO_INVALID_ARGUMENT" | "AUDIO_INVALID_CONFIG" | "AUDIO_CONFLICT" | "PARTICLE_MISSING_REQUIRED" | "PARTICLE_INVALID_ARGUMENT" | "PARTICLE_INVALID_CONFIG" | "PARTICLE_INVARIANT_VIOLATION" | "PARTICLE_RUNTIME_FAILURE" | "PREFAB_MISSING_REQUIRED" | "PREFAB_INVALID_CONFIG" | "PREFAB_CONFLICT" | "PREFAB_INVARIANT_VIOLATION" | "FSM_INVALID_CONFIG" | "FSM_INVALID_STATE" | "COMMAND_MISSING_REQUIRED" | "COMMAND_INVALID_PAYLOAD" | "CONFIG_MISSING_REQUIRED" | "CONFIG_INVALID_ARGUMENT" | "CONFIG_INVALID_SCHEMA";
4933
+ export type EngineErrorCodeValue = "CORE_INVALID_ARGUMENT" | "CORE_MISSING_REQUIRED" | "CORE_INVALID_CONFIG" | "CORE_INVARIANT_VIOLATION" | "SCENE_INVALID_ARGUMENT" | "SCENE_INVALID_STATE" | "SCENE_LIFECYCLE_FAILURE" | "DI_INVALID_CONFIG" | "DI_PROVIDER_NOT_FOUND" | "DI_INVALID_PROVIDER" | "DI_DUPLICATE_PROVIDER" | "DI_FORBIDDEN_OVERRIDE" | "DI_UNRESOLVABLE_TOKEN" | "ASSET_MISSING_REQUIRED" | "ASSET_INVALID_CONFIG" | "ASSET_CONFLICT" | "ASSET_LOAD_FAILURE" | "LAYOUT_MISSING_REQUIRED" | "LAYOUT_INVALID_CONFIG" | "LAYOUT_CONFLICT" | "LAYOUT_INVARIANT_VIOLATION" | "AUDIO_MISSING_REQUIRED" | "AUDIO_INVALID_ARGUMENT" | "AUDIO_INVALID_CONFIG" | "AUDIO_CONFLICT" | "AUDIO_PLAYBACK_BLOCKED" | "PARTICLE_MISSING_REQUIRED" | "PARTICLE_INVALID_ARGUMENT" | "PARTICLE_INVALID_CONFIG" | "PARTICLE_INVARIANT_VIOLATION" | "PARTICLE_RUNTIME_FAILURE" | "PREFAB_MISSING_REQUIRED" | "PREFAB_INVALID_CONFIG" | "PREFAB_CONFLICT" | "PREFAB_INVARIANT_VIOLATION" | "FSM_INVALID_CONFIG" | "FSM_INVALID_STATE" | "COMMAND_MISSING_REQUIRED" | "COMMAND_INVALID_PAYLOAD" | "CONFIG_MISSING_REQUIRED" | "CONFIG_INVALID_ARGUMENT" | "CONFIG_INVALID_SCHEMA";
4908
4934
  export declare class EngineErrorCode {
4909
4935
  static readonly CoreInvalidArgument = "CORE_INVALID_ARGUMENT";
4910
4936
  static readonly CoreMissingRequired = "CORE_MISSING_REQUIRED";
@@ -4931,6 +4957,7 @@ export declare class EngineErrorCode {
4931
4957
  static readonly AudioInvalidArgument = "AUDIO_INVALID_ARGUMENT";
4932
4958
  static readonly AudioInvalidConfig = "AUDIO_INVALID_CONFIG";
4933
4959
  static readonly AudioConflict = "AUDIO_CONFLICT";
4960
+ static readonly AudioPlaybackBlocked = "AUDIO_PLAYBACK_BLOCKED";
4934
4961
  static readonly ParticleMissingRequired = "PARTICLE_MISSING_REQUIRED";
4935
4962
  static readonly ParticleInvalidArgument = "PARTICLE_INVALID_ARGUMENT";
4936
4963
  static readonly ParticleInvalidConfig = "PARTICLE_INVALID_CONFIG";