asterism-games 0.20.0 → 0.22.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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -0
  3. data/README.md +67 -9
  4. data/VERSION +1 -1
  5. data/lib/asterism_games/commentate/assets/SOUNDS_LICENSE.txt +63 -0
  6. data/lib/asterism_games/commentate/assets/commentate.css +9 -4
  7. data/lib/asterism_games/commentate/assets/commentate.js +298 -7
  8. data/lib/asterism_games/commentate/assets/index.html +5 -3
  9. data/lib/asterism_games/commentate/assets/sounds/after_the_rain.mp3 +0 -0
  10. data/lib/asterism_games/commentate/assets/sounds/chi.wav +0 -0
  11. data/lib/asterism_games/commentate/assets/sounds/discard.wav +0 -0
  12. data/lib/asterism_games/commentate/assets/sounds/discard_other.wav +0 -0
  13. data/lib/asterism_games/commentate/assets/sounds/draw.wav +0 -0
  14. data/lib/asterism_games/commentate/assets/sounds/encounter.mp3 +0 -0
  15. data/lib/asterism_games/commentate/assets/sounds/fanfare1.wav +0 -0
  16. data/lib/asterism_games/commentate/assets/sounds/fanfare2.wav +0 -0
  17. data/lib/asterism_games/commentate/assets/sounds/fanfare3.wav +0 -0
  18. data/lib/asterism_games/commentate/assets/sounds/fanfare4.wav +0 -0
  19. data/lib/asterism_games/commentate/assets/sounds/kan.wav +0 -0
  20. data/lib/asterism_games/commentate/assets/sounds/kyuushu.wav +0 -0
  21. data/lib/asterism_games/commentate/assets/sounds/points.wav +0 -0
  22. data/lib/asterism_games/commentate/assets/sounds/pon.wav +0 -0
  23. data/lib/asterism_games/commentate/assets/sounds/riichi.wav +0 -0
  24. data/lib/asterism_games/commentate/assets/sounds/ron.wav +0 -0
  25. data/lib/asterism_games/commentate/assets/sounds/round.wav +0 -0
  26. data/lib/asterism_games/commentate/assets/sounds/ryuukyoku.wav +0 -0
  27. data/lib/asterism_games/commentate/assets/sounds/sounds.json +335 -0
  28. data/lib/asterism_games/commentate/assets/sounds/spring_thing.mp3 +0 -0
  29. data/lib/asterism_games/commentate/assets/sounds/tsumo.wav +0 -0
  30. data/lib/asterism_games/commentate/assets/sounds/turn.wav +0 -0
  31. data/lib/asterism_games/commentate/assets/sounds/yaku.wav +0 -0
  32. data/lib/asterism_games/commentate/mahjong.rb +11 -4
  33. data/lib/asterism_games/commentate/mahjong_sounds.rb +118 -0
  34. data/lib/asterism_games/commentate/server.rb +14 -2
  35. data/lib/asterism_games/commentate.rb +8 -2
  36. data/lib/asterism_games/play/mahjong.rb +3 -1
  37. metadata +29 -8
@@ -8,8 +8,14 @@
8
8
  // the browser's speech synthesis.
9
9
  // The AI's one-liners (l.ai) come after the fixed lines, marked "AI" in
10
10
  // the captions, and are the first to go when the voice falls behind.
11
- // Keys: Space pause, M voice, H hide the hands. URL parameters: table=<id>,
12
- // hands=0 (hidden), voice=0, obs=1 (no bar, a transparent background).
11
+ // The sound of Family mruby's mahjong app (README "The sound"): its BGM
12
+ // for the scene ("state"'s bgm) and its effects for each step ("state"'s
13
+ // sounds), files made with the app's own APU (/sounds/, sounds.json),
14
+ // played with Web Audio beside the voice; the BGM goes down while the
15
+ // voice speaks.
16
+ // Keys: Space pause, M voice, B BGM (off, low, mid, high), E effects,
17
+ // H hide the hands. URL parameters: table=<id>, hands=0 (hidden), voice=0,
18
+ // obs=1 (no bar, a transparent background).
13
19
  "use strict";
14
20
 
15
21
  (function () {
@@ -29,7 +35,7 @@
29
35
  voiceOn: params.get("voice") !== "0",
30
36
  unlocked: false, // the page was clicked: the browser lets it speak
31
37
  paused: false,
32
- held: { view: null, lines: [] },
38
+ held: { view: null, lines: [], bgm: null },
33
39
  connected: false,
34
40
  sheets: null,
35
41
  };
@@ -399,7 +405,7 @@
399
405
  const e = { t: Date.now(), kind: kind, id: item ? item.line.id : null, prio: item ? item.prio : null, src: src };
400
406
  played.push(e);
401
407
  if (played.length > 500) played.shift();
402
- if (window.console) console.log("voice", kind, e.id, src);
408
+ if (window.console) console.log("voice", kind, e.id, src, e.t);
403
409
  }
404
410
 
405
411
  // Queues the line's voice; false when nothing is said (the caption then
@@ -468,16 +474,20 @@
468
474
  if (!item) return;
469
475
  speech.speaking = true;
470
476
  speech.current = item;
477
+ sound.duck();
471
478
  showLine(item.line);
472
479
  sayItem(item).then(() => {
473
480
  if (speech.current !== item) return; // cut off (M, H)
474
481
  speech.current = null;
475
482
  speech.speaking = false;
483
+ sound.duck();
476
484
  setTimeout(next, 120);
477
485
  });
478
486
  }
479
487
 
480
488
  async function sayItem(item) {
489
+ await sound.afterJingle();
490
+ if (speech.current !== item) return;
481
491
  if (item.clips || item.blob) {
482
492
  for (const c of item.clips || []) {
483
493
  if (speech.current !== item) return;
@@ -536,6 +546,7 @@
536
546
  drop(() => true, "cut");
537
547
  speech.current = null;
538
548
  speech.speaking = false;
549
+ sound.duck();
539
550
  }
540
551
 
541
552
  function showVoice() {
@@ -559,17 +570,20 @@
559
570
  if (st.unlocked) return;
560
571
  st.unlocked = true;
561
572
  $("hint").hidden = true;
573
+ sound.start();
562
574
  // A first sound inside the gesture opens the way on some browsers.
563
575
  const v = vv();
564
576
  if (st.voiceOn && canSpeak()) {
565
577
  const item = { line: { id: 0 }, prio: 1, at: Date.now() };
566
578
  speech.speaking = true;
567
579
  speech.current = item;
580
+ sound.duck();
568
581
  const p = v && v.start ? (log("clip", item, v.start), playUrl(v.start)) : speak(item, "実況を始めます");
569
582
  p.then(() => {
570
583
  if (speech.current !== item) return;
571
584
  speech.current = null;
572
585
  speech.speaking = false;
586
+ sound.duck();
573
587
  next();
574
588
  });
575
589
  }
@@ -596,6 +610,270 @@
596
610
  }
597
611
  }
598
612
 
613
+ // ------------------------------------------------------------ the app's sound
614
+
615
+ // The BGM and the effects of Family mruby's mahjong app, as files made
616
+ // with its APU (sounds.json lists them: the effects' priorities and
617
+ // channels, the tunes' loops, the app's BGM levels and scenes). Web Audio,
618
+ // after the first click (the browser's autoplay rule): the BGM is the
619
+ // scene's tune from its start, its loop repeated without a seam; the
620
+ // levels are the app's (off, low, mid, high: low by default, as the
621
+ // app's), and while the voice speaks the BGM is at DUCK of its level
622
+ // (the effects stay as they are). An effect plays at once, beside the
623
+ // voice (not in its queue), and claims the APU channels it uses as the
624
+ // app's do: one of a higher priority still sounding on them keeps them
625
+ // (the new one is left out when it gets none), otherwise the older
626
+ // sounds on them stop. A voice that would start on top of a jingle
627
+ // waits for most of it (at most JINGLE_WAIT_MS). B and E switch, and the
628
+ // page remembers both (localStorage).
629
+ const sound = {
630
+ ctx: null,
631
+ manifest: null,
632
+ buffers: {}, // file => AudioBuffer (or a Promise of one)
633
+ bgmLevel: null,
634
+ seOn: true,
635
+ scene: "lobby",
636
+ bgm: null, // {key, src, gain} playing
637
+ bgmGain: null,
638
+ seGain: null,
639
+ ducked: false,
640
+ duckTimer: null,
641
+ holders: [null, null, null, null], // per APU channel: {pri, until, src}
642
+ jingleUntil: 0,
643
+ paused: false,
644
+ log: [],
645
+ DUCK: 0.5,
646
+ JINGLE_WAIT_MS: 600,
647
+ STORE: "asterism-games.commentate.",
648
+ };
649
+
650
+ sound.note = function (kind, name) {
651
+ const e = { t: Date.now(), kind: kind, name: name };
652
+ sound.log.push(e);
653
+ if (sound.log.length > 500) sound.log.shift();
654
+ if (window.console) console.log("sound", kind, name, e.t);
655
+ };
656
+
657
+ function stored(key) {
658
+ try { return window.localStorage.getItem(sound.STORE + key); } catch (e) { return null; }
659
+ }
660
+ function store(key, value) {
661
+ try { window.localStorage.setItem(sound.STORE + key, String(value)); } catch (e) { /* not kept */ }
662
+ }
663
+
664
+ sound.levels = function () { return (sound.manifest && sound.manifest.bgm_levels) || [0, 0.3, 0.55, 1]; };
665
+ sound.names = function () { return (sound.manifest && sound.manifest.bgm_names) || ["切", "小", "中", "大"]; };
666
+
667
+ sound.load = function () {
668
+ const lv = parseInt(stored("bgm"), 10);
669
+ sound.bgmLevel = lv >= 0 && lv <= 3 ? lv : null;
670
+ sound.seOn = stored("se") !== "0";
671
+ fetch("/sounds/sounds.json").then((r) => (r.ok ? r.json() : Promise.reject(new Error("HTTP " + r.status))))
672
+ .then((m) => {
673
+ sound.manifest = m;
674
+ if (sound.bgmLevel === null || sound.bgmLevel >= m.bgm_levels.length) sound.bgmLevel = m.bgm_default;
675
+ sound.show();
676
+ if (sound.ctx) { sound.preload(); sound.applyBgm(); }
677
+ })
678
+ .catch(() => { sound.manifest = null; sound.show(); });
679
+ sound.show();
680
+ };
681
+
682
+ // The first click: the context, the two volumes, the effects' files.
683
+ sound.start = function () {
684
+ if (sound.ctx) return;
685
+ const AC = window.AudioContext || window.webkitAudioContext;
686
+ if (!AC) { sound.show(); return; }
687
+ // 24 kHz is plenty for the APU's sound (15.72 kHz) and halves what the
688
+ // decoded tunes take; a browser that will not have it gets its own rate.
689
+ try { sound.ctx = new AC({ sampleRate: 24000 }); } catch (e) { sound.ctx = new AC(); }
690
+ sound.bgmGain = sound.ctx.createGain();
691
+ sound.bgmGain.gain.value = sound.bgmVolume();
692
+ sound.bgmGain.connect(sound.ctx.destination);
693
+ sound.seGain = sound.ctx.createGain();
694
+ sound.seGain.connect(sound.ctx.destination);
695
+ if (sound.ctx.state === "suspended") sound.ctx.resume().catch(() => {});
696
+ if (sound.manifest) { sound.preload(); sound.applyBgm(); }
697
+ sound.show();
698
+ };
699
+
700
+ sound.buffer = function (file) {
701
+ const b = sound.buffers[file];
702
+ if (b) return b instanceof Promise ? b : Promise.resolve(b);
703
+ const p = fetch("/sounds/" + file)
704
+ .then((r) => (r.ok ? r.arrayBuffer() : Promise.reject(new Error("HTTP " + r.status))))
705
+ .then((data) => new Promise((resolve, reject) => sound.ctx.decodeAudioData(data, resolve, reject)))
706
+ .then((buf) => { sound.buffers[file] = buf; return buf; })
707
+ .catch((e) => { delete sound.buffers[file]; sound.note("failed", file); throw e; });
708
+ sound.buffers[file] = p;
709
+ return p;
710
+ };
711
+
712
+ sound.preload = function () {
713
+ Object.keys(sound.manifest.effects).forEach((n) => sound.buffer(sound.manifest.effects[n].file).catch(() => {}));
714
+ };
715
+
716
+ sound.bgmVolume = function () {
717
+ const v = sound.levels()[sound.bgmLevel || 0] || 0;
718
+ return sound.ducked ? v * sound.DUCK : v;
719
+ };
720
+
721
+ sound.setScene = function (scene) {
722
+ if (scene === sound.scene) return;
723
+ sound.scene = scene;
724
+ sound.applyBgm();
725
+ };
726
+
727
+ // The tune of the scene, or none (off, the end of a game, paused).
728
+ sound.applyBgm = function () {
729
+ const m = sound.manifest;
730
+ if (!sound.ctx || !m) return;
731
+ let key = m.scenes[sound.scene] || null;
732
+ if (!key && sound.scene === "tense") key = m.scenes.play || null;
733
+ if (!sound.bgmLevel || sound.paused) key = null;
734
+ const cur = sound.bgm;
735
+ if (cur && cur.key === key) return;
736
+ if (cur) {
737
+ const t = sound.ctx.currentTime;
738
+ cur.gain.gain.setTargetAtTime(0, t, 0.03);
739
+ cur.src.stop(t + 0.2);
740
+ sound.bgm = null;
741
+ }
742
+ if (!key) return;
743
+ const tune = m.tunes[key];
744
+ const want = { key: key, src: null, gain: null };
745
+ sound.bgm = want;
746
+ sound.buffer(tune.file).then((buf) => {
747
+ if (sound.bgm !== want) return;
748
+ const src = sound.ctx.createBufferSource();
749
+ src.buffer = buf;
750
+ src.loop = true;
751
+ src.loopStart = tune.loop_start;
752
+ src.loopEnd = tune.loop_start + tune.loop_s;
753
+ const g = sound.ctx.createGain();
754
+ src.connect(g);
755
+ g.connect(sound.bgmGain);
756
+ src.start();
757
+ want.src = src;
758
+ want.gain = g;
759
+ sound.note("bgm", key);
760
+ }).catch(() => { if (sound.bgm === want) sound.bgm = null; });
761
+ };
762
+
763
+ // An effect of the app (name: sounds.json's effects), delay ms from now.
764
+ sound.effect = function (name, delay) {
765
+ if (!sound.ctx || !sound.seOn || sound.paused || !sound.manifest) return;
766
+ const e = sound.manifest.effects[name];
767
+ if (!e) return;
768
+ const go = () => {
769
+ if (!sound.seOn || sound.paused) return;
770
+ sound.buffer(e.file).then((buf) => sound.play(name, e, buf)).catch(() => {});
771
+ };
772
+ if (delay > 0) setTimeout(go, delay); else go();
773
+ };
774
+
775
+ sound.play = function (name, e, buf) {
776
+ const now = Date.now();
777
+ const free = e.channels.filter((ch) => {
778
+ const h = sound.holders[ch];
779
+ return !h || h.until <= now || e.priority >= h.pri;
780
+ });
781
+ if (!free.length) { sound.note("left out", name); return; }
782
+ const src = sound.ctx.createBufferSource();
783
+ src.buffer = buf;
784
+ src.connect(sound.seGain);
785
+ const until = now + buf.duration * 1000;
786
+ free.forEach((ch) => {
787
+ const h = sound.holders[ch];
788
+ if (h && h.until > now && h.src !== src) {
789
+ try { h.src.stop(); } catch (x) { /* ended */ }
790
+ }
791
+ sound.holders[ch] = { pri: e.priority, until: until, src: src };
792
+ });
793
+ src.start();
794
+ if (e.priority >= 3) sound.jingleUntil = Math.max(sound.jingleUntil, until);
795
+ sound.note("sfx", name);
796
+ };
797
+
798
+ // Resolves when a voice may start: after most of a sounding jingle.
799
+ sound.afterJingle = function () {
800
+ const wait = Math.min(sound.JINGLE_WAIT_MS, sound.jingleUntil - Date.now() - 120);
801
+ return wait > 0 ? new Promise((resolve) => setTimeout(resolve, wait)) : Promise.resolve();
802
+ };
803
+
804
+ // The BGM down while the voice speaks; up again a moment after it stops
805
+ // (not between two lines).
806
+ sound.duck = function () {
807
+ const on = speech.speaking;
808
+ if (on) {
809
+ if (sound.duckTimer) { clearTimeout(sound.duckTimer); sound.duckTimer = null; }
810
+ if (!sound.ducked) { sound.ducked = true; sound.volume(0.05); sound.note("duck", "down"); }
811
+ } else if (sound.ducked && !sound.duckTimer) {
812
+ sound.duckTimer = setTimeout(() => {
813
+ sound.duckTimer = null;
814
+ if (speech.speaking) return;
815
+ sound.ducked = false;
816
+ sound.volume(0.25);
817
+ sound.note("duck", "up");
818
+ }, 600);
819
+ }
820
+ };
821
+
822
+ sound.volume = function (tc) {
823
+ if (!sound.bgmGain) return;
824
+ const t = sound.ctx.currentTime;
825
+ sound.bgmGain.gain.cancelScheduledValues(t);
826
+ sound.bgmGain.gain.setTargetAtTime(sound.bgmVolume(), t, tc || 0.05);
827
+ };
828
+
829
+ sound.cycleBgm = function () {
830
+ const n = sound.levels().length;
831
+ sound.bgmLevel = ((sound.bgmLevel || 0) + 1) % n;
832
+ store("bgm", sound.bgmLevel);
833
+ sound.volume(0.05);
834
+ sound.applyBgm();
835
+ sound.show();
836
+ };
837
+
838
+ sound.toggleSe = function () {
839
+ sound.seOn = !sound.seOn;
840
+ store("se", sound.seOn ? 1 : 0);
841
+ if (!sound.seOn) {
842
+ sound.holders.forEach((h) => { if (h) { try { h.src.stop(); } catch (x) { /* ended */ } } });
843
+ sound.holders = [null, null, null, null];
844
+ }
845
+ sound.show();
846
+ };
847
+
848
+ sound.pause = function (p) {
849
+ sound.paused = p;
850
+ if (!sound.ctx) return;
851
+ if (p) sound.ctx.suspend().catch(() => {});
852
+ else sound.ctx.resume().catch(() => {});
853
+ };
854
+
855
+ sound.show = function () {
856
+ const b = $("bgm");
857
+ const e = $("se");
858
+ const credit = $("bgmcredit");
859
+ if (!sound.manifest) {
860
+ b.textContent = "BGM: なし";
861
+ b.className = "item";
862
+ e.textContent = "";
863
+ credit.hidden = true;
864
+ return;
865
+ }
866
+ const lv = sound.bgmLevel || 0;
867
+ const wait = st.unlocked ? "" : " (クリック待ち)";
868
+ b.textContent = "BGM " + sound.names()[lv] + " [B]" + (lv ? wait : "");
869
+ b.className = "item btn" + (lv ? " on" : "");
870
+ e.textContent = "効果音 " + (sound.seOn ? "入" : "切") + " [E]";
871
+ e.className = "item btn" + (sound.seOn ? " on" : "");
872
+ credit.hidden = !lv;
873
+ };
874
+ $("bgm").addEventListener("click", () => sound.cycleBgm());
875
+ $("se").addEventListener("click", () => sound.toggleSe());
876
+
599
877
  // ------------------------------------------------------------ the bar
600
878
 
601
879
  function showBar() {
@@ -627,6 +905,7 @@
627
905
  $("conn").className = "item warn";
628
906
  showVoice();
629
907
  showAI();
908
+ sound.show();
630
909
  }
631
910
 
632
911
  $("tables").addEventListener("change", (e) => {
@@ -644,6 +923,7 @@
644
923
 
645
924
  document.addEventListener("keydown", (e) => {
646
925
  if (e.target && e.target.tagName === "SELECT") return;
926
+ if (e.ctrlKey || e.metaKey || e.altKey) return;
647
927
  if (e.key === " ") {
648
928
  e.preventDefault();
649
929
  setPaused(!st.paused);
@@ -652,6 +932,12 @@
652
932
  if (!st.voiceOn) cut();
653
933
  unlock();
654
934
  showVoice();
935
+ } else if (e.key === "b" || e.key === "B") {
936
+ sound.cycleBgm();
937
+ unlock();
938
+ } else if (e.key === "e" || e.key === "E") {
939
+ sound.toggleSe();
940
+ unlock();
655
941
  } else if (e.key === "h" || e.key === "H") {
656
942
  st.hide = !st.hide;
657
943
  cut();
@@ -670,10 +956,12 @@
670
956
  st.paused = p;
671
957
  if (synth) { if (p) synth.pause(); else synth.resume(); }
672
958
  if (speech.audio) { if (p) speech.audio.pause(); else speech.audio.play().catch(() => {}); }
959
+ sound.pause(p);
673
960
  if (!p) {
674
961
  if (st.held.view) st.view = st.held.view;
962
+ if (st.held.bgm) sound.setScene(st.held.bgm);
675
963
  const held = st.held.lines;
676
- st.held = { view: null, lines: [] };
964
+ st.held = { view: null, lines: [], bgm: null };
677
965
  const big = held.filter((l) => (l.prio || 0) >= 3).pop();
678
966
  held.forEach((l) => { st.lines.push(l); if (l !== big) addCaption(l); });
679
967
  if (big && !say(big)) addCaption(big);
@@ -707,8 +995,10 @@
707
995
  const d = JSON.parse(e.data);
708
996
  if (st.table && d.table !== st.table) return;
709
997
  if (!st.table) st.table = d.table;
710
- if (st.paused) { st.held.view = d.view; return; }
998
+ if (st.paused) { st.held.view = d.view; st.held.bgm = d.bgm; return; }
711
999
  st.view = d.view;
1000
+ sound.setScene(d.bgm || "lobby");
1001
+ (d.sounds || []).forEach((x) => sound.effect(x[0], x[1]));
712
1002
  draw();
713
1003
  showBar();
714
1004
  });
@@ -730,6 +1020,7 @@
730
1020
  draw();
731
1021
  showBar();
732
1022
  connect();
1023
+ sound.load();
733
1024
  // For a test or a tool driving the page.
734
- window.commentate = { state: st, played: played, speech: speech };
1025
+ window.commentate = { state: st, played: played, speech: speech, sound: sound };
735
1026
  })();
@@ -11,16 +11,18 @@
11
11
  <span id="tname" class="item strong">卓を待っています</span>
12
12
  <select id="tables" class="item" title="卓" hidden></select>
13
13
  <span id="voice" class="item" title="M で切り替え">声: 切</span>
14
+ <span id="bgm" class="item btn" role="button" title="B で切り替え (切/小/中/大)">BGM 小 [B]</span>
15
+ <span id="se" class="item btn" role="button" title="E で切り替え">効果音 入 [E]</span>
14
16
  <span id="ai" class="item"></span>
15
17
  <span id="delay" class="item"></span>
16
18
  <span id="mode" class="item"></span>
17
19
  <span id="conn" class="item"></span>
18
20
  </header>
19
- <div id="hint" role="button" tabindex="0">画面をクリックすると実況の声が出ます (ブラウザの自動再生の決まり)</div>
21
+ <div id="hint" role="button" tabindex="0">画面をクリックすると実況の声と音が出ます (ブラウザの自動再生の決まり)</div>
20
22
  <main id="stage"><canvas id="table" aria-label="卓"></canvas></main>
21
23
  <section id="captions" aria-live="polite"></section>
22
- <footer id="keys">Space 一時停止 / M 声 / H 手牌を隠す</footer>
23
- <div id="credit" hidden></div>
24
+ <footer id="keys">Space 一時停止 / M 声 / B BGM / E 効果音 / H 手牌を隠す</footer>
25
+ <div id="credits"><span id="credit" hidden></span><span id="bgmcredit" hidden>BGM: shiru8bit (CC BY 3.0)</span></div>
24
26
  <script src="/commentate.js"></script>
25
27
  </body>
26
28
  </html>