@onnie81/murdoku-spor 1.2.0 → 1.2.2

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.
@@ -3,7 +3,7 @@
3
3
  // copyright entanglements. Autoplay-safe: the context starts on first gesture.
4
4
 
5
5
  let ctx = null;
6
- let master, sfxBus, musicBus;
6
+ let master, sfxBus, musicBus, analyser;
7
7
  let enabled = { sfx: true, music: true };
8
8
  let musicOn = false;
9
9
  let schedTimer = null;
@@ -16,6 +16,9 @@ function ensureCtx() {
16
16
  master = ctx.createGain();
17
17
  master.gain.value = 0.9;
18
18
  master.connect(ctx.destination);
19
+ analyser = ctx.createAnalyser();
20
+ analyser.fftSize = 2048;
21
+ master.connect(analyser);
19
22
  sfxBus = ctx.createGain();
20
23
  sfxBus.gain.value = 0.5;
21
24
  sfxBus.connect(master);
@@ -37,18 +40,46 @@ function ensureCtx() {
37
40
  return ctx;
38
41
  }
39
42
 
43
+ // iOS: playing a (nearly) silent media element inside the gesture switches the
44
+ // audio session to "playback", so WebAudio stays audible even with the ring
45
+ // switch on silent. One-time, invisible.
46
+ const SILENT_WAV = 'data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAIA+AAACABAAZGF0YQAAAAA=';
47
+ let mediaUnlocked = false;
48
+ function unlockMediaSession() {
49
+ if (mediaUnlocked) return;
50
+ mediaUnlocked = true;
51
+ try {
52
+ const a = document.createElement('audio');
53
+ a.setAttribute('playsinline', '');
54
+ a.src = SILENT_WAV;
55
+ a.volume = 0.01;
56
+ const p = a.play();
57
+ if (p && p.catch) p.catch(() => { mediaUnlocked = false; });
58
+ } catch { mediaUnlocked = false; }
59
+ }
60
+
40
61
  export function initAudioOnGesture() {
62
+ const EVENTS = ['pointerdown', 'touchend', 'mousedown', 'keydown'];
41
63
  const kick = () => {
42
64
  const c = ensureCtx();
43
65
  if (!c) return;
44
- const go = () => { if (enabled.music) startMusic(); };
66
+ unlockMediaSession();
67
+ const go = () => {
68
+ if (c.state !== 'running') return; // keep listeners until truly unlocked
69
+ EVENTS.forEach(ev => window.removeEventListener(ev, kick));
70
+ if (enabled.music) startMusic();
71
+ };
45
72
  if (c.state === 'suspended') c.resume().then(go).catch(() => {});
46
73
  else go();
47
- window.removeEventListener('pointerdown', kick);
48
- window.removeEventListener('keydown', kick);
49
74
  };
50
- window.addEventListener('pointerdown', kick);
51
- window.addEventListener('keydown', kick);
75
+ EVENTS.forEach(ev => window.addEventListener(ev, kick));
76
+
77
+ // phones suspend the context when the tab goes to background — resume on return
78
+ document.addEventListener('visibilitychange', () => {
79
+ if (!document.hidden && ctx && ctx.state === 'suspended') {
80
+ ctx.resume().then(() => { if (enabled.music) startMusic(); }).catch(() => {});
81
+ }
82
+ });
52
83
  }
53
84
 
54
85
  export function setAudioPrefs({ sfx, music }) {
@@ -264,3 +295,21 @@ export function stopMusic() {
264
295
  musicBus.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 0.8);
265
296
  }
266
297
  }
298
+
299
+ // diagnostics hook (used by the e2e test and handy in devtools)
300
+ if (typeof window !== 'undefined') {
301
+ window.__mdkAudio = {
302
+ state: () => (ctx ? ctx.state : 'none'),
303
+ musicOn: () => musicOn,
304
+ enabled: () => ({ ...enabled }),
305
+ rms() {
306
+ if (!analyser) return 0;
307
+ const a = new Float32Array(analyser.fftSize);
308
+ analyser.getFloatTimeDomainData(a);
309
+ let s = 0;
310
+ for (let i = 0; i < a.length; i++) s += a[i] * a[i];
311
+ return Math.sqrt(s / a.length);
312
+ },
313
+ ping: () => sfx('chime')
314
+ };
315
+ }
@@ -5,7 +5,7 @@
5
5
  import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react';
6
6
  import { api } from '../api.js';
7
7
  import { createGameStore, useGame } from '../game.js';
8
- import { useApp, navigate, toast, getState } from '../store.js';
8
+ import { useApp, navigate, toast, getState, updateSettings } from '../store.js';
9
9
  import { Board } from './Board.jsx';
10
10
  import { Tray } from './Tray.jsx';
11
11
  import { CluePanel } from './CluePanel.jsx';
@@ -44,10 +44,13 @@ export function PlayView({ gameId }) {
44
44
  }
45
45
 
46
46
  function PlayInner({ loaded }) {
47
+ const app = useApp();
47
48
  const store = useMemo(
48
49
  () => createGameStore(loaded.game, loaded.puzzle, () => getState().settings, (key) => toast(t(key))),
49
50
  [loaded]
50
51
  );
52
+ const soundOn = app.settings.sfx !== false || app.settings.music !== false;
53
+ const soundToggle = () => updateSettings({ sfx: !soundOn, music: !soundOn });
51
54
  const snap = useGame(store);
52
55
  const boardRef = useRef(null);
53
56
  const [drag, setDrag] = useState(null);
@@ -294,6 +297,7 @@ function PlayInner({ loaded }) {
294
297
  <span className="stat-mist">✗ {snap.mistakes}</span>
295
298
  </div>
296
299
  <button className="icon-btn" onClick={() => setCaseOpen(true)} aria-label={t('caseFile')}>📜</button>
300
+ <button className="icon-btn" onClick={soundToggle} aria-label={t('sound')}>{soundOn ? '🔊' : '🔇'}</button>
297
301
  {fsAvailable && (
298
302
  <button className="icon-btn" onClick={fsToggle} aria-label={t('fullscreen')}>{isFs ? '🡼' : '⛶'}</button>
299
303
  )}
@@ -358,6 +362,7 @@ function PlayInner({ loaded }) {
358
362
  <span className="stat-time">{fmtTime(snap.timeMs)}</span>
359
363
  <span className="stat-mist" title={t('mistakes')}>✗ {snap.mistakes}</span>
360
364
  </div>
365
+ <button className="icon-btn" onClick={soundToggle} aria-label={t('sound')}>{soundOn ? '🔊' : '🔇'}</button>
361
366
  {fsAvailable && (
362
367
  <button className="icon-btn" onClick={fsToggle} aria-label={t('fullscreen')}>{isFs ? '🡼' : '⛶'}</button>
363
368
  )}
package/murdoku.bundle CHANGED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onnie81/murdoku-spor",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
4
4
  "type": "module",
5
5
  "description": "Murdoku — online murder-mystery logic puzzle (sudoku-style placement deduction). Deploy artifact for games.onniehex.dev.",
6
6
  "license": "MIT",
@@ -32,6 +32,12 @@ mkdir -p "$APP"
32
32
  find "$APP" -mindepth 1 -maxdepth 1 ! -name .git ! -name .version -exec rm -rf {} +
33
33
  cp -a "$src"/. "$APP"/
34
34
 
35
+ # the deploy runner updates itself from the package (effective next run)
36
+ if [ -f "$APP/scripts/npm-server-deploy.sh" ] && ! cmp -s "$APP/scripts/npm-server-deploy.sh" /usr/local/bin/murdoku-deploy; then
37
+ install -m 755 "$APP/scripts/npm-server-deploy.sh" /usr/local/bin/murdoku-deploy || true
38
+ echo "$(date -Is) deploy runner self-updated"
39
+ fi
40
+
35
41
  # refresh real git history from the bundle carried in the package (optional)
36
42
  if command -v git >/dev/null 2>&1 && [ -f "$src/murdoku.bundle" ]; then
37
43
  if [ ! -d "$APP/.git" ]; then git -C "$APP" init -q; fi
@@ -43,7 +49,11 @@ cd "$APP"
43
49
  GIT_SHA=$(git rev-parse --short main 2>/dev/null || echo "$ver")
44
50
  export GIT_SHA
45
51
  if docker compose version >/dev/null 2>&1; then DC="docker compose"; else DC="docker-compose"; fi
46
- $DC up -d --build
52
+ if ! $DC up -d --build --remove-orphans; then
53
+ echo "$(date -Is) compose up failed — clearing stray containers and retrying"
54
+ docker rm -f murdoku murdoku-caddy >/dev/null 2>&1 || true
55
+ $DC up -d --build --remove-orphans
56
+ fi
47
57
 
48
58
  ok=0
49
59
  for _ in $(seq 1 45); do