@duyquangnvx/pixi-game-engine 0.1.5 → 0.1.7

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/README.md CHANGED
@@ -1,75 +1,75 @@
1
- # @duyquangnvx/pixi-game-engine
2
-
3
- Full-featured PixiJS v7 game engine with TypeScript support.
4
-
5
- ## Installation
6
-
7
- ```bash
8
- pnpm add @duyquangnvx/pixi-game-engine
9
- ```
10
-
11
- ## Quick Start
12
-
13
- ```typescript
14
- import { Engine, Scene, PIXI } from '@duyquangnvx/pixi-game-engine';
15
-
16
- class GameScene extends Scene {
17
- private player!: PIXI.Sprite;
18
-
19
- onEnter() {
20
- this.player = new PIXI.Graphics();
21
- this.player.beginFill(0x4ecca3);
22
- this.player.drawRect(-25, -25, 50, 50);
23
- this.player.x = Engine.screen.width / 2;
24
- this.player.y = Engine.screen.height / 2;
25
- this.addChild(this.player);
26
- }
27
-
28
- onUpdate(delta: number) {
29
- const { keyboard } = Engine.input;
30
-
31
- if (keyboard.isDown('ArrowLeft')) this.player.x -= 5 * delta;
32
- if (keyboard.isDown('ArrowRight')) this.player.x += 5 * delta;
33
- if (keyboard.isDown('ArrowUp')) this.player.y -= 5 * delta;
34
- if (keyboard.isDown('ArrowDown')) this.player.y += 5 * delta;
35
- }
36
-
37
- onExit() {}
38
- }
39
-
40
- Engine.init({
41
- width: 800,
42
- height: 600,
43
- backgroundColor: 0x1a1a2e,
44
- });
45
-
46
- document.body.appendChild(Engine.view as HTMLCanvasElement);
47
- Engine.scenes.add('game', GameScene);
48
- Engine.scenes.start('game');
49
- ```
50
-
51
- ## Features
52
-
53
- | Feature | Module | Description |
54
- |---------|--------|-------------|
55
- | Scenes | `SceneManager` | Scene lifecycle, transitions |
56
- | Input | `InputManager` | Keyboard, mouse, touch, gamepad |
57
- | Assets | `AssetManager` | Manifest-based loading |
58
- | Sound | `SoundManager` | @pixi/sound wrapper |
59
- | Particles | `ParticleManager` | @pixi/particle-emitter |
60
- | Tweens | `TweenManager` | GSAP + PixiPlugin |
61
- | UI | `UIManager` | @pixi/ui components |
62
- | Spine | `SpineManager` | pixi-spine animations |
63
-
64
- ## Dependencies
65
-
66
- - pixi.js ^7.4.2
67
- - @pixi/sound ^5.2.3
68
- - @pixi/particle-emitter ^5.0.10
69
- - @pixi/ui ^1.2.4
70
- - pixi-spine ^4.0.5
71
- - gsap ^3.12.x
72
-
73
- ## License
74
-
75
- MIT
1
+ # @duyquangnvx/pixi-game-engine
2
+
3
+ Full-featured PixiJS v7 game engine with TypeScript support.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pnpm add @duyquangnvx/pixi-game-engine
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```typescript
14
+ import { Engine, Scene, PIXI } from '@duyquangnvx/pixi-game-engine';
15
+
16
+ class GameScene extends Scene {
17
+ private player!: PIXI.Sprite;
18
+
19
+ onEnter() {
20
+ this.player = new PIXI.Graphics();
21
+ this.player.beginFill(0x4ecca3);
22
+ this.player.drawRect(-25, -25, 50, 50);
23
+ this.player.x = Engine.screen.width / 2;
24
+ this.player.y = Engine.screen.height / 2;
25
+ this.addChild(this.player);
26
+ }
27
+
28
+ onUpdate(delta: number) {
29
+ const { keyboard } = Engine.input;
30
+
31
+ if (keyboard.isDown('ArrowLeft')) this.player.x -= 5 * delta;
32
+ if (keyboard.isDown('ArrowRight')) this.player.x += 5 * delta;
33
+ if (keyboard.isDown('ArrowUp')) this.player.y -= 5 * delta;
34
+ if (keyboard.isDown('ArrowDown')) this.player.y += 5 * delta;
35
+ }
36
+
37
+ onExit() {}
38
+ }
39
+
40
+ Engine.init({
41
+ width: 800,
42
+ height: 600,
43
+ backgroundColor: 0x1a1a2e,
44
+ });
45
+
46
+ document.body.appendChild(Engine.view as HTMLCanvasElement);
47
+ Engine.scenes.add('game', GameScene);
48
+ Engine.scenes.start('game');
49
+ ```
50
+
51
+ ## Features
52
+
53
+ | Feature | Module | Description |
54
+ |---------|--------|-------------|
55
+ | Scenes | `SceneManager` | Scene lifecycle, transitions |
56
+ | Input | `InputManager` | Keyboard, mouse, touch, gamepad |
57
+ | Assets | `AssetManager` | Manifest-based loading |
58
+ | Sound | `SoundManager` | @pixi/sound wrapper |
59
+ | Particles | `ParticleManager` | @pixi/particle-emitter |
60
+ | Tweens | `TweenManager` | GSAP + PixiPlugin |
61
+ | UI | `UIManager` | @pixi/ui components |
62
+ | Spine | `SpineManager` | pixi-spine animations |
63
+
64
+ ## Dependencies
65
+
66
+ - pixi.js ^7.4.2
67
+ - @pixi/sound ^5.2.3
68
+ - @pixi/particle-emitter ^5.0.10
69
+ - @pixi/ui ^1.2.4
70
+ - pixi-spine ^4.0.5
71
+ - gsap ^3.12.x
72
+
73
+ ## License
74
+
75
+ MIT
package/dist/index.cjs CHANGED
@@ -1327,111 +1327,252 @@ var SceneManager = class {
1327
1327
 
1328
1328
  // src/sound/sound-manager.ts
1329
1329
  var import_sound = require("@pixi/sound");
1330
+ var STORAGE_KEY = "sound_settings";
1330
1331
  var SoundManager = class {
1331
1332
  volumes = {
1332
1333
  master: 1,
1333
1334
  music: 1,
1334
1335
  sfx: 1
1335
1336
  };
1337
+ _muted = false;
1336
1338
  currentMusic = null;
1337
1339
  currentMusicAlias = null;
1338
- playSfx(alias, options = {}) {
1339
- this.play(alias, options);
1340
+ currentMusicInstance = null;
1341
+ /** Track active SFX instances for pause/resume/stop */
1342
+ activeSfx = /* @__PURE__ */ new Map();
1343
+ /** Whether the AudioContext has been unlocked by user interaction */
1344
+ _unlocked = false;
1345
+ constructor() {
1346
+ this.loadSettings();
1347
+ this.setupUnlockListener();
1348
+ }
1349
+ // ─── AudioContext unlock ──────────────────────────────
1350
+ /**
1351
+ * Browsers block audio until first user interaction.
1352
+ * Call this early or let the auto-listener handle it.
1353
+ */
1354
+ setupUnlockListener() {
1355
+ const unlock = () => {
1356
+ if (this._unlocked) return;
1357
+ const ctx = import_sound.sound.context;
1358
+ if (ctx.audioContext.state === "suspended") {
1359
+ ctx.audioContext.resume().then(() => {
1360
+ this._unlocked = true;
1361
+ Logger.info("SoundManager", "AudioContext unlocked");
1362
+ });
1363
+ } else {
1364
+ this._unlocked = true;
1365
+ }
1366
+ if (this._unlocked) {
1367
+ document.removeEventListener("pointerdown", unlock);
1368
+ document.removeEventListener("keydown", unlock);
1369
+ }
1370
+ };
1371
+ document.addEventListener("pointerdown", unlock, { once: false });
1372
+ document.addEventListener("keydown", unlock, { once: false });
1340
1373
  }
1341
- /** Play a sound effect */
1342
- play(alias, options = {}) {
1374
+ get isUnlocked() {
1375
+ return this._unlocked;
1376
+ }
1377
+ // ─── SFX ─────────────────────────────────────────────
1378
+ /** Play a sound effect. Returns instance for fine-grained control. */
1379
+ playSfx(alias, options = {}) {
1343
1380
  const snd = import_sound.sound.find(alias);
1344
1381
  if (!snd) {
1345
1382
  Logger.warn("SoundManager", `Sound "${alias}" not found`);
1346
- return;
1383
+ return null;
1347
1384
  }
1348
- snd.play({
1349
- volume: (options.volume ?? 1) * this.volumes.master * this.volumes.sfx,
1385
+ const effectiveVolume = (options.volume ?? 1) * this.volumes.master * this.volumes.sfx;
1386
+ const instance = snd.play({
1387
+ volume: options.fadeIn ? 0 : effectiveVolume,
1350
1388
  loop: options.loop ?? false,
1351
1389
  speed: options.speed ?? 1
1352
1390
  });
1391
+ if (!this.activeSfx.has(alias)) {
1392
+ this.activeSfx.set(alias, /* @__PURE__ */ new Set());
1393
+ }
1394
+ this.activeSfx.get(alias).add(instance);
1395
+ instance.on("end", () => {
1396
+ this.activeSfx.get(alias)?.delete(instance);
1397
+ });
1398
+ if (options.fadeIn && options.fadeIn > 0) {
1399
+ this.fadeInstance(instance, 0, effectiveVolume, options.fadeIn);
1400
+ }
1401
+ return instance;
1402
+ }
1403
+ /** Stop a specific SFX by alias (all instances) */
1404
+ stopSfx(alias, fadeOut) {
1405
+ const instances = this.activeSfx.get(alias);
1406
+ if (!instances) return;
1407
+ for (const inst of instances) {
1408
+ if (fadeOut && fadeOut > 0) {
1409
+ this.fadeInstance(inst, inst.volume, 0, fadeOut, () => {
1410
+ inst.stop();
1411
+ });
1412
+ } else {
1413
+ inst.stop();
1414
+ }
1415
+ }
1416
+ this.activeSfx.delete(alias);
1353
1417
  }
1354
- /** Play background music (only one at a time) */
1418
+ // ─── Music ───────────────────────────────────────────
1419
+ /** Play background music with optional crossfade */
1355
1420
  playMusic(alias, options = {}) {
1356
- if (this.currentMusicAlias !== alias) {
1357
- this.stopMusic();
1358
- }
1421
+ if (this.currentMusicAlias === alias) return;
1359
1422
  const snd = import_sound.sound.find(alias);
1360
1423
  if (!snd) {
1361
1424
  Logger.warn("SoundManager", `Music "${alias}" not found`);
1362
1425
  return;
1363
1426
  }
1364
- this.currentMusic = snd;
1365
- this.currentMusicAlias = alias;
1366
- snd.play({
1367
- volume: (options.volume ?? 1) * this.volumes.master * this.volumes.music,
1427
+ const crossfade = options.crossfadeDuration ?? 0;
1428
+ const targetVolume = (options.volume ?? 1) * this.volumes.master * this.volumes.music;
1429
+ if (this.currentMusicInstance && crossfade > 0) {
1430
+ const oldInstance = this.currentMusicInstance;
1431
+ this.fadeInstance(oldInstance, oldInstance.volume, 0, crossfade, () => {
1432
+ oldInstance.stop();
1433
+ });
1434
+ } else {
1435
+ this.stopMusic();
1436
+ }
1437
+ const instance = snd.play({
1438
+ volume: crossfade > 0 ? 0 : targetVolume,
1368
1439
  loop: options.loop ?? true,
1369
1440
  speed: options.speed ?? 1
1370
1441
  });
1371
- }
1372
- /** Stop the current music */
1373
- stopMusic() {
1374
- if (this.currentMusic) {
1375
- this.currentMusic.stop();
1376
- this.currentMusic = null;
1377
- this.currentMusicAlias = null;
1442
+ this.currentMusic = snd;
1443
+ this.currentMusicAlias = alias;
1444
+ this.currentMusicInstance = instance;
1445
+ if (crossfade > 0) {
1446
+ this.fadeInstance(instance, 0, targetVolume, crossfade);
1378
1447
  }
1379
1448
  }
1380
- /** Stop a specific sound */
1381
- stop(alias) {
1382
- const snd = import_sound.sound.find(alias);
1383
- if (snd) {
1384
- snd.stop();
1449
+ stopMusic(fadeOut) {
1450
+ if (!this.currentMusicInstance) return;
1451
+ if (fadeOut && fadeOut > 0) {
1452
+ const inst = this.currentMusicInstance;
1453
+ this.fadeInstance(inst, inst.volume, 0, fadeOut, () => {
1454
+ inst.stop();
1455
+ });
1456
+ } else {
1457
+ this.currentMusicInstance.stop();
1385
1458
  }
1386
- }
1387
- /** Stop all sounds */
1388
- stopAll() {
1389
- import_sound.sound.stopAll();
1390
1459
  this.currentMusic = null;
1391
1460
  this.currentMusicAlias = null;
1461
+ this.currentMusicInstance = null;
1392
1462
  }
1393
- /** Pause all sounds */
1394
- pauseAll() {
1395
- import_sound.sound.pauseAll();
1463
+ get currentMusicName() {
1464
+ return this.currentMusicAlias;
1396
1465
  }
1397
- /** Resume all sounds */
1398
- resumeAll() {
1399
- import_sound.sound.resumeAll();
1466
+ /** Pause only music */
1467
+ pauseMusic() {
1468
+ this.currentMusicInstance?.set("paused", true);
1400
1469
  }
1401
- /** Set volume for a channel */
1470
+ /** Resume only music */
1471
+ resumeMusic() {
1472
+ this.currentMusicInstance?.set("paused", false);
1473
+ }
1474
+ // ─── Volume ──────────────────────────────────────────
1402
1475
  setVolume(channel, value) {
1403
1476
  this.volumes[channel] = Math.max(0, Math.min(1, value));
1404
- this.updateMusicVolume();
1477
+ this.applyVolumes();
1478
+ this.saveSettings();
1405
1479
  }
1406
- /** Get volume for a channel */
1407
1480
  getVolume(channel) {
1408
1481
  return this.volumes[channel];
1409
1482
  }
1410
- updateMusicVolume() {
1411
- if (this.currentMusic) {
1412
- this.currentMusic.volume = this.volumes.master * this.volumes.music;
1483
+ /** Re-apply volume to all active sounds after a channel change */
1484
+ applyVolumes() {
1485
+ if (this.currentMusicInstance) {
1486
+ this.currentMusicInstance.volume = this.volumes.master * this.volumes.music;
1487
+ }
1488
+ for (const instances of this.activeSfx.values()) {
1489
+ for (const inst of instances) {
1490
+ inst.volume = this.volumes.master * this.volumes.sfx;
1491
+ }
1413
1492
  }
1414
1493
  }
1415
- /** Mute all sounds */
1494
+ // ─── Mute ────────────────────────────────────────────
1416
1495
  mute() {
1496
+ this._muted = true;
1417
1497
  import_sound.sound.muteAll();
1498
+ this.saveSettings();
1418
1499
  }
1419
- /** Unmute all sounds */
1420
1500
  unmute() {
1501
+ this._muted = false;
1421
1502
  import_sound.sound.unmuteAll();
1503
+ this.saveSettings();
1422
1504
  }
1423
- /** Toggle mute */
1424
1505
  toggleMute() {
1425
- import_sound.sound.toggleMuteAll();
1426
- return import_sound.sound.context.muted;
1506
+ if (this._muted) this.unmute();
1507
+ else this.mute();
1508
+ return this._muted;
1427
1509
  }
1428
- /** Check if muted */
1429
1510
  get isMuted() {
1430
- return import_sound.sound.context.muted;
1511
+ return this._muted;
1431
1512
  }
1432
- /** Clean up */
1513
+ // ─── Global controls ─────────────────────────────────
1514
+ stopAll() {
1515
+ import_sound.sound.stopAll();
1516
+ this.currentMusic = null;
1517
+ this.currentMusicAlias = null;
1518
+ this.currentMusicInstance = null;
1519
+ this.activeSfx.clear();
1520
+ }
1521
+ pauseAll() {
1522
+ import_sound.sound.pauseAll();
1523
+ }
1524
+ resumeAll() {
1525
+ import_sound.sound.resumeAll();
1526
+ }
1527
+ // ─── Persistence ─────────────────────────────────────
1528
+ saveSettings() {
1529
+ try {
1530
+ const data = {
1531
+ volumes: this.volumes,
1532
+ muted: this._muted
1533
+ };
1534
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
1535
+ } catch {
1536
+ }
1537
+ }
1538
+ loadSettings() {
1539
+ try {
1540
+ const raw = localStorage.getItem(STORAGE_KEY);
1541
+ if (!raw) return;
1542
+ const data = JSON.parse(raw);
1543
+ if (data.volumes) {
1544
+ this.volumes.master = data.volumes.master ?? 1;
1545
+ this.volumes.music = data.volumes.music ?? 1;
1546
+ this.volumes.sfx = data.volumes.sfx ?? 1;
1547
+ }
1548
+ if (data.muted) {
1549
+ this._muted = true;
1550
+ import_sound.sound.muteAll();
1551
+ }
1552
+ } catch {
1553
+ }
1554
+ }
1555
+ // ─── Fade utility ────────────────────────────────────
1556
+ fadeInstance(instance, from, to, duration, onComplete) {
1557
+ const steps = 30;
1558
+ const interval = duration * 1e3 / steps;
1559
+ const delta = (to - from) / steps;
1560
+ let step = 0;
1561
+ const timer = setInterval(() => {
1562
+ step++;
1563
+ instance.volume = from + delta * step;
1564
+ if (step >= steps) {
1565
+ clearInterval(timer);
1566
+ instance.volume = to;
1567
+ onComplete?.();
1568
+ }
1569
+ }, interval);
1570
+ }
1571
+ // ─── Cleanup ─────────────────────────────────────────
1433
1572
  destroy() {
1434
1573
  this.stopAll();
1574
+ document.removeEventListener("pointerdown", this.setupUnlockListener);
1575
+ document.removeEventListener("keydown", this.setupUnlockListener);
1435
1576
  }
1436
1577
  };
1437
1578
 
@@ -1453,10 +1594,32 @@ var SpineManager = class {
1453
1594
  fromData(skeletonData) {
1454
1595
  return new import_pixi_spine2.Spine(skeletonData);
1455
1596
  }
1456
- /** Play an animation on a Spine instance */
1457
- play(spine, animationName, loop = true, trackIndex = 0) {
1597
+ /** Play an animation on a Spine instance.
1598
+ * By default skips if the same animation is already playing (avoids restart).
1599
+ * Pass `force: true` to restart from frame 0. */
1600
+ play(spine, animationName, loop = true, trackIndex = 0, options) {
1601
+ if (!options?.force) {
1602
+ const current = spine.state.tracks[trackIndex];
1603
+ if (current?.animation?.name === animationName) return;
1604
+ }
1458
1605
  spine.state.setAnimation(trackIndex, animationName, loop);
1459
1606
  }
1607
+ /** Play a non-looping animation and resolve when it completes. */
1608
+ playOnce(spine, animationName, trackIndex = 0) {
1609
+ return new Promise((resolve) => {
1610
+ spine.state.setAnimation(trackIndex, animationName, false);
1611
+ const listener = {
1612
+ complete: (entry) => {
1613
+ const track = entry;
1614
+ if (track.animation?.name === animationName) {
1615
+ spine.state.removeListener(listener);
1616
+ resolve();
1617
+ }
1618
+ }
1619
+ };
1620
+ spine.state.addListener(listener);
1621
+ });
1622
+ }
1460
1623
  /** Add an animation to the queue */
1461
1624
  queue(spine, animationName, loop = false, delay = 0, trackIndex = 0) {
1462
1625
  spine.state.addAnimation(trackIndex, animationName, loop, delay);
@@ -1490,6 +1653,12 @@ var SpineManager = class {
1490
1653
  setSpeed(spine, speed) {
1491
1654
  spine.state.timeScale = speed;
1492
1655
  }
1656
+ /** Clear tracks, remove listeners, and destroy the spine instance. */
1657
+ destroy(spine) {
1658
+ spine.state.clearTracks();
1659
+ spine.state.clearListeners();
1660
+ spine.destroy({ children: true });
1661
+ }
1493
1662
  };
1494
1663
 
1495
1664
  // src/ui/toast/toast-manager.ts