asterism-games 0.18.0 → 0.19.1

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.
@@ -0,0 +1,735 @@
1
+ // The commentary page (asterism-games commentate mahjong; README
2
+ // "Commentary"). It listens to /events (Server-Sent Events): "status" (the
3
+ // tables, whether the hands are hidden for everybody), "state" (a table's
4
+ // spectate view) and "line" (a line of commentary). It draws the table in
5
+ // the canvas with the tile sheets of Family mruby's mahjong app, shows the
6
+ // lines as captions and says them: VOICEVOX's character (fixed phrases as
7
+ // WAV files, the rest synthesised) when the command found the engine, else
8
+ // the browser's speech synthesis.
9
+ // The AI's one-liners (l.ai) come after the fixed lines, marked "AI" in
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).
13
+ "use strict";
14
+
15
+ (function () {
16
+ const params = new URLSearchParams(location.search);
17
+ const $ = (id) => document.getElementById(id);
18
+ const canvas = $("table");
19
+ const ctx = canvas.getContext("2d");
20
+
21
+ // ------------------------------------------------------------ state
22
+
23
+ const st = {
24
+ table: params.get("table") || null,
25
+ view: null,
26
+ lines: [], // every line kept (the last MAX_LINES)
27
+ status: { tables: [], public: false },
28
+ hide: params.get("hands") === "0",
29
+ voiceOn: params.get("voice") !== "0",
30
+ unlocked: false, // the page was clicked: the browser lets it speak
31
+ paused: false,
32
+ held: { view: null, lines: [] },
33
+ connected: false,
34
+ sheets: null,
35
+ };
36
+ const MAX_LINES = 300;
37
+ if (params.get("obs") === "1") document.body.classList.add("obs");
38
+
39
+ // ------------------------------------------------------------ tiles
40
+
41
+ // Tile numbers as everywhere in the game: 0..8 1m..9m, 9 the red 5m,
42
+ // 10..19 the circles, 20..29 the bamboos, 30..36 the honours, 37 the back.
43
+ function tileIndex(name) {
44
+ const m = /^([0-9])([mpsz])$/.exec(name || "");
45
+ if (!m) return 37;
46
+ const d = +m[1];
47
+ if (m[2] === "z") return d >= 1 && d <= 7 ? 29 + d : 37;
48
+ const base = { m: 0, p: 10, s: 20 }[m[2]];
49
+ return d === 0 ? base + 9 : base + d - 1;
50
+ }
51
+
52
+ // A sheet as the browser decodes the BMP, with its transparent colour
53
+ // (RGB332 0xE3, magenta) made transparent on a canvas of its own. The
54
+ // file itself is the hand-drawn one as it is.
55
+ function loadSheet(url, cw, ch) {
56
+ return new Promise((resolve, reject) => {
57
+ const img = new Image();
58
+ img.onload = () => {
59
+ const c = document.createElement("canvas");
60
+ c.width = img.width;
61
+ c.height = img.height;
62
+ const g = c.getContext("2d");
63
+ g.drawImage(img, 0, 0);
64
+ const d = g.getImageData(0, 0, c.width, c.height);
65
+ const p = d.data;
66
+ for (let i = 0; i < p.length; i += 4) {
67
+ if (p[i] === 255 && p[i + 1] === 0 && p[i + 2] === 255) p[i + 3] = 0;
68
+ }
69
+ g.putImageData(d, 0, 0);
70
+ resolve({ canvas: c, cw: cw, ch: ch });
71
+ };
72
+ img.onerror = reject;
73
+ img.src = url;
74
+ });
75
+ }
76
+
77
+ // Draws tile n of a sheet at (x, y) w x h; rot: quarter turns clockwise.
78
+ function tile(sheet, n, x, y, w, h, rot, alpha) {
79
+ if (!st.sheets) return;
80
+ const sx = (n % 10) * sheet.cw;
81
+ const sy = Math.floor(n / 10) * sheet.ch;
82
+ ctx.save();
83
+ if (alpha !== undefined) ctx.globalAlpha = alpha;
84
+ if (rot) {
85
+ // The box (x, y, w, h) is the turned tile's: draw upright around its centre.
86
+ ctx.translate(x + w / 2, y + h / 2);
87
+ ctx.rotate((rot * Math.PI) / 2);
88
+ ctx.drawImage(sheet.canvas, sx, sy, sheet.cw, sheet.ch, -h / 2, -w / 2, h, w);
89
+ } else {
90
+ ctx.drawImage(sheet.canvas, sx, sy, sheet.cw, sheet.ch, x, y, w, h);
91
+ }
92
+ ctx.restore();
93
+ }
94
+
95
+ // ------------------------------------------------------------ layout
96
+
97
+ // Logical sizes (the canvas is scaled to the window).
98
+ const L = {
99
+ PW: 720, PH: 176, GAP: 10, BAND: 74,
100
+ HW: 34, HH: 45, // a hand tile (the hand sheet at 0.8)
101
+ RW: 24, RH: 32, // a river tile (the river sheet as it is)
102
+ RIVER_X: 10, HAND_X: 214,
103
+ };
104
+ const WIND = { east: "東", south: "南", west: "西", north: "北" };
105
+ const KIND = { cpu: "CPU", ai: "AI", human: "", empty: "空席" };
106
+
107
+ function layout() {
108
+ const avail = Math.max(280, $("stage").clientWidth - 2);
109
+ const cols = avail >= 1150 ? 2 : 1;
110
+ const w = cols * L.PW + (cols + 1) * L.GAP;
111
+ const rows = 4 / cols;
112
+ const h = L.BAND + rows * L.PH + (rows + 1) * L.GAP;
113
+ const scale = Math.min(1.25, avail / w);
114
+ const dpr = window.devicePixelRatio || 1;
115
+ canvas.style.width = Math.floor(w * scale) + "px";
116
+ canvas.style.height = Math.floor(h * scale) + "px";
117
+ canvas.width = Math.floor(w * scale * dpr);
118
+ canvas.height = Math.floor(h * scale * dpr);
119
+ ctx.setTransform(scale * dpr, 0, 0, scale * dpr, 0, 0);
120
+ ctx.imageSmoothingEnabled = true;
121
+ return { cols: cols, w: w, h: h };
122
+ }
123
+
124
+ // ------------------------------------------------------------ drawing
125
+
126
+ function draw() {
127
+ const lay = layout();
128
+ ctx.fillStyle = "#1d4a33";
129
+ ctx.fillRect(0, 0, lay.w, lay.h);
130
+ const v = st.view;
131
+ drawBand(v, lay.w);
132
+ for (let s = 0; s < 4; s++) {
133
+ const c = s % lay.cols;
134
+ const r = Math.floor(s / lay.cols);
135
+ const x = L.GAP + c * (L.PW + L.GAP);
136
+ const y = L.BAND + L.GAP + r * (L.PH + L.GAP);
137
+ drawSeat(v, s, x, y);
138
+ }
139
+ if (!st.sheets) text("牌の画像を読み込んでいます", 20, L.BAND + 40, 16, "#fff");
140
+ }
141
+
142
+ function text(s, x, y, size, color, bold) {
143
+ ctx.font = (bold ? "bold " : "") + size + 'px "Hiragino Sans", "Yu Gothic UI", "Meiryo", "Noto Sans CJK JP", sans-serif';
144
+ ctx.fillStyle = color;
145
+ ctx.textBaseline = "alphabetic";
146
+ ctx.fillText(s, x, y);
147
+ return ctx.measureText(s).width;
148
+ }
149
+
150
+ function roundName(r) {
151
+ return (WIND[r.wind] || r.wind) + r.number + "局";
152
+ }
153
+
154
+ // The band on top: the round, the honba, the riichi sticks, the wall,
155
+ // the dora indicators; and what the table is doing (a result's line).
156
+ function drawBand(v, w) {
157
+ ctx.fillStyle = "#0f2a1d";
158
+ ctx.fillRect(0, 0, w, L.BAND);
159
+ if (!v) {
160
+ text("卓の様子を待っています", 16, 32, 20, "#e8efe9", true);
161
+ return;
162
+ }
163
+ let x = 16;
164
+ const r = v.round;
165
+ if (r && v.status !== "wait") {
166
+ x += text(roundName(r), x, 32, 26, "#ffd166", true) + 14;
167
+ x += text(r.honba + "本場", x, 32, 18, "#e8efe9") + 14;
168
+ x += text("供託 " + r.riichi_sticks, x, 32, 18, "#e8efe9") + 14;
169
+ x += text("残り " + r.wall_left, x, 32, 18, r.wall_left <= 10 ? "#ff7b6b" : "#e8efe9") + 18;
170
+ x += text("ドラ表示牌", x, 32, 15, "#9fb5a6") + 8;
171
+ (v.dora_indicators || []).forEach((d) => {
172
+ tile(st.sheets && st.sheets.river, tileIndex(d), x, 8, L.RW, L.RH);
173
+ x += L.RW + 2;
174
+ });
175
+ } else {
176
+ text(v.status === "wait" ? "対局の始まりを待っています" : "", 16, 32, 20, "#e8efe9", true);
177
+ }
178
+ text(statusLine(v), 16, 62, 16, "#e8efe9");
179
+ }
180
+
181
+ function seatName(v, i) {
182
+ const s = v && v.seats && v.seats[i];
183
+ return (s && s.name) || (i + 1) + "番の席";
184
+ }
185
+
186
+ function statusLine(v) {
187
+ const res = v.result;
188
+ if ((v.status === "result" || v.status === "over") && res) {
189
+ if (res.wins && res.wins.length) {
190
+ return res.wins.map((w) => {
191
+ const by = res.kind === "ron_agari" ? "ロン" : "ツモ";
192
+ return by + " " + seatName(v, w.seat) + " " + (w.yaku || []).join("・") + " " + (w.points || "");
193
+ }).join(" ");
194
+ }
195
+ if (res.kind === "ryuukyoku") {
196
+ const t = (res.tenpai || []).map((i) => seatName(v, i));
197
+ return "流局 " + (t.length ? "テンパイ: " + t.join("、") : "全員ノーテン");
198
+ }
199
+ return "途中流局";
200
+ }
201
+ if (v.status === "over" && v.final) {
202
+ return "終局 " + v.final.slice().sort((a, b) => a[1] - b[1]).map((f) => f[1] + "位 " + seatName(v, f[0]) + " " + f[2]).join(" ");
203
+ }
204
+ if (v.status === "play" && v.round) {
205
+ const t = v.round.turn;
206
+ return "手番: " + seatName(v, t);
207
+ }
208
+ return "";
209
+ }
210
+
211
+ // A seat's panel: the line of its name, the river on the left, the hand
212
+ // and the drawn tile, the melds under the hand.
213
+ function drawSeat(v, s, x, y) {
214
+ const seat = v && v.seats ? v.seats[s] : null;
215
+ const play = v && (v.status === "play" || v.status === "result" || v.status === "over") && v.round;
216
+ const turn = play && v.status === "play" && v.round.turn === s;
217
+ ctx.fillStyle = "#16382a";
218
+ ctx.fillRect(x, y, L.PW, L.PH);
219
+ ctx.lineWidth = turn ? 3 : 1;
220
+ ctx.strokeStyle = turn ? "#ffd166" : "#2c5a40";
221
+ ctx.strokeRect(x + 0.5, y + 0.5, L.PW - 1, L.PH - 1);
222
+ if (!seat) return;
223
+ // The name line.
224
+ let nx = x + 10;
225
+ if (seat.wind) {
226
+ const dealer = play && v.round.dealer === s;
227
+ nx += text(WIND[seat.wind] || "", nx, y + 22, 18, dealer ? "#ff7b6b" : "#ffd166", true) + 4;
228
+ if (dealer) nx += text("親", nx, y + 22, 13, "#ff7b6b") + 6;
229
+ }
230
+ nx += text(seat.name || "", nx, y + 22, 17, "#e8efe9", true) + 8;
231
+ if (KIND[seat.kind]) nx += text(KIND[seat.kind], nx, y + 22, 12, "#9fb5a6") + 8;
232
+ if (seat.score !== undefined) nx += text(seat.score + "点", nx, y + 22, 17, "#e8efe9") + 10;
233
+ if (seat.riichi) {
234
+ ctx.fillStyle = "#ff7b6b";
235
+ ctx.fillRect(nx, y + 9, 52, 18);
236
+ text("リーチ", nx + 6, y + 23, 13, "#fff", true);
237
+ nx += 60;
238
+ }
239
+ if (!play || !st.sheets) return;
240
+ drawRiver(v, s, seat, x + L.RIVER_X, y + 34);
241
+ drawHand(seat, x + L.HAND_X, y + 34);
242
+ drawMelds(seat, x + L.HAND_X, y + 34 + L.HH + 12);
243
+ }
244
+
245
+ function drawRiver(v, s, seat, x, y) {
246
+ const river = seat.river || [];
247
+ const per = river.length > 28 ? 8 : river.length > 24 ? 7 : 6;
248
+ const called = seat.called_indexes || [];
249
+ const last = v.last_discard && v.last_discard.seat === s ? river.length - 1 : -1;
250
+ let cx = x;
251
+ let cy = y;
252
+ river.forEach((t, i) => {
253
+ if (i > 0 && i % per === 0) {
254
+ cx = x;
255
+ cy += L.RH + 2;
256
+ }
257
+ const sideways = seat.riichi_index === i;
258
+ const w = sideways ? L.RH : L.RW;
259
+ const alpha = called.indexOf(i) >= 0 ? 0.35 : undefined;
260
+ if (sideways) tile(st.sheets.river, tileIndex(t), cx, cy + (L.RH - L.RW), w, L.RW, 1, alpha);
261
+ else tile(st.sheets.river, tileIndex(t), cx, cy, w, L.RH, 0, alpha);
262
+ if (i === last && v.status === "play") {
263
+ ctx.lineWidth = 2;
264
+ ctx.strokeStyle = "#ff7b6b";
265
+ ctx.strokeRect(cx, sideways ? cy + (L.RH - L.RW) : cy, w, sideways ? L.RW : L.RH);
266
+ }
267
+ cx += w;
268
+ });
269
+ }
270
+
271
+ function drawHand(seat, x, y) {
272
+ const shown = !st.hide && !st.status.public && Array.isArray(seat.hand);
273
+ let hand;
274
+ let drawn;
275
+ if (shown) {
276
+ hand = seat.hand;
277
+ drawn = seat.drawn;
278
+ } else {
279
+ const n = seat.hand_count || 0;
280
+ const hasDrawn = Array.isArray(seat.hand) ? !!seat.drawn : (n % 3 === 2);
281
+ hand = new Array(Math.max(0, n - (hasDrawn ? 1 : 0))).fill(null);
282
+ drawn = hasDrawn ? null : undefined;
283
+ }
284
+ let cx = x;
285
+ hand.forEach((t) => {
286
+ tile(st.sheets.hand, t === null ? 37 : tileIndex(t), cx, y, L.HW, L.HH);
287
+ cx += L.HW;
288
+ });
289
+ if (drawn !== undefined && (drawn !== null || !shown)) {
290
+ tile(st.sheets.hand, drawn === null ? 37 : tileIndex(drawn), cx + 10, y, L.HW, L.HH);
291
+ }
292
+ }
293
+
294
+ // The melds under the hand, in the river's tiles; a closed kan shows its
295
+ // two outer tiles as backs.
296
+ function drawMelds(seat, x, y) {
297
+ let cx = x;
298
+ (seat.melds || []).forEach((m) => {
299
+ const tiles = m.tiles || [];
300
+ tiles.forEach((t, i) => {
301
+ const back = m.type === "closed_kan" && (i === 0 || i === tiles.length - 1);
302
+ tile(st.sheets.river, back ? 37 : tileIndex(t), cx, y, L.RW, L.RH);
303
+ cx += L.RW;
304
+ });
305
+ cx += 8;
306
+ });
307
+ }
308
+
309
+ // ------------------------------------------------------------ captions
310
+
311
+ function lineText(l) {
312
+ if (hidden()) return l.ptext === undefined ? l.text : l.ptext;
313
+ return l.text;
314
+ }
315
+ function lineSpeech(l) {
316
+ if (hidden()) return l.pspeech === undefined ? l.speech : l.pspeech;
317
+ return l.speech;
318
+ }
319
+ function hidden() { return st.hide || st.status.public; }
320
+
321
+ function addCaption(l) {
322
+ const t = lineText(l);
323
+ if (!t) return;
324
+ const box = $("captions");
325
+ const atBottom = box.scrollHeight - box.scrollTop - box.clientHeight < 40;
326
+ const div = document.createElement("div");
327
+ div.className = "cap p" + (l.prio || 0) + (l.old ? " old" : "");
328
+ const time = document.createElement("time");
329
+ time.textContent = l.at || "";
330
+ const span = document.createElement("span");
331
+ span.textContent = t;
332
+ div.appendChild(time);
333
+ if (l.ai) {
334
+ div.className += " ai";
335
+ const mark = document.createElement("b");
336
+ mark.className = "aimark";
337
+ mark.textContent = "AI";
338
+ div.appendChild(mark);
339
+ }
340
+ div.appendChild(span);
341
+ box.appendChild(div);
342
+ while (box.children.length > MAX_LINES) box.removeChild(box.firstChild);
343
+ if (atBottom) box.scrollTop = box.scrollHeight;
344
+ }
345
+
346
+ function renderCaptions() {
347
+ $("captions").textContent = "";
348
+ st.lines.forEach((l) => { if (!l.pending) addCaption(l); });
349
+ $("captions").scrollTop = $("captions").scrollHeight;
350
+ }
351
+
352
+ // ------------------------------------------------------------ voice
353
+
354
+ // One queue for every line said, read in turn. A line with VOICEVOX's
355
+ // sound (l.voice, the status says the engine) plays its fixed phrases
356
+ // first (WAV files made on this machine) and then the rest of the line
357
+ // (synthesised meanwhile: its WAV is fetched as soon as the line is
358
+ // queued); without it the browser's own voice reads the line. The
359
+ // caption comes up when its voice starts. When the queue falls behind,
360
+ // the old small lines go first: a win or a draw (prio 2) drops the
361
+ // smallest ones waiting, a big moment (prio 3) every small one, so it is
362
+ // said next after the lines that matter (never before the win that made
363
+ // it). An AI's line ranks below every fixed line (prio -1 here): it
364
+ // waits behind them (the fixed lines of its own scene come first, a win
365
+ // and the lead changing hands can take 20 s), and is dropped when a new
366
+ // scene comes (a riichi, a win, a draw; a round's start waits behind
367
+ // it), when the queue is full (first), or after AI_STALE_MS. The command already
368
+ // dropped an answer that came late. What was played is logged in
369
+ // window.commentate.played.
370
+ const speech = {
371
+ queue: [],
372
+ speaking: false,
373
+ current: null, // the item being said
374
+ audio: null, // the Audio playing, if any
375
+ voice: null, // the browser's Japanese voice
376
+ MAX: 4,
377
+ STALE_MS: 20000,
378
+ AI_STALE_MS: 40000,
379
+ };
380
+ const played = [];
381
+ const synth = window.speechSynthesis;
382
+
383
+ function vv() { return st.status.voice && st.status.voice.engine === "voicevox" ? st.status.voice : null; }
384
+ function canSpeak() { return !!(vv() || (synth && speech.voice)); }
385
+
386
+ function pickVoice() {
387
+ if (!synth) return;
388
+ const vs = synth.getVoices();
389
+ speech.voice = vs.find((v) => /^ja(-|_|$)/i.test(v.lang) && v.localService) ||
390
+ vs.find((v) => /^ja(-|_|$)/i.test(v.lang)) || null;
391
+ showVoice();
392
+ }
393
+ if (synth) {
394
+ pickVoice();
395
+ synth.onvoiceschanged = pickVoice;
396
+ }
397
+
398
+ function log(kind, item, src) {
399
+ const e = { t: Date.now(), kind: kind, id: item ? item.line.id : null, prio: item ? item.prio : null, src: src };
400
+ played.push(e);
401
+ if (played.length > 500) played.shift();
402
+ if (window.console) console.log("voice", kind, e.id, src);
403
+ }
404
+
405
+ // Queues the line's voice; false when nothing is said (the caption then
406
+ // comes up at once).
407
+ function say(l) {
408
+ if (!st.voiceOn || !st.unlocked || !canSpeak()) return false;
409
+ const s = lineSpeech(l);
410
+ if (!s) return false;
411
+ const item = { line: l, text: s, prio: l.ai ? -1 : (l.prio || 0), at: Date.now() };
412
+ const v = l.voice;
413
+ if (vv() && v) {
414
+ item.clips = v.clips || [];
415
+ const live = hidden() && v.plive !== undefined ? v.plive : v.live;
416
+ if (live) {
417
+ item.ctl = window.AbortController ? new AbortController() : null;
418
+ item.blob = fetch(live, item.ctl ? { signal: item.ctl.signal } : {})
419
+ .then((r) => (r.ok ? r.blob() : Promise.reject(new Error("HTTP " + r.status))));
420
+ item.blob.catch(() => {});
421
+ item.live = live;
422
+ }
423
+ }
424
+ l.pending = true;
425
+ if (item.prio >= 3) drop((q) => q.prio >= 0 && q.prio < 2, "big");
426
+ else if (item.prio === 2) drop((q) => q.prio === 0, "win");
427
+ // A new scene (a riichi, a win, a draw, the lead changing hands) lets
428
+ // a waiting AI line go: it spoke of the one before. A round's start
429
+ // (the oorasu too) waits behind it.
430
+ if (item.prio >= 2 && l.type !== "round_start") drop((q) => q.prio < 0, "scene");
431
+ speech.queue.push(item);
432
+ while (speech.queue.length > speech.MAX) {
433
+ let k = speech.queue.findIndex((q) => q.prio < 0);
434
+ if (k < 0) k = speech.queue.findIndex((q) => q.prio <= 1);
435
+ if (k < 0) k = speech.queue.findIndex((q) => q.prio <= 2);
436
+ if (k < 0) k = speech.queue.length - 1;
437
+ dropAt(k, "full");
438
+ }
439
+ next();
440
+ return true;
441
+ }
442
+
443
+ // Lines left unsaid still get their captions.
444
+ // why: big (a prio 3 came), win (a prio 2 came), full, stale, cut.
445
+ function dropAt(k, why) {
446
+ const item = speech.queue.splice(k, 1)[0];
447
+ if (!item) return;
448
+ if (item.ctl) item.ctl.abort();
449
+ log("drop", item, (why || "") + ": " + item.text);
450
+ showLine(item.line);
451
+ }
452
+ function drop(pred, why) {
453
+ for (let k = speech.queue.length - 1; k >= 0; k--) if (pred(speech.queue[k])) dropAt(k, why);
454
+ }
455
+
456
+ function showLine(l) {
457
+ if (!l.pending) return;
458
+ l.pending = false;
459
+ addCaption(l);
460
+ }
461
+
462
+ function next() {
463
+ if (speech.speaking || st.paused || !st.unlocked) return;
464
+ const now = Date.now();
465
+ drop((q) => q.prio >= 0 && q.prio < 2 && now - q.at >= speech.STALE_MS, "stale");
466
+ drop((q) => q.prio < 0 && now - q.at >= speech.AI_STALE_MS, "stale");
467
+ const item = speech.queue.shift();
468
+ if (!item) return;
469
+ speech.speaking = true;
470
+ speech.current = item;
471
+ showLine(item.line);
472
+ sayItem(item).then(() => {
473
+ if (speech.current !== item) return; // cut off (M, H)
474
+ speech.current = null;
475
+ speech.speaking = false;
476
+ setTimeout(next, 120);
477
+ });
478
+ }
479
+
480
+ async function sayItem(item) {
481
+ if (item.clips || item.blob) {
482
+ for (const c of item.clips || []) {
483
+ if (speech.current !== item) return;
484
+ log("clip", item, c);
485
+ await playUrl(c);
486
+ }
487
+ if (!item.blob || speech.current !== item) return;
488
+ let url = null;
489
+ try {
490
+ url = URL.createObjectURL(await item.blob);
491
+ } catch (e) {
492
+ log("live-failed", item, item.live);
493
+ if (synth && speech.voice) await speak(item, item.text);
494
+ return;
495
+ }
496
+ if (speech.current !== item) return;
497
+ log("live", item, item.live);
498
+ await playUrl(url);
499
+ URL.revokeObjectURL(url);
500
+ return;
501
+ }
502
+ await speak(item, item.text);
503
+ }
504
+
505
+ function playUrl(url) {
506
+ return new Promise((resolve) => {
507
+ const a = new Audio(url);
508
+ speech.audio = a;
509
+ const done = () => { if (speech.audio === a) speech.audio = null; resolve(); };
510
+ a.onended = done;
511
+ a.onerror = done;
512
+ a.play().catch(done);
513
+ });
514
+ }
515
+
516
+ function speak(item, text) {
517
+ return new Promise((resolve) => {
518
+ if (!synth || !speech.voice) return resolve();
519
+ log("speech", item, text);
520
+ const u = new SpeechSynthesisUtterance(text);
521
+ u.lang = "ja-JP";
522
+ u.voice = speech.voice;
523
+ u.rate = item && item.prio >= 3 ? 1.1 : 1.15;
524
+ u.pitch = item && item.prio >= 3 ? 1.15 : 1.0;
525
+ u.onend = resolve;
526
+ u.onerror = resolve;
527
+ synth.speak(u);
528
+ });
529
+ }
530
+
531
+ // Stops what is being said and empties the queue (M, H); the captions
532
+ // of what was waiting come up.
533
+ function cut() {
534
+ if (synth) synth.cancel();
535
+ if (speech.audio) { speech.audio.pause(); speech.audio = null; }
536
+ drop(() => true, "cut");
537
+ speech.current = null;
538
+ speech.speaking = false;
539
+ }
540
+
541
+ function showVoice() {
542
+ const el = $("voice");
543
+ const v = vv();
544
+ const credit = $("credit");
545
+ credit.textContent = v ? v.credit : "";
546
+ credit.hidden = !v;
547
+ const note = !v && st.status.voice && st.status.voice.note === "missing" ? "VOICEVOX が見つからない (無料のアプリ VOICEVOX を入れて起動すると、ずんだもんの声になる): ブラウザの声で読む / " : "";
548
+ let what;
549
+ if (v) what = "VOICEVOX " + v.speaker + " (" + v.style + ")";
550
+ else if (!synth) { el.textContent = "声: " + note + "このブラウザは読み上げ無し"; el.className = "item warn"; return; }
551
+ else if (!speech.voice) { el.textContent = "声: " + note + "日本語の声がありません"; el.className = "item warn"; return; }
552
+ else what = speech.voice.name;
553
+ if (!st.voiceOn) { el.textContent = "声: " + note + "切 (M)"; el.className = "item"; return; }
554
+ el.textContent = "声: " + note + (st.unlocked ? "入 (" + what + ")" : "クリック待ち");
555
+ el.className = "item on";
556
+ }
557
+
558
+ function unlock() {
559
+ if (st.unlocked) return;
560
+ st.unlocked = true;
561
+ $("hint").hidden = true;
562
+ // A first sound inside the gesture opens the way on some browsers.
563
+ const v = vv();
564
+ if (st.voiceOn && canSpeak()) {
565
+ const item = { line: { id: 0 }, prio: 1, at: Date.now() };
566
+ speech.speaking = true;
567
+ speech.current = item;
568
+ const p = v && v.start ? (log("clip", item, v.start), playUrl(v.start)) : speak(item, "実況を始めます");
569
+ p.then(() => {
570
+ if (speech.current !== item) return;
571
+ speech.current = null;
572
+ speech.speaking = false;
573
+ next();
574
+ });
575
+ }
576
+ showVoice();
577
+ }
578
+ document.addEventListener("click", unlock);
579
+ $("hint").addEventListener("keydown", (e) => { if (e.key === "Enter") unlock(); });
580
+
581
+ // The AI's one-liners: on (the model, today's money and the limit),
582
+ // over the limit, or off and why.
583
+ function showAI() {
584
+ const el = $("ai");
585
+ const a = st.status.ai;
586
+ if (!a) { el.textContent = ""; return; }
587
+ const usd = (x) => "$" + Number(x || 0).toFixed(x >= 1 ? 2 : 4);
588
+ const money = "今日 " + usd(a.usd) + " / 上限 $" + Number(a.limit_usd || 0).toFixed(2);
589
+ if (a.state === "on") { el.textContent = "AI: " + a.model + " / " + money; el.className = "item on"; }
590
+ else if (a.state === "fake") { el.textContent = "AI: 偽物 (API なし) / " + money; el.className = "item on"; }
591
+ else if (a.state === "over") { el.textContent = "AI: 今日の上限に達した (" + money + "): 決まりの実況だけ"; el.className = "item warn"; }
592
+ else {
593
+ const why = { no_key: "キーなし", no_gem: "anthropic の gem なし", off: "--ai off" }[a.note] || a.note || "";
594
+ el.textContent = "AI: off (" + why + ")";
595
+ el.className = "item";
596
+ }
597
+ }
598
+
599
+ // ------------------------------------------------------------ the bar
600
+
601
+ function showBar() {
602
+ const tables = st.status.tables || [];
603
+ const sel = $("tables");
604
+ const cur = tables.find((t) => t.id === st.table);
605
+ $("tname").textContent = cur ? cur.name + " (" + cur.host + ")" : (tables.length ? "" : "卓を待っています");
606
+ sel.hidden = tables.length < 2;
607
+ sel.textContent = "";
608
+ tables.forEach((t) => {
609
+ const o = document.createElement("option");
610
+ o.value = t.id;
611
+ o.textContent = t.name + " (" + t.host + ")" + (t.gone ? " 不在" : "");
612
+ if (t.id === st.table) o.selected = true;
613
+ sel.appendChild(o);
614
+ });
615
+ const d = st.view && st.view.delay_s;
616
+ $("delay").textContent = st.view ? (d ? "遅れ " + d + " 秒" : "遅れなし") : "";
617
+ const mode = [];
618
+ if (st.status.public) mode.push("手牌非公開");
619
+ else if (st.hide) mode.push("手牌を隠す (H)");
620
+ if (st.paused) mode.push("一時停止中 (Space)");
621
+ $("mode").textContent = mode.join(" / ");
622
+ $("mode").className = "item" + (st.paused ? " warn" : "");
623
+ let conn = "";
624
+ if (!st.connected) conn = "実況アプリとの接続が切れています (再接続を待っています)";
625
+ else if (cur && cur.gone) conn = "ホストが見えません (戻るのを待っています)";
626
+ $("conn").textContent = conn;
627
+ $("conn").className = "item warn";
628
+ showVoice();
629
+ showAI();
630
+ }
631
+
632
+ $("tables").addEventListener("change", (e) => {
633
+ st.table = e.target.value;
634
+ const u = new URL(location.href);
635
+ u.searchParams.set("table", st.table);
636
+ history.replaceState(null, "", u);
637
+ st.view = null;
638
+ st.lines = [];
639
+ renderCaptions();
640
+ connect();
641
+ });
642
+
643
+ // ------------------------------------------------------------ keys
644
+
645
+ document.addEventListener("keydown", (e) => {
646
+ if (e.target && e.target.tagName === "SELECT") return;
647
+ if (e.key === " ") {
648
+ e.preventDefault();
649
+ setPaused(!st.paused);
650
+ } else if (e.key === "m" || e.key === "M") {
651
+ st.voiceOn = !st.voiceOn;
652
+ if (!st.voiceOn) cut();
653
+ unlock();
654
+ showVoice();
655
+ } else if (e.key === "h" || e.key === "H") {
656
+ st.hide = !st.hide;
657
+ cut();
658
+ renderCaptions();
659
+ draw();
660
+ showBar();
661
+ connect(); // the server learns it: the AI gets no hands meanwhile
662
+ } else {
663
+ return;
664
+ }
665
+ });
666
+
667
+ // Paused: what arrives is held; going on shows the newest table and the
668
+ // held lines (only the newest big one is read).
669
+ function setPaused(p) {
670
+ st.paused = p;
671
+ if (synth) { if (p) synth.pause(); else synth.resume(); }
672
+ if (speech.audio) { if (p) speech.audio.pause(); else speech.audio.play().catch(() => {}); }
673
+ if (!p) {
674
+ if (st.held.view) st.view = st.held.view;
675
+ const held = st.held.lines;
676
+ st.held = { view: null, lines: [] };
677
+ const big = held.filter((l) => (l.prio || 0) >= 3).pop();
678
+ held.forEach((l) => { st.lines.push(l); if (l !== big) addCaption(l); });
679
+ if (big && !say(big)) addCaption(big);
680
+ trimLines();
681
+ draw();
682
+ next();
683
+ }
684
+ showBar();
685
+ }
686
+
687
+ function trimLines() { while (st.lines.length > MAX_LINES) st.lines.shift(); }
688
+
689
+ // ------------------------------------------------------------ events
690
+
691
+ let es = null;
692
+ function connect() {
693
+ if (es) es.close();
694
+ const q = [];
695
+ if (st.table) q.push("table=" + encodeURIComponent(st.table));
696
+ if (st.hide) q.push("hide=1");
697
+ const url = "/events" + (q.length ? "?" + q.join("&") : "");
698
+ es = new EventSource(url);
699
+ es.onopen = () => { st.connected = true; showBar(); };
700
+ es.onerror = () => { st.connected = false; showBar(); };
701
+ es.addEventListener("status", (e) => {
702
+ st.status = JSON.parse(e.data);
703
+ if (!st.table && st.status.tables.length) st.table = st.status.tables[0].id;
704
+ showBar();
705
+ });
706
+ es.addEventListener("state", (e) => {
707
+ const d = JSON.parse(e.data);
708
+ if (st.table && d.table !== st.table) return;
709
+ if (!st.table) st.table = d.table;
710
+ if (st.paused) { st.held.view = d.view; return; }
711
+ st.view = d.view;
712
+ draw();
713
+ showBar();
714
+ });
715
+ es.addEventListener("line", (e) => {
716
+ const l = JSON.parse(e.data);
717
+ if (st.table && l.table !== st.table) return;
718
+ if (st.lines.some((x) => x.id === l.id)) return;
719
+ if (st.paused && !l.old) { st.held.lines.push(l); return; }
720
+ st.lines.push(l);
721
+ trimLines();
722
+ if (l.old || !say(l)) addCaption(l);
723
+ });
724
+ }
725
+
726
+ window.addEventListener("resize", () => draw());
727
+ Promise.all([loadSheet("/tiles_hand.bmp", 42, 56), loadSheet("/tiles_river.bmp", 24, 32)])
728
+ .then(([hand, river]) => { st.sheets = { hand: hand, river: river }; draw(); })
729
+ .catch(() => { text("牌の画像を読めませんでした", 20, L.BAND + 40, 16, "#ff7b6b"); });
730
+ draw();
731
+ showBar();
732
+ connect();
733
+ // For a test or a tool driving the page.
734
+ window.commentate = { state: st, played: played, speech: speech };
735
+ })();