asterism-games 0.20.0 → 0.21.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 +29 -0
  3. data/README.md +65 -7
  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 +27 -2
@@ -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>
@@ -0,0 +1,335 @@
1
+ {
2
+ "made_by": "fmruby-core tools/mahjong_sounds.rb (Family mruby's APU, components/apu_emu)",
3
+ "from": {
4
+ "flash/app/game/mahjong/mahjong_sound.rb": {
5
+ "SFX": "a8739fe35e6aa353a1bca456b26bd90f706ce1cbd0b43824f1531f890048e2bd"
6
+ },
7
+ "flash/usr/share/sounds/nsf/after_the_rain.nsf": "2d12f597da28207ae72d6ef82ceda6e7a0c32268d09e94328e499a9c6dff1023",
8
+ "flash/usr/share/sounds/nsf/spring_thing.nsf": "7541ff743d241752fa0b496b1f6ed7146526208c5a4b0d59cff8f8e8d55e45ac",
9
+ "flash/usr/share/sounds/nsf/encounter.nsf": "d11c34896b7a60a325e7296499e3b58bb4df04dec2d26bdd15bed178baf551a7"
10
+ },
11
+ "rate": 15720,
12
+ "bgm_levels": [
13
+ 0.0,
14
+ 0.3,
15
+ 0.55,
16
+ 1.0
17
+ ],
18
+ "bgm_names": [
19
+ "切",
20
+ "小",
21
+ "中",
22
+ "大"
23
+ ],
24
+ "bgm_default": 1,
25
+ "tense_left": 10,
26
+ "scenes": {
27
+ "lobby": "after_the_rain",
28
+ "play": "spring_thing",
29
+ "tense": "encounter"
30
+ },
31
+ "tunes": {
32
+ "after_the_rain": {
33
+ "file": "after_the_rain.mp3",
34
+ "nsf": "after_the_rain.nsf",
35
+ "track": 0,
36
+ "title": "8-bit Chiptune - After The Rain",
37
+ "author": "shiru8bit",
38
+ "license": "CC BY 3.0",
39
+ "intro_s": 0.0167,
40
+ "loop_s": 153.6,
41
+ "loop_start": 3.083333,
42
+ "loop_end": 156.683333,
43
+ "length_s": 157.6833
44
+ },
45
+ "spring_thing": {
46
+ "file": "spring_thing.mp3",
47
+ "nsf": "spring_thing.nsf",
48
+ "track": 0,
49
+ "title": "8-bit Chiptune - Spring Thing",
50
+ "author": "shiru8bit",
51
+ "license": "CC BY 3.0",
52
+ "intro_s": 1.9167,
53
+ "loop_s": 115.2,
54
+ "loop_start": 7.183333,
55
+ "loop_end": 122.383333,
56
+ "length_s": 123.3833
57
+ },
58
+ "encounter": {
59
+ "file": "encounter.mp3",
60
+ "nsf": "encounter.nsf",
61
+ "track": 0,
62
+ "title": "8-bit Chiptune - Encounter",
63
+ "author": "shiru8bit",
64
+ "license": "CC BY 3.0",
65
+ "intro_s": 0.0,
66
+ "loop_s": 128.0,
67
+ "loop_start": 1.116667,
68
+ "loop_end": 129.116667,
69
+ "length_s": 130.1167
70
+ }
71
+ },
72
+ "effects": {
73
+ "discard": {
74
+ "file": "discard.wav",
75
+ "priority": 1,
76
+ "channels": [
77
+ 3
78
+ ],
79
+ "length_s": 0.083
80
+ },
81
+ "discard_other": {
82
+ "file": "discard_other.wav",
83
+ "priority": 1,
84
+ "channels": [
85
+ 3
86
+ ],
87
+ "length_s": 0.05
88
+ },
89
+ "draw": {
90
+ "file": "draw.wav",
91
+ "priority": 1,
92
+ "channels": [
93
+ 1
94
+ ],
95
+ "length_s": 0.083
96
+ },
97
+ "turn": {
98
+ "file": "turn.wav",
99
+ "priority": 2,
100
+ "channels": [
101
+ 0
102
+ ],
103
+ "length_s": 0.2
104
+ },
105
+ "round": {
106
+ "file": "round.wav",
107
+ "priority": 3,
108
+ "channels": [
109
+ 0,
110
+ 2
111
+ ],
112
+ "length_s": 0.545
113
+ },
114
+ "yaku": {
115
+ "file": "yaku.wav",
116
+ "priority": 2,
117
+ "channels": [
118
+ 0
119
+ ],
120
+ "length_s": 0.117
121
+ },
122
+ "points": {
123
+ "file": "points.wav",
124
+ "priority": 3,
125
+ "channels": [
126
+ 0,
127
+ 1,
128
+ 2
129
+ ],
130
+ "length_s": 0.476
131
+ },
132
+ "riichi": {
133
+ "file": "riichi.wav",
134
+ "priority": 3,
135
+ "channels": [
136
+ 0,
137
+ 1
138
+ ],
139
+ "length_s": 0.35
140
+ },
141
+ "pon": {
142
+ "file": "pon.wav",
143
+ "priority": 3,
144
+ "channels": [
145
+ 0,
146
+ 3
147
+ ],
148
+ "length_s": 0.183
149
+ },
150
+ "chi": {
151
+ "file": "chi.wav",
152
+ "priority": 3,
153
+ "channels": [
154
+ 0
155
+ ],
156
+ "length_s": 0.167
157
+ },
158
+ "kan": {
159
+ "file": "kan.wav",
160
+ "priority": 3,
161
+ "channels": [
162
+ 0,
163
+ 2
164
+ ],
165
+ "length_s": 0.374
166
+ },
167
+ "ron": {
168
+ "file": "ron.wav",
169
+ "priority": 3,
170
+ "channels": [
171
+ 3,
172
+ 0,
173
+ 1,
174
+ 2
175
+ ],
176
+ "length_s": 0.599
177
+ },
178
+ "tsumo": {
179
+ "file": "tsumo.wav",
180
+ "priority": 3,
181
+ "channels": [
182
+ 0,
183
+ 1,
184
+ 2
185
+ ],
186
+ "length_s": 0.532
187
+ },
188
+ "kyuushu": {
189
+ "file": "kyuushu.wav",
190
+ "priority": 3,
191
+ "channels": [
192
+ 0,
193
+ 2
194
+ ],
195
+ "length_s": 0.464
196
+ },
197
+ "ryuukyoku": {
198
+ "file": "ryuukyoku.wav",
199
+ "priority": 3,
200
+ "channels": [
201
+ 0,
202
+ 2
203
+ ],
204
+ "length_s": 0.841
205
+ },
206
+ "fanfare1": {
207
+ "file": "fanfare1.wav",
208
+ "priority": 4,
209
+ "channels": [
210
+ 0,
211
+ 1,
212
+ 2
213
+ ],
214
+ "length_s": 1.188
215
+ },
216
+ "fanfare2": {
217
+ "file": "fanfare2.wav",
218
+ "priority": 4,
219
+ "channels": [
220
+ 0,
221
+ 1,
222
+ 2
223
+ ],
224
+ "length_s": 0.783
225
+ },
226
+ "fanfare3": {
227
+ "file": "fanfare3.wav",
228
+ "priority": 4,
229
+ "channels": [
230
+ 0,
231
+ 2
232
+ ],
233
+ "length_s": 0.853
234
+ },
235
+ "fanfare4": {
236
+ "file": "fanfare4.wav",
237
+ "priority": 4,
238
+ "channels": [
239
+ 0,
240
+ 2
241
+ ],
242
+ "length_s": 1.06
243
+ }
244
+ },
245
+ "files": {
246
+ "after_the_rain.mp3": {
247
+ "sha256": "031f62d6d389cf972ca4d76d4f60eb39aaf81abfddb8cd9bfdadb6a9ee2d0c36",
248
+ "bytes": 1262208
249
+ },
250
+ "chi.wav": {
251
+ "sha256": "4dc17f9942b7b24c0516e63722edb0337b8a08878436c541a25b008afffd93db",
252
+ "bytes": 5286
253
+ },
254
+ "discard.wav": {
255
+ "sha256": "b7c7e6d9d85c095086bbb363878f2d43718852faa760ec9a80c22a278af25c3d",
256
+ "bytes": 2664
257
+ },
258
+ "discard_other.wav": {
259
+ "sha256": "827a02bcc0b4b562c0e5570fbdefa7380c1d93f08d891c5a25b582d78eebc19b",
260
+ "bytes": 1616
261
+ },
262
+ "draw.wav": {
263
+ "sha256": "109f99db18e74a70c658651fde27080ca3d10e56ad6af9c4923fa79b3cc55c5f",
264
+ "bytes": 2666
265
+ },
266
+ "encounter.mp3": {
267
+ "sha256": "d266523c8a39b9df416ba0e279b3d408ed53aa9cb9eb4dd91c234f3e81ab7e70",
268
+ "bytes": 1041600
269
+ },
270
+ "fanfare1.wav": {
271
+ "sha256": "1c033836febf9f7fd4453c33233fd23103f4ad0f86caf385faf86227e38d2a90",
272
+ "bytes": 37404
273
+ },
274
+ "fanfare2.wav": {
275
+ "sha256": "7c5cf3343d909d0fd0c0bc7218ef27b824d3ee3b217ed3b80ada4141813dd9b3",
276
+ "bytes": 24652
277
+ },
278
+ "fanfare3.wav": {
279
+ "sha256": "1bf38ce91857961b980d268adb8898a5242c159d1d4f00a5e8243fc919b94c57",
280
+ "bytes": 26872
281
+ },
282
+ "fanfare4.wav": {
283
+ "sha256": "f75997ee293281fa8b5c5dc0a33980def453f6a889cba16a36cb2449e3ef87c1",
284
+ "bytes": 33362
285
+ },
286
+ "kan.wav": {
287
+ "sha256": "50ac3498c781e1c7cfd1275c9625167650d1b1390719bc544dc6e90aa73e06f9",
288
+ "bytes": 11790
289
+ },
290
+ "kyuushu.wav": {
291
+ "sha256": "73d5c7a57ad5dd109df0da1cde6364943ac9b956233cac1336c5d5d517bcf7af",
292
+ "bytes": 14626
293
+ },
294
+ "points.wav": {
295
+ "sha256": "2b6167f3bc36339118e30b290a8a2fe9b5069da5cb2b6160b1be89db24546296",
296
+ "bytes": 15004
297
+ },
298
+ "pon.wav": {
299
+ "sha256": "cf904f8855444198c89a76d76acf1041efc42dd888b3ad0f1f964099ab1ee8d8",
300
+ "bytes": 5810
301
+ },
302
+ "riichi.wav": {
303
+ "sha256": "e8d8064abba1da685453bc0fb30d938a2da5ad69aef69e4993459676c1a75e65",
304
+ "bytes": 11050
305
+ },
306
+ "ron.wav": {
307
+ "sha256": "aaec44cfe51ffb0219e6936cd0642e0b8b972b0f4e21625fb54d1e36028e4029",
308
+ "bytes": 18892
309
+ },
310
+ "round.wav": {
311
+ "sha256": "f2857dd2e45e0a1e130e63285119e3072a35056f571954f95665cce81b7047d4",
312
+ "bytes": 17164
313
+ },
314
+ "ryuukyoku.wav": {
315
+ "sha256": "105f366cf26b8be5e8aa210886c05b91965e566ecf610a64b4b5b63db452bff7",
316
+ "bytes": 26500
317
+ },
318
+ "spring_thing.mp3": {
319
+ "sha256": "55f90585c6994293d33ece9145b9a7f58c457a86a9b466c52882997a12fb87db",
320
+ "bytes": 987648
321
+ },
322
+ "tsumo.wav": {
323
+ "sha256": "2f849a68fe071355714322e0e5693c529067621c7030252ad6efce69f7ef5750",
324
+ "bytes": 16766
325
+ },
326
+ "turn.wav": {
327
+ "sha256": "a2813659474f629ea8fc88e5dc4eae70ba662d8bbe569178089522c929edd0c4",
328
+ "bytes": 6334
329
+ },
330
+ "yaku.wav": {
331
+ "sha256": "b19fbb15ad4ee6125bfa9ebe31c3701d6a25551a2bdb7e490f68c1f26dd3d8a5",
332
+ "bytes": 3714
333
+ }
334
+ }
335
+ }
@@ -4,6 +4,7 @@ require "json"
4
4
  require "monitor"
5
5
  require_relative "mahjong_events"
6
6
  require_relative "mahjong_phrases"
7
+ require_relative "mahjong_sounds"
7
8
  require_relative "server"
8
9
  require_relative "voice"
9
10
  require_relative "ai_comment"
@@ -68,6 +69,7 @@ module AsterismGames
68
69
  t["gone"] = false
69
70
  t["at"] = @clock.call
70
71
  evs, t["memo"] = MahjongEvents.diff(t["view"], view, t["memo"])
72
+ sounds = MahjongSounds.effects(t["view"], view)
71
73
  evs.each do |ev|
72
74
  l = MahjongPhrases.line(ev, view, @rng, tone: @tone)
73
75
  l = public_line(l) if @public
@@ -82,8 +84,7 @@ module AsterismGames
82
84
  end
83
85
  t["lines"].shift while t["lines"].size > KEEP_LINES
84
86
  t["view"] = view
85
- out = shown(view)
86
- frames << [id, Server.frame("state", { "table" => id, "view" => out })]
87
+ frames << [id, Server.frame("state", state_frame(id, view).merge("sounds" => sounds))]
87
88
  lines.each { |l| frames << [id, Server.frame("line", l)] }
88
89
  broadcast_status if fresh || was_gone
89
90
  frames.each { |tid, f| each_client(tid) { |c| c.push(f) } }
@@ -182,7 +183,7 @@ module AsterismGames
182
183
  out = [["status", status]]
183
184
  t = @tables[client.table]
184
185
  if t
185
- out << ["state", { "table" => client.table, "view" => shown(t["view"]) }]
186
+ out << ["state", state_frame(client.table, t["view"])]
186
187
  t["lines"].each { |l| out << ["line", l.merge("old" => true)] }
187
188
  end
188
189
  out
@@ -226,7 +227,7 @@ module AsterismGames
226
227
  if c.table.nil? && (d = default_table)
227
228
  c.table = d
228
229
  t = @tables[d]
229
- c.push(Server.frame("state", { "table" => d, "view" => shown(t["view"]) })) if t["view"]
230
+ c.push(Server.frame("state", state_frame(d, t["view"]))) if t["view"]
230
231
  end
231
232
  c.push(f)
232
233
  end
@@ -239,6 +240,12 @@ module AsterismGames
239
240
  end
240
241
  end
241
242
 
243
+ # A "state" event: the view as it leaves, and the BGM's scene for it
244
+ # (MahjongSounds; update adds the effects of the step).
245
+ def state_frame(id, view)
246
+ { "table" => id, "view" => shown(view), "bgm" => MahjongSounds.scene(view) }
247
+ end
248
+
242
249
  # The view as it leaves: without the hands with --public.
243
250
  def shown(view)
244
251
  return view unless @public && view.is_a?(Hash)
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsterismGames
4
+ module Commentate
5
+ # The sound of Family mruby's mahjong app at the commentary page
6
+ # (README "Commentary", "The sound"): which of the app's effects a new
7
+ # spectate view calls for, and the scene of the BGM. Pure functions, as
8
+ # MahjongEvents: the same two views always give the same sounds.
9
+ #
10
+ # sounds = MahjongSounds.effects(prev, cur) # [[name, delay_ms], ...]
11
+ # scene = MahjongSounds.scene(cur) # "lobby", "play", "tense" or "none"
12
+ #
13
+ # The names are the app's (mahjong_sound.rb's SFX, the files in
14
+ # assets/sounds/, sounds.json). The page hears the table as the app's
15
+ # spectator does: a discard is discard_other whoever made it, and the
16
+ # player's own sounds (a draw, the turn coming) are not played. In the
17
+ # order the app plays them, at the same moments:
18
+ # a new round round
19
+ # a call, a riichi chi, pon, kan, riichi (the jingle of the call)
20
+ # a discard discard_other
21
+ # a win ron or tsumo (the jingle), then points after
22
+ # the shout (1 s), the winner's points
23
+ # a draw of the round ryuukyoku (and the abortive draws: kyuushu for
24
+ # nine kinds, ryuukyoku for the others; three
25
+ # rons is the app's ryuukyoku jingle too)
26
+ # the end of the game fanfare1 (a spectator's: the winner's), after
27
+ # a win's shout when one is sounding
28
+ # The BGM's scenes are the app's: the lobby (waiting), the game and its
29
+ # results, the tense one (a riichi on the table, or TENSE_LEFT tiles or
30
+ # fewer left), and none at the end of a game (the fanfare plays alone).
31
+ module MahjongSounds
32
+ TENSE_LEFT = 10 # mahjong_sound.rb's TENSE_LEFT (sounds.json's tense_left)
33
+ SHOUT_MS = 1000 # how long the app shows a shout (a win's result waits for it)
34
+ CALLS = { "chi" => "chi", "pon" => "pon", "open_kan" => "kan", "closed_kan" => "kan", "added_kan" => "kan" }.freeze
35
+ DRAWS = %w[ryuukyoku suufon_renda suukaikan suucha_riichi sanchahou].freeze
36
+ # Every name these functions give (the files the page needs).
37
+ NAMES = %w[round chi pon kan riichi discard_other ron tsumo points ryuukyoku kyuushu fanfare1].freeze
38
+
39
+ module_function
40
+
41
+ # [[name, delay_ms], ...] for the step from prev to cur (nothing for the
42
+ # first view: that one is old news, as in the app).
43
+ def effects(prev, cur)
44
+ return [] unless prev.is_a?(Hash) && cur.is_a?(Hash)
45
+ out = []
46
+ st = cur["status"]
47
+ pst = prev["status"]
48
+ same = %w[play result].include?(pst) && round_key(prev) == round_key(cur)
49
+ if st == "play" && !same
50
+ out << ["round", 0]
51
+ return out
52
+ end
53
+ jingle = nil
54
+ if same && %w[play result over].include?(st)
55
+ ps = Array(prev["seats"])
56
+ Array(cur["seats"]).each_with_index do |s, i|
57
+ p = ps[i] || {}
58
+ call = new_call(Array(p["melds"]), Array(s["melds"]))
59
+ jingle = call if call
60
+ jingle = "riichi" if s["riichi"] && !p["riichi"]
61
+ end
62
+ end
63
+ res = cur["result"]
64
+ win = false
65
+ if %w[result over].include?(st) && res.is_a?(Hash) && (pst == "play" || prev["result"] != res)
66
+ kind = res["kind"].to_s
67
+ if %w[ron_agari tsumo_agari].include?(kind)
68
+ jingle = kind == "ron_agari" ? "ron" : "tsumo"
69
+ win = true
70
+ elsif kind == "kyuushu_kyuuhai"
71
+ jingle = "kyuushu"
72
+ elsif DRAWS.include?(kind)
73
+ jingle = "ryuukyoku"
74
+ end
75
+ end
76
+ out << [jingle, 0] if jingle
77
+ out << ["discard_other", 0] if st == "play" && pst == "play" && same && river_n(cur) > river_n(prev)
78
+ if st == "over" && pst != "over"
79
+ out << ["fanfare1", jingle ? SHOUT_MS : 0]
80
+ elsif win
81
+ out << ["points", SHOUT_MS]
82
+ end
83
+ out
84
+ end
85
+
86
+ # The BGM's scene for a view (nil: no table yet, the lobby's tune).
87
+ def scene(view)
88
+ return "lobby" unless view.is_a?(Hash)
89
+ st = view["status"]
90
+ return "lobby" if st == "wait"
91
+ return "none" if st == "over"
92
+ return "play" unless %w[play result].include?(st)
93
+ riichi = Array(view["seats"]).any? { |s| s.is_a?(Hash) && s["riichi"] }
94
+ left = view.dig("round", "wall_left")
95
+ riichi || (st == "play" && left.is_a?(Integer) && left <= TENSE_LEFT) ? "tense" : "play"
96
+ end
97
+
98
+ def round_key(v)
99
+ r = v["round"]
100
+ r.is_a?(Hash) ? [r["wind"], r["number"], r["honba"]] : nil
101
+ end
102
+
103
+ def river_n(v)
104
+ Array(v["seats"]).sum { |s| s.is_a?(Hash) ? Array(s["river"]).size : 0 }
105
+ end
106
+
107
+ # The jingle of a seat's new meld, or nil.
108
+ def new_call(old, cur)
109
+ return CALLS[cur.last["type"].to_s] if cur.size > old.size && cur.last.is_a?(Hash)
110
+ cur.each_with_index do |m, j|
111
+ o = old[j]
112
+ return "kan" if o.is_a?(Hash) && m.is_a?(Hash) && o["type"] != m["type"] && m["type"] == "added_kan"
113
+ end
114
+ nil
115
+ end
116
+ end
117
+ end
118
+ end
@@ -15,7 +15,9 @@ module AsterismGames
15
15
  # VOICEVOX voice (the hub's voice), also the fixed phrases'
16
16
  # files (/clip/<digest>.wav, only those of the list) and the lines'
17
17
  # sound (/voice/<digest>.wav, only the texts the hub registered:
18
- # never a text a page sends).
18
+ # never a text a page sends). And the mahjong app's sound (README "The
19
+ # sound"): /sounds/<name>.wav|mp3 and their sounds.json, the files of
20
+ # assets/sounds/ only, with their note (/SOUNDS_LICENSE.txt).
19
21
  class Server
20
22
  ASSETS = File.expand_path("assets", __dir__)
21
23
  FILES = {
@@ -25,8 +27,11 @@ module AsterismGames
25
27
  "/commentate.css" => ["commentate.css", "text/css; charset=utf-8"],
26
28
  "/tiles_hand.bmp" => ["tiles_hand.bmp", "image/bmp"],
27
29
  "/tiles_river.bmp" => ["tiles_river.bmp", "image/bmp"],
28
- "/TILES_LICENSE.txt" => ["TILES_LICENSE.txt", "text/plain; charset=utf-8"]
30
+ "/TILES_LICENSE.txt" => ["TILES_LICENSE.txt", "text/plain; charset=utf-8"],
31
+ "/SOUNDS_LICENSE.txt" => ["SOUNDS_LICENSE.txt", "text/plain; charset=utf-8"]
29
32
  }.freeze
33
+ SOUNDS = %r{\A/sounds/([a-z0-9_]+\.(wav|mp3|json))\z}
34
+ SOUND_TYPES = { "wav" => "audio/wav", "mp3" => "audio/mpeg", "json" => "application/json; charset=utf-8" }.freeze
30
35
  CLIP = %r{\A/clip/([0-9a-f]{16}\.wav)\z}
31
36
  LIVE = %r{\A/voice/([0-9a-f]{32}\.wav)\z}
32
37
  LOCAL_HOSTS = %w[127.0.0.1 localhost].freeze
@@ -98,6 +103,13 @@ module AsterismGames
98
103
  return respond(sock, 404, "text/plain", "not found\n") unless body
99
104
  respond(sock, 200, "audio/wav", method == "HEAD" ? "" : body, "Cache-Control" => "max-age=600",
100
105
  "Content-Length" => body.bytesize.to_s)
106
+ elsif (m = path.match(SOUNDS))
107
+ file = File.join(ASSETS, "sounds", m[1])
108
+ return respond(sock, 404, "text/plain", "not found\n") unless File.file?(file)
109
+ body = File.binread(file)
110
+ cache = m[2] == "json" ? "no-cache" : "max-age=3600"
111
+ respond(sock, 200, SOUND_TYPES[m[2]], method == "HEAD" ? "" : body, "Cache-Control" => cache,
112
+ "Content-Length" => body.bytesize.to_s)
101
113
  elsif (f = FILES[path])
102
114
  body = File.binread(File.join(ASSETS, f[0]))
103
115
  respond(sock, 200, f[1], method == "HEAD" ? "" : body, "Cache-Control" => "no-cache",
@@ -56,8 +56,14 @@ module AsterismGames
56
56
  fixed commentary alone
57
57
  -h, --help this text
58
58
 
59
- The page: Space pauses, M turns the voice off and on, H hides the hands.
60
- The browser speaks once the page has been clicked (its autoplay rule).
59
+ The page: Space pauses, M turns the voice off and on, B steps the BGM
60
+ (off, low, mid, high), E turns the effects off and on, H hides the
61
+ hands. The browser speaks once the page has been clicked (its autoplay rule).
62
+
63
+ The sound: the BGM and the effects of Family mruby's mahjong app, at
64
+ the app's moments, the BGM low by default and lower while the voice
65
+ speaks. The BGM is shiru8bit's (CC BY 3.0): the page shows the credit
66
+ (BGM: shiru8bit (CC BY 3.0)); put it in a stream's description too.
61
67
 
62
68
  The voice: VOICEVOX's character says the fixed phrases (リーチ, ポン,
63
69
  ロン, the rounds ...) from WAV files made on this machine at the first
@@ -327,7 +327,9 @@ module AsterismGames
327
327
  return "待ち合わせを待っています" unless v["st"] == "wait"
328
328
  return nil unless OWNER_WORDS.include?(w)
329
329
  unless MahjongText.owner?(v)
330
- return v["me"].to_i >= 0 ? "卓の主を待っています" : "席に着くのを待っています"
330
+ # Seated or not, the role is someone else's (since games 0.20.0
331
+ # an owner may be unseated): wait for it to come here.
332
+ return "卓の主ではありません。卓の主の役が回ってくるのを待っています"
331
333
  end
332
334
  return "4 席がそろうのを待っています" if %w[start watch].include?(w) && (v["sk"] || [0]).any?(&:zero?)
333
335
  nil