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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +72 -0
- data/README.md +321 -3
- data/VERSION +1 -1
- data/bin/asterism-games +8 -0
- data/lib/asterism_games/ai.rb +64 -0
- data/lib/asterism_games/commentate/ai_comment.rb +633 -0
- data/lib/asterism_games/commentate/assets/TILES_LICENSE.txt +42 -0
- data/lib/asterism_games/commentate/assets/commentate.css +100 -0
- data/lib/asterism_games/commentate/assets/commentate.js +735 -0
- data/lib/asterism_games/commentate/assets/index.html +26 -0
- data/lib/asterism_games/commentate/assets/tiles_hand.bmp +0 -0
- data/lib/asterism_games/commentate/assets/tiles_river.bmp +0 -0
- data/lib/asterism_games/commentate/mahjong.rb +400 -0
- data/lib/asterism_games/commentate/mahjong_events.rb +320 -0
- data/lib/asterism_games/commentate/mahjong_phrases.rb +535 -0
- data/lib/asterism_games/commentate/mahjong_voice.yml +93 -0
- data/lib/asterism_games/commentate/server.rb +204 -0
- data/lib/asterism_games/commentate/voice.rb +394 -0
- data/lib/asterism_games/commentate/voicevox.rb +110 -0
- data/lib/asterism_games/commentate.rb +223 -0
- metadata +18 -2
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsterismGames
|
|
4
|
+
module Commentate
|
|
5
|
+
# The words of the commentary (README "Commentary"): an event of
|
|
6
|
+
# MahjongEvents into a line, in Japanese, the way the table app says
|
|
7
|
+
# things (東1局, 本場, 供託, ドラ, リーチ, ポン). Pure: the view gives the
|
|
8
|
+
# names, rng (anything with rand(n)) chooses among the phrasings.
|
|
9
|
+
#
|
|
10
|
+
# line = MahjongPhrases.line(event, view, rng, tone: "zundamon")
|
|
11
|
+
# # => {"type", "prio", "text", "speech", "ptext", "pspeech", "clips"}
|
|
12
|
+
#
|
|
13
|
+
# "text" is the caption, "speech" the same for a voice (the tiles and
|
|
14
|
+
# the yaku as they are read: サンマン, リーチ). "ptext" / "pspeech" are
|
|
15
|
+
# the line with the hands hidden: the same when the event does not
|
|
16
|
+
# touch a hand, a shorter one when part of it does (a riichi's waits),
|
|
17
|
+
# nil when all of it does (a tenpai): then nothing is said. "clips" are
|
|
18
|
+
# the keys of the fixed phrases (mahjong_voice.yml) a voice plays
|
|
19
|
+
# before the line: never one that tells a hand.
|
|
20
|
+
#
|
|
21
|
+
# The tone (TONES) is the character's way of speaking: the plain one,
|
|
22
|
+
# and ずんだもん's (〜なのだ) when the voice is VOICEVOX's ずんだもん. A
|
|
23
|
+
# tone has phrasings of its own for every event (the same number as
|
|
24
|
+
# the plain ones, so one choice gives the same line in both), and a
|
|
25
|
+
# few words for an AI asked to speak in it (C3).
|
|
26
|
+
module MahjongPhrases
|
|
27
|
+
WIND_TEXT = { "east" => "東", "south" => "南", "west" => "西", "north" => "北" }.freeze
|
|
28
|
+
WIND_READ = { "east" => "トン", "south" => "ナン", "west" => "シャー", "north" => "ペー" }.freeze
|
|
29
|
+
HONOR_TEXT = %w[東 南 西 北 白 發 中].freeze
|
|
30
|
+
HONOR_READ = %w[トン ナン シャー ペー ハク ハツ チュン].freeze
|
|
31
|
+
SUIT_TEXT = { "m" => "萬", "p" => "筒", "s" => "索" }.freeze
|
|
32
|
+
SUIT_READ = { "m" => "マン", "p" => "ピン", "s" => "ソー" }.freeze
|
|
33
|
+
NUM_READ = %w[イー リャン サン スー ウー ロー チー パー キュー].freeze
|
|
34
|
+
CALL_TEXT = { "chi" => "チー", "pon" => "ポン", "open_kan" => "カン", "closed_kan" => "カン",
|
|
35
|
+
"added_kan" => "カン" }.freeze
|
|
36
|
+
CALL_KIND = { "chi" => "チー", "pon" => "ポン", "open_kan" => "大明槓", "closed_kan" => "暗槓",
|
|
37
|
+
"added_kan" => "加槓" }.freeze
|
|
38
|
+
CALL_KIND_READ = { "chi" => "チー", "pon" => "ポン", "open_kan" => "ダイミンカン", "closed_kan" => "アンカン",
|
|
39
|
+
"added_kan" => "カカン" }.freeze
|
|
40
|
+
YAKU_READ = {
|
|
41
|
+
"立直" => "リーチ", "ダブル立直" => "ダブルリーチ", "一発" => "イッパツ", "門前清自摸和" => "メンゼンツモ",
|
|
42
|
+
"平和" => "ピンフ", "断么九" => "タンヤオ", "一盃口" => "イーペーコー", "二盃口" => "リャンペーコー",
|
|
43
|
+
"七対子" => "チートイツ", "三色同順" => "サンショク", "三色同刻" => "サンショクドウコー", "一気通貫" => "イッツー",
|
|
44
|
+
"混全帯么九" => "チャンタ", "純全帯么九" => "ジュンチャン", "対々和" => "トイトイ", "三暗刻" => "サンアンコー",
|
|
45
|
+
"三槓子" => "サンカンツ", "混老頭" => "ホンロートー", "小三元" => "ショウサンゲン", "混一色" => "ホンイツ",
|
|
46
|
+
"清一色" => "チンイツ", "嶺上開花" => "リンシャンカイホー", "槍槓" => "チャンカン", "海底摸月" => "ハイテイ",
|
|
47
|
+
"河底撈魚" => "ホーテイ", "国士無双" => "コクシムソウ", "国士無双十三面" => "コクシムソウ ジューサンメン",
|
|
48
|
+
"四暗刻" => "スーアンコー", "四暗刻単騎" => "スーアンコー タンキ", "大三元" => "ダイサンゲン", "字一色" => "ツーイーソー",
|
|
49
|
+
"緑一色" => "リューイーソー", "清老頭" => "チンロートー", "小四喜" => "ショウスーシー", "大四喜" => "ダイスーシー",
|
|
50
|
+
"四槓子" => "スーカンツ", "九蓮宝燈" => "チューレンポートー", "純正九蓮宝燈" => "ジュンセイ チューレンポートー",
|
|
51
|
+
"天和" => "テンホー", "地和" => "チーホー"
|
|
52
|
+
}.freeze
|
|
53
|
+
LEVEL_READ = { "満貫" => "マンガン", "跳満" => "ハネマン", "倍満" => "バイマン", "三倍満" => "サンバイマン",
|
|
54
|
+
"役満" => "ヤクマン" }.freeze
|
|
55
|
+
ABORT_TEXT = { "kyuushu_kyuuhai" => "九種九牌", "sanchahou" => "三家和", "suufon_renda" => "四風連打",
|
|
56
|
+
"suucha_riichi" => "四家立直", "suukaikan" => "四開槓" }.freeze
|
|
57
|
+
ABORT_READ = { "kyuushu_kyuuhai" => "キューシュキューハイ", "sanchahou" => "サンチャホー",
|
|
58
|
+
"suufon_renda" => "スーフォンレンダ", "suucha_riichi" => "スーチャリーチ", "suukaikan" => "スーカイカン" }.freeze
|
|
59
|
+
|
|
60
|
+
# The phrasings of each event: [caption, speech] templates; "strong"
|
|
61
|
+
# for the big moments. %{...} are filled by fill (the speech's own
|
|
62
|
+
# values where they are read differently).
|
|
63
|
+
PHRASES = {
|
|
64
|
+
"hello" => ["%{status_line}"],
|
|
65
|
+
"round_start" => [
|
|
66
|
+
"%{round}、親は%{dealer}。ドラは%{dora}。",
|
|
67
|
+
"さあ%{round}が始まります。親は%{dealer}、ドラ%{dora}。",
|
|
68
|
+
"%{round}。%{dealer}の親番です。ドラは%{dora}。"
|
|
69
|
+
],
|
|
70
|
+
"round_start_game" => [
|
|
71
|
+
"対局開始! 東1局、起家は%{dealer}。ドラは%{dora}。",
|
|
72
|
+
"さあ始まりました。東1局、親は%{dealer}。ドラは%{dora}。"
|
|
73
|
+
],
|
|
74
|
+
"round_start_oorasu" => [
|
|
75
|
+
"いよいよオーラス! %{round}、親は%{dealer}。トップは%{leader}、%{lead_score}点。",
|
|
76
|
+
"最後の局、オーラスです! %{round}。%{leader}が%{lead_score}点でトップ、逃げ切れるか。",
|
|
77
|
+
"オーラス! %{round}。この一局で決まります。トップは%{leader}。"
|
|
78
|
+
],
|
|
79
|
+
"round_start_oorasu_again" => [
|
|
80
|
+
"オーラス続行、%{round}。親は%{dealer}。",
|
|
81
|
+
"まだ終わらない! %{round}。親の%{dealer}が粘ります。"
|
|
82
|
+
],
|
|
83
|
+
"dora_discard" => [
|
|
84
|
+
"%{name}、%{tile}を切りました。",
|
|
85
|
+
"%{name}からドラの%{tile}。",
|
|
86
|
+
"%{name}、%{tile}を手放します。"
|
|
87
|
+
],
|
|
88
|
+
"red_discard" => [
|
|
89
|
+
"%{name}、赤の%{plain}を切りました。",
|
|
90
|
+
"%{name}から赤ドラ、%{plain}。"
|
|
91
|
+
],
|
|
92
|
+
"call" => [
|
|
93
|
+
"%{name}、%{call}! %{called}。",
|
|
94
|
+
"%{name}が%{called}を%{call}。",
|
|
95
|
+
"%{call}! %{name}、仕掛けました。"
|
|
96
|
+
],
|
|
97
|
+
"kan" => [
|
|
98
|
+
"%{name}、%{kind}!",
|
|
99
|
+
"%{kind}です。%{name}がカンをしました。"
|
|
100
|
+
],
|
|
101
|
+
"riichi" => [
|
|
102
|
+
"%{name}、リーチ! 待ちは%{waits}。",
|
|
103
|
+
"リーチが入りました、%{name}。待ちは%{waits}。",
|
|
104
|
+
"%{name}から%{turn}巡目のリーチ! %{waits}待ち。"
|
|
105
|
+
],
|
|
106
|
+
"riichi_public" => [
|
|
107
|
+
"%{name}、リーチ!",
|
|
108
|
+
"リーチが入りました、%{name}。",
|
|
109
|
+
"%{name}から%{turn}巡目のリーチ!"
|
|
110
|
+
],
|
|
111
|
+
"new_dora" => [
|
|
112
|
+
"新ドラは%{dora}。",
|
|
113
|
+
"カンドラがめくれて、ドラは%{dora}。"
|
|
114
|
+
],
|
|
115
|
+
"wall" => [
|
|
116
|
+
"残り%{left}枚。",
|
|
117
|
+
"山は残り%{left}枚になりました。"
|
|
118
|
+
],
|
|
119
|
+
"tenpai" => [
|
|
120
|
+
"%{name}、テンパイ。待ちは%{waits}。",
|
|
121
|
+
"%{name}の手がテンパイしました。%{waits}待ち。",
|
|
122
|
+
"%{name}、静かにテンパイ。%{waits}待ちです。"
|
|
123
|
+
],
|
|
124
|
+
"win" => [
|
|
125
|
+
"%{by_word}! %{name}、%{yaku}。%{points}。",
|
|
126
|
+
"%{name}の%{by_word}! %{yaku}で%{points}。",
|
|
127
|
+
"%{name}、%{from_part}%{by_word}です。%{yaku}、%{points}。"
|
|
128
|
+
],
|
|
129
|
+
"win_strong" => [
|
|
130
|
+
"%{by_word}!! %{name}、%{level}! %{yaku}、%{points}!",
|
|
131
|
+
"出ました%{level}! %{name}の%{by_word}、%{yaku}。%{points}!",
|
|
132
|
+
"大きい! %{name}、%{from_part}%{by_word}で%{level}! %{yaku}、%{points}!"
|
|
133
|
+
],
|
|
134
|
+
"draw" => [
|
|
135
|
+
"流局。%{tenpai_line}",
|
|
136
|
+
"ここで流局です。%{tenpai_line}"
|
|
137
|
+
],
|
|
138
|
+
"abort" => [
|
|
139
|
+
"途中流局、%{kind}。",
|
|
140
|
+
"%{kind}で流局です。"
|
|
141
|
+
],
|
|
142
|
+
"lead_change" => [
|
|
143
|
+
"逆転! %{name}がトップに立ちました、%{score}点。",
|
|
144
|
+
"トップが入れ替わります! %{name}、%{score}点でトップ。",
|
|
145
|
+
"ここで逆転、%{name}がトップへ!"
|
|
146
|
+
],
|
|
147
|
+
"game_over" => [
|
|
148
|
+
"終局! トップは%{top}、%{top_score}点。%{ranks}",
|
|
149
|
+
"対局終了。%{top}が%{top_score}点でトップです。%{ranks}"
|
|
150
|
+
]
|
|
151
|
+
}.freeze
|
|
152
|
+
|
|
153
|
+
# ずんだもん's phrasings (TONES): the same events, the same number of
|
|
154
|
+
# phrasings, the endings 〜なのだ / 〜のだ.
|
|
155
|
+
ZUNDAMON_PHRASES = {
|
|
156
|
+
"hello" => ["%{status_line}"],
|
|
157
|
+
"round_start" => [
|
|
158
|
+
"%{round}なのだ。親は%{dealer}、ドラは%{dora}なのだ。",
|
|
159
|
+
"さあ%{round}が始まるのだ! 親は%{dealer}、ドラ%{dora}なのだ。",
|
|
160
|
+
"%{round}、%{dealer}の親番なのだ。ドラは%{dora}なのだ。"
|
|
161
|
+
],
|
|
162
|
+
"round_start_game" => [
|
|
163
|
+
"対局開始なのだ! 東1局、起家は%{dealer}。ドラは%{dora}なのだ。",
|
|
164
|
+
"さあ始まったのだ! 東1局、親は%{dealer}。ドラは%{dora}なのだ。"
|
|
165
|
+
],
|
|
166
|
+
"round_start_oorasu" => [
|
|
167
|
+
"いよいよオーラスなのだ! %{round}、親は%{dealer}。トップは%{leader}、%{lead_score}点なのだ。",
|
|
168
|
+
"最後の局、オーラスなのだ! %{round}。%{leader}が%{lead_score}点でトップ、逃げ切れるのか。",
|
|
169
|
+
"オーラスなのだ! %{round}。この一局で決まるのだ。トップは%{leader}なのだ。"
|
|
170
|
+
],
|
|
171
|
+
"round_start_oorasu_again" => [
|
|
172
|
+
"オーラス続行なのだ、%{round}。親は%{dealer}なのだ。",
|
|
173
|
+
"まだ終わらないのだ! %{round}。親の%{dealer}が粘るのだ。"
|
|
174
|
+
],
|
|
175
|
+
"dora_discard" => [
|
|
176
|
+
"%{name}、%{tile}を切ったのだ。",
|
|
177
|
+
"%{name}からドラの%{tile}なのだ。",
|
|
178
|
+
"%{name}、%{tile}を手放したのだ。"
|
|
179
|
+
],
|
|
180
|
+
"red_discard" => [
|
|
181
|
+
"%{name}、赤の%{plain}を切ったのだ。",
|
|
182
|
+
"%{name}から赤ドラ、%{plain}なのだ。"
|
|
183
|
+
],
|
|
184
|
+
"call" => [
|
|
185
|
+
"%{name}、%{call}なのだ! %{called}。",
|
|
186
|
+
"%{name}が%{called}を%{call}したのだ。",
|
|
187
|
+
"%{call}なのだ! %{name}、仕掛けたのだ。"
|
|
188
|
+
],
|
|
189
|
+
"kan" => [
|
|
190
|
+
"%{name}、%{kind}なのだ!",
|
|
191
|
+
"%{kind}なのだ。%{name}がカンをしたのだ。"
|
|
192
|
+
],
|
|
193
|
+
"riichi" => [
|
|
194
|
+
"%{name}、リーチなのだ! 待ちは%{waits}なのだ。",
|
|
195
|
+
"リーチが入ったのだ、%{name}。待ちは%{waits}なのだ。",
|
|
196
|
+
"%{name}から%{turn}巡目のリーチなのだ! %{waits}待ちなのだ。"
|
|
197
|
+
],
|
|
198
|
+
"riichi_public" => [
|
|
199
|
+
"%{name}、リーチなのだ!",
|
|
200
|
+
"リーチが入ったのだ、%{name}。",
|
|
201
|
+
"%{name}から%{turn}巡目のリーチなのだ!"
|
|
202
|
+
],
|
|
203
|
+
"new_dora" => [
|
|
204
|
+
"新ドラは%{dora}なのだ。",
|
|
205
|
+
"カンドラがめくれて、ドラは%{dora}なのだ。"
|
|
206
|
+
],
|
|
207
|
+
"wall" => [
|
|
208
|
+
"残り%{left}枚なのだ。",
|
|
209
|
+
"山は残り%{left}枚になったのだ。"
|
|
210
|
+
],
|
|
211
|
+
"tenpai" => [
|
|
212
|
+
"%{name}、テンパイなのだ。待ちは%{waits}なのだ。",
|
|
213
|
+
"%{name}の手がテンパイしたのだ。%{waits}待ちなのだ。",
|
|
214
|
+
"%{name}、静かにテンパイなのだ。%{waits}待ちなのだ。"
|
|
215
|
+
],
|
|
216
|
+
"win" => [
|
|
217
|
+
"%{by_word}なのだ! %{name}、%{yaku}。%{points}なのだ。",
|
|
218
|
+
"%{name}の%{by_word}なのだ! %{yaku}で%{points}なのだ。",
|
|
219
|
+
"%{name}、%{from_part}%{by_word}なのだ。%{yaku}、%{points}なのだ。"
|
|
220
|
+
],
|
|
221
|
+
"win_strong" => [
|
|
222
|
+
"%{by_word}なのだ!! %{name}、%{level}なのだ! %{yaku}、%{points}なのだ!",
|
|
223
|
+
"出たのだ、%{level}! %{name}の%{by_word}、%{yaku}。%{points}なのだ!",
|
|
224
|
+
"大きいのだ! %{name}、%{from_part}%{by_word}で%{level}なのだ! %{yaku}、%{points}なのだ!"
|
|
225
|
+
],
|
|
226
|
+
"draw" => [
|
|
227
|
+
"流局なのだ。%{tenpai_line}",
|
|
228
|
+
"ここで流局なのだ。%{tenpai_line}"
|
|
229
|
+
],
|
|
230
|
+
"abort" => [
|
|
231
|
+
"途中流局なのだ、%{kind}。",
|
|
232
|
+
"%{kind}で流局なのだ。"
|
|
233
|
+
],
|
|
234
|
+
"lead_change" => [
|
|
235
|
+
"逆転なのだ! %{name}がトップに立ったのだ、%{score}点。",
|
|
236
|
+
"トップが入れ替わるのだ! %{name}、%{score}点でトップなのだ。",
|
|
237
|
+
"ここで逆転、%{name}がトップなのだ!"
|
|
238
|
+
],
|
|
239
|
+
"game_over" => [
|
|
240
|
+
"終局なのだ! トップは%{top}、%{top_score}点。%{ranks}",
|
|
241
|
+
"対局終了なのだ。%{top}が%{top_score}点でトップなのだ。%{ranks}"
|
|
242
|
+
]
|
|
243
|
+
}.freeze
|
|
244
|
+
|
|
245
|
+
# The characters' tones: which VOICEVOX characters speak in each, its
|
|
246
|
+
# phrasings, and how to ask an AI to speak so (C3). Any other
|
|
247
|
+
# character, and the browser's voice, speak plain.
|
|
248
|
+
TONES = {
|
|
249
|
+
"plain" => {
|
|
250
|
+
"speakers" => [],
|
|
251
|
+
"phrases" => PHRASES,
|
|
252
|
+
"prompt" => "落ち着いた実況の口調で、です・ます調で話す。"
|
|
253
|
+
},
|
|
254
|
+
"zundamon" => {
|
|
255
|
+
"speakers" => ["ずんだもん"],
|
|
256
|
+
"phrases" => ZUNDAMON_PHRASES,
|
|
257
|
+
"prompt" => "ずんだもんの口調で話す。語尾は「〜なのだ」「〜のだ」、一人称は「ボク」。明るく元気に、短く。"
|
|
258
|
+
}
|
|
259
|
+
}.freeze
|
|
260
|
+
|
|
261
|
+
# Words a speech synthesiser reads wrongly (VOICEVOX: 流局 ナガレキョク,
|
|
262
|
+
# 起家 オコリカ, 跳満 トベミツル, 三萬 サンヨロズ): the speech and the
|
|
263
|
+
# fixed phrases are read through this (reading), longest first. The
|
|
264
|
+
# tiles and the yaku are read by their own tables already; these are
|
|
265
|
+
# the words of the phrasings and what could slip through. 本場, 供託,
|
|
266
|
+
# 巡目, 親番, 終局, 逆転 are read rightly as they are (checked on
|
|
267
|
+
# VOICEVOX 0.25.0) and stay kanji.
|
|
268
|
+
SPEECH_READ = {
|
|
269
|
+
"途中流局" => "途中リューキョク", "流局" => "リューキョク", "起家" => "チーチャ", "東1局" => "トン1局",
|
|
270
|
+
"三倍満" => "サンバイマン", "跳満" => "ハネマン", "倍満" => "バイマン", "満貫" => "マンガン", "役満" => "ヤクマン",
|
|
271
|
+
"立直" => "リーチ", "一発" => "イッパツ", "裏ドラ" => "ウラドラ", "赤ドラ" => "アカドラ",
|
|
272
|
+
"符" => "フ", "翻" => "ハン", "暗槓" => "アンカン", "加槓" => "カカン",
|
|
273
|
+
"大明槓" => "ダイミンカン", "聴牌" => "テンパイ", "和了" => "ホーラ"
|
|
274
|
+
}.freeze
|
|
275
|
+
SPEECH_READ_RE = Regexp.union(SPEECH_READ.keys.sort_by { |k| -k.size })
|
|
276
|
+
|
|
277
|
+
LEVEL_CLIP = { "満貫" => "mangan", "跳満" => "haneman", "倍満" => "baiman", "三倍満" => "sanbaiman",
|
|
278
|
+
"役満" => "yakuman" }.freeze
|
|
279
|
+
|
|
280
|
+
module_function
|
|
281
|
+
|
|
282
|
+
# The tone of a VOICEVOX character (nil: the browser's voice).
|
|
283
|
+
def tone_for(speaker)
|
|
284
|
+
TONES.find { |_, t| t["speakers"].include?(speaker) }&.first || "plain"
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def phrases(tone) = (TONES[tone] || TONES["plain"])["phrases"]
|
|
288
|
+
|
|
289
|
+
# The keys of the fixed phrases played before the line of ev
|
|
290
|
+
# (mahjong_voice.yml): the round's name, riichi, the call, ron or
|
|
291
|
+
# tsumo and the level, ... Nothing for what tells a hand (a tenpai)
|
|
292
|
+
# or for the small things (a discard, the wall).
|
|
293
|
+
def clips(ev)
|
|
294
|
+
case ev["type"]
|
|
295
|
+
when "round_start"
|
|
296
|
+
return ["oorasu"] if ev["oorasu"]
|
|
297
|
+
return ["game_start"] if ev["game_start"]
|
|
298
|
+
r = ev["round"]
|
|
299
|
+
r.is_a?(Hash) && r["wind"] && r["number"] ? ["round_#{r['wind']}#{r['number']}"] : []
|
|
300
|
+
when "riichi" then ["riichi"]
|
|
301
|
+
when "call"
|
|
302
|
+
c = ev["call"].to_s
|
|
303
|
+
return ["kan"] if c.end_with?("kan")
|
|
304
|
+
%w[chi pon].include?(c) ? [c] : []
|
|
305
|
+
when "win"
|
|
306
|
+
out = [ev["by"] == "ron" ? "ron" : "tsumo"]
|
|
307
|
+
out << LEVEL_CLIP[ev["level"]] if LEVEL_CLIP[ev["level"]]
|
|
308
|
+
out
|
|
309
|
+
when "draw" then ["ryuukyoku"]
|
|
310
|
+
when "abort" then ["abort"]
|
|
311
|
+
when "lead_change" then ["lead_change"]
|
|
312
|
+
when "game_over" then ["game_over"]
|
|
313
|
+
else []
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# The line of an event in view (the view the event came from).
|
|
318
|
+
def line(ev, view, rng = Random.new, tone: "plain")
|
|
319
|
+
key, pkey = keys_of(ev)
|
|
320
|
+
book = phrases(tone)
|
|
321
|
+
vals = values(ev, view, tone)
|
|
322
|
+
i = rng.rand(book.fetch(key).size)
|
|
323
|
+
text, speech = fill(book[key][i], vals)
|
|
324
|
+
out = { "type" => ev["type"], "prio" => ev["prio"].to_i, "text" => text, "speech" => speech }
|
|
325
|
+
if ev["hands"]
|
|
326
|
+
out["ptext"] = nil
|
|
327
|
+
out["pspeech"] = nil
|
|
328
|
+
elsif pkey
|
|
329
|
+
pt = book.fetch(pkey)
|
|
330
|
+
out["ptext"], out["pspeech"] = fill(pt[i % pt.size], vals)
|
|
331
|
+
else
|
|
332
|
+
out["ptext"] = text
|
|
333
|
+
out["pspeech"] = speech
|
|
334
|
+
end
|
|
335
|
+
out["clips"] = clips(ev)
|
|
336
|
+
out
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# The phrasing's key, and the one with the hands hidden (nil: the same).
|
|
340
|
+
def keys_of(ev)
|
|
341
|
+
case ev["type"]
|
|
342
|
+
when "round_start"
|
|
343
|
+
return ["round_start_oorasu_again", nil] if ev["oorasu"] && ev["again"]
|
|
344
|
+
return ["round_start_oorasu", nil] if ev["oorasu"]
|
|
345
|
+
return ["round_start_game", nil] if ev["game_start"]
|
|
346
|
+
["round_start", nil]
|
|
347
|
+
when "dora_discard" then [ev["red"] && !ev["dora"] ? "red_discard" : "dora_discard", nil]
|
|
348
|
+
when "call" then [ev["call"].to_s.end_with?("kan") ? "kan" : "call", nil]
|
|
349
|
+
when "riichi" then ev["waits"] ? %w[riichi riichi_public] : ["riichi_public", nil]
|
|
350
|
+
when "win" then [ev["level"] ? "win_strong" : "win", nil]
|
|
351
|
+
else [ev["type"], nil]
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# The values of the templates: {key => [caption, speech]}.
|
|
356
|
+
def values(ev, view, tone = "plain")
|
|
357
|
+
z = tone == "zundamon"
|
|
358
|
+
v = {}
|
|
359
|
+
seats = Array(view.is_a?(Hash) ? view["seats"] : nil)
|
|
360
|
+
name = ->(i) { seat_name(seats, i) }
|
|
361
|
+
both = ->(s) { [s, s] }
|
|
362
|
+
v["name"] = both.call(name.call(ev["seat"])) if ev.key?("seat")
|
|
363
|
+
r = ev["round"] || (view.is_a?(Hash) ? view["round"] : nil)
|
|
364
|
+
if r.is_a?(Hash)
|
|
365
|
+
v["round"] = round_words(r)
|
|
366
|
+
v["dealer"] = both.call(name.call(r["dealer"]))
|
|
367
|
+
end
|
|
368
|
+
if ev["dora"].is_a?(Array)
|
|
369
|
+
d = ev["dora"].compact
|
|
370
|
+
v["dora"] = d.empty? ? %w[なし なし] : tiles_words(d)
|
|
371
|
+
elsif ev["dora"].is_a?(String) && ev["type"] == "new_dora"
|
|
372
|
+
v["dora"] = tile_words(ev["dora"])
|
|
373
|
+
end
|
|
374
|
+
if ev["tile"]
|
|
375
|
+
v["tile"] = tile_words(ev["tile"])
|
|
376
|
+
v["plain"] = tile_words(MahjongEvents.plain(ev["tile"]))
|
|
377
|
+
end
|
|
378
|
+
v["waits"] = tiles_words(ev["waits"]) if ev["waits"]
|
|
379
|
+
v["turn"] = both.call(ev["turn"].to_s) if ev["turn"]
|
|
380
|
+
v["left"] = both.call(ev["left"].to_s) if ev["left"]
|
|
381
|
+
if ev["type"] == "call"
|
|
382
|
+
v["call"] = [CALL_TEXT[ev["call"]] || ev["call"].to_s, CALL_TEXT[ev["call"]] || ev["call"].to_s]
|
|
383
|
+
v["kind"] = [CALL_KIND[ev["call"]] || ev["call"].to_s, CALL_KIND_READ[ev["call"]] || ev["call"].to_s]
|
|
384
|
+
tiles = Array(ev["tiles"])
|
|
385
|
+
v["tiles"] = tiles_words(tiles, sep: "")
|
|
386
|
+
# A pon or a kan is one tile said once; a chi its run.
|
|
387
|
+
v["called"] = ev["call"] == "chi" ? tiles_words(tiles, sep: "") : tile_words(tiles.min_by { |t| t.start_with?("0") ? 1 : 0 } || "")
|
|
388
|
+
end
|
|
389
|
+
if ev["type"] == "win"
|
|
390
|
+
v["by_word"] = ev["by"] == "ron" ? %w[ロン ロン] : %w[ツモ ツモ]
|
|
391
|
+
v["yaku"] = yaku_words(ev["yaku"])
|
|
392
|
+
# The level (満貫 ...) is said by the strong phrasings already.
|
|
393
|
+
v["points"] = points_words(ev["level"] ? ev["points"].to_s.sub(ev["level"].to_s, "").squeeze(" ") : ev["points"])
|
|
394
|
+
v["level"] = [ev["level"].to_s, LEVEL_READ[ev["level"]] || ev["level"].to_s]
|
|
395
|
+
v["from_part"] = ev["by"] == "ron" && ev["from"].is_a?(Integer) ? both.call("#{name.call(ev['from'])}から") : ["", ""]
|
|
396
|
+
end
|
|
397
|
+
if ev["type"] == "draw"
|
|
398
|
+
t = Array(ev["tenpai"])
|
|
399
|
+
v["tenpai_line"] = if t.empty? then both.call(z ? "全員ノーテンなのだ。" : "全員ノーテン。")
|
|
400
|
+
elsif t.size == 4 then both.call(z ? "全員テンパイなのだ。" : "全員テンパイ。")
|
|
401
|
+
else both.call("テンパイは#{t.map { |i| name.call(i) }.join('、')}#{'なのだ' if z}。")
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
v["kind"] = [ABORT_TEXT[ev["kind"]] || ev["kind"].to_s, ABORT_READ[ev["kind"]] || ev["kind"].to_s] if ev["type"] == "abort"
|
|
405
|
+
v["score"] = both.call(ev["score"].to_s) if ev["score"]
|
|
406
|
+
if ev["type"] == "game_over"
|
|
407
|
+
fin = Array(ev["final"]).sort_by { |f| f[1].to_i }
|
|
408
|
+
top = fin.first || []
|
|
409
|
+
v["top"] = both.call(name.call(top[0]))
|
|
410
|
+
v["top_score"] = both.call(top[2].to_s)
|
|
411
|
+
rest = fin.drop(1).map { |f| "#{f[1]}位 #{name.call(f[0])} #{f[2]}点" }.join("、")
|
|
412
|
+
v["ranks"] = both.call(rest.empty? ? "" : "#{rest}。")
|
|
413
|
+
end
|
|
414
|
+
if ev["type"] == "round_start"
|
|
415
|
+
sc = seats.map { |s| s["score"].to_i }
|
|
416
|
+
unless sc.empty?
|
|
417
|
+
li = sc.index(sc.max)
|
|
418
|
+
v["leader"] = both.call(name.call(li))
|
|
419
|
+
v["lead_score"] = both.call(sc[li].to_s)
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
v["status_line"] = hello_words(ev, view, tone) if ev["type"] == "hello"
|
|
423
|
+
v
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
# The speech as a synthesiser should read it: SPEECH_READ, and a tile
|
|
427
|
+
# name that slipped through (5萬, 赤5筒, or an AI's 三萬 / 五筒) read
|
|
428
|
+
# as a player says it.
|
|
429
|
+
KANJI_NUM = "一二三四五六七八九"
|
|
430
|
+
def reading(speech)
|
|
431
|
+
s = speech.to_s.gsub(/(赤)?([0-9#{KANJI_NUM}])(萬|筒|索)/o) do
|
|
432
|
+
red, d, suit = Regexp.last_match.captures
|
|
433
|
+
d = (KANJI_NUM.index(d) + 1).to_s if KANJI_NUM.include?(d)
|
|
434
|
+
red ||= d == "0"
|
|
435
|
+
d = "5" if d == "0"
|
|
436
|
+
"#{'アカ' if red}#{NUM_READ[d.to_i - 1]}#{SUIT_READ[SUIT_TEXT.key(suit)]}"
|
|
437
|
+
end
|
|
438
|
+
s.gsub(SPEECH_READ_RE) { |w| SPEECH_READ[w] }
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
# Fills a template from the values: [caption, speech].
|
|
442
|
+
def fill(tpl, vals)
|
|
443
|
+
text = tpl.gsub(/%\{(\w+)\}/) { (vals[Regexp.last_match(1)] || ["", ""])[0] }
|
|
444
|
+
speech = tpl.gsub(/%\{(\w+)\}/) { (vals[Regexp.last_match(1)] || ["", ""])[1] }
|
|
445
|
+
[text, reading(speech.delete("!").gsub(/\s+/, " ").strip)]
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def seat_name(seats, i)
|
|
449
|
+
return "?" unless i.is_a?(Integer)
|
|
450
|
+
n = seats[i].is_a?(Hash) ? seats[i]["name"].to_s.strip : ""
|
|
451
|
+
n.empty? ? "#{i + 1}番の席" : n
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
# 東1局 1本場 (供託 1): [caption, speech].
|
|
455
|
+
def round_words(r)
|
|
456
|
+
w = r["wind"].to_s
|
|
457
|
+
text = "#{WIND_TEXT[w] || w}#{r['number']}局"
|
|
458
|
+
read = "#{WIND_READ[w] || w}#{r['number']}局"
|
|
459
|
+
h = r["honba"].to_i
|
|
460
|
+
if h.positive?
|
|
461
|
+
text += " #{h}本場"
|
|
462
|
+
read += " #{h}本場"
|
|
463
|
+
end
|
|
464
|
+
[text, read]
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
# A tile: 3萬 / 赤5筒 / 東, and サンマン / アカウーピン / トン.
|
|
468
|
+
def tile_words(name)
|
|
469
|
+
n = name.to_s
|
|
470
|
+
m = n.match(/\A([0-9])([mpsz])\z/) or return [n, n]
|
|
471
|
+
d = m[1].to_i
|
|
472
|
+
if m[2] == "z"
|
|
473
|
+
return [HONOR_TEXT[d - 1] || n, HONOR_READ[d - 1] || n]
|
|
474
|
+
end
|
|
475
|
+
red = d.zero?
|
|
476
|
+
d = 5 if red
|
|
477
|
+
["#{'赤' if red}#{d}#{SUIT_TEXT[m[2]]}", "#{'アカ' if red}#{NUM_READ[d - 1]}#{SUIT_READ[m[2]]}"]
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
def tiles_words(names, sep: "・")
|
|
481
|
+
ws = names.map { |t| tile_words(t) }
|
|
482
|
+
[ws.map(&:first).join(sep), ws.map(&:last).join("、")]
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
# 立直・ドラ 1 / リーチ、ドラ1.
|
|
486
|
+
def yaku_words(list)
|
|
487
|
+
list = Array(list)
|
|
488
|
+
return %w[役なし 役なし] if list.empty?
|
|
489
|
+
text = list.map { |y| y.to_s.strip.squeeze(" ") }.join("・")
|
|
490
|
+
read = list.map { |y| yaku_read(y.to_s) }.join("、")
|
|
491
|
+
[text, read]
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
def yaku_read(y)
|
|
495
|
+
return YAKU_READ[y] if YAKU_READ.key?(y)
|
|
496
|
+
if (m = y.match(/\A(役牌|自風|場風)\s*(\S+)\z/))
|
|
497
|
+
i = HONOR_TEXT.index(m[2])
|
|
498
|
+
tile = i ? HONOR_READ[i] : m[2]
|
|
499
|
+
return { "役牌" => tile, "自風" => "ジカゼ #{tile}", "場風" => "バカゼ #{tile}" }[m[1]]
|
|
500
|
+
end
|
|
501
|
+
if (m = y.match(/\A(赤ドラ|裏ドラ|ドラ)\s*(\d+)\z/))
|
|
502
|
+
return "#{{ '赤ドラ' => 'アカドラ', '裏ドラ' => 'ウラドラ', 'ドラ' => 'ドラ' }[m[1]]}#{m[2]}"
|
|
503
|
+
end
|
|
504
|
+
y
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
# 30符 2翻 2000点 → the same, read: 30フ 2ハン 2000点; 満貫 → マンガン.
|
|
508
|
+
def points_words(pts)
|
|
509
|
+
text = pts.to_s.strip
|
|
510
|
+
read = text.gsub("符", "フ").gsub("翻", "ハン").gsub("点オール", "点オール").gsub("/", "、")
|
|
511
|
+
LEVEL_READ.each { |k, r| read = read.gsub(k, r) }
|
|
512
|
+
[text, read]
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
# What the first view says: where the table is.
|
|
516
|
+
def hello_words(ev, _view, tone = "plain")
|
|
517
|
+
z = tone == "zundamon"
|
|
518
|
+
case ev["status"]
|
|
519
|
+
when "play", "result"
|
|
520
|
+
r = ev["round"] || {}
|
|
521
|
+
rt, rr = round_words(r)
|
|
522
|
+
left = r["wall_left"]
|
|
523
|
+
sticks = r["riichi_sticks"].to_i
|
|
524
|
+
extra = +""
|
|
525
|
+
extra << "、供託#{sticks}本" if sticks.positive?
|
|
526
|
+
extra << "、残り#{left}枚" if left
|
|
527
|
+
return ["観戦を始めるのだ! #{rt}#{extra}なのだ。", "観戦を始めるのだ! #{rr}#{extra}なのだ。"] if z
|
|
528
|
+
["観戦を始めます。#{rt}#{extra}。", "観戦を始めます。#{rr}#{extra}。"]
|
|
529
|
+
when "over" then [z ? "観戦を始めるのだ! 対局は終わったところなのだ。" : "観戦を始めます。対局は終わったところです。"] * 2
|
|
530
|
+
else [z ? "観戦を始めるのだ! 卓は対局の始まりを待っているのだ。" : "観戦を始めます。卓は対局の始まりを待っています。"] * 2
|
|
531
|
+
end
|
|
532
|
+
end
|
|
533
|
+
end
|
|
534
|
+
end
|
|
535
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# The fixed phrases of the mahjong commentary (README "Commentary", "The
|
|
2
|
+
# voice"): played at once as WAV files while the rest of a line is being
|
|
3
|
+
# synthesised. No sound is in the gem: `asterism-games commentate voices
|
|
4
|
+
# --make` (and the commentary at its start) has this machine's VOICEVOX
|
|
5
|
+
# say each phrase into ~/.cache/asterism-games/voices/<speaker>-<style>/.
|
|
6
|
+
#
|
|
7
|
+
# <key>:
|
|
8
|
+
# word: the word as the line's own speech begins with it; the line
|
|
9
|
+
# leaves it out when the phrase was played before it
|
|
10
|
+
# plain: [the phrasings in a plain tone] (one is chosen at random)
|
|
11
|
+
# zundamon: [the same in ずんだもん's tone (〜なのだ)]
|
|
12
|
+
#
|
|
13
|
+
# A tone without phrasings of its own uses plain's. The phrasings are
|
|
14
|
+
# written as they are read (トン1局, not 東1局). Change a phrasing and its
|
|
15
|
+
# file is made again (the files are named by the phrasing's digest).
|
|
16
|
+
start:
|
|
17
|
+
plain: [実況を始めます。]
|
|
18
|
+
zundamon: [実況を始めるのだ!]
|
|
19
|
+
game_start:
|
|
20
|
+
word: 対局開始
|
|
21
|
+
plain: [対局開始!]
|
|
22
|
+
zundamon: [対局開始なのだ!]
|
|
23
|
+
oorasu:
|
|
24
|
+
word: オーラス
|
|
25
|
+
plain: [オーラス!]
|
|
26
|
+
zundamon: [オーラスなのだ!]
|
|
27
|
+
round_east1: { word: トン1局, plain: [トン1局。], zundamon: [トン1局なのだ。] }
|
|
28
|
+
round_east2: { word: トン2局, plain: [トン2局。], zundamon: [トン2局なのだ。] }
|
|
29
|
+
round_east3: { word: トン3局, plain: [トン3局。], zundamon: [トン3局なのだ。] }
|
|
30
|
+
round_east4: { word: トン4局, plain: [トン4局。], zundamon: [トン4局なのだ。] }
|
|
31
|
+
round_south1: { word: ナン1局, plain: [ナン1局。], zundamon: [ナン1局なのだ。] }
|
|
32
|
+
round_south2: { word: ナン2局, plain: [ナン2局。], zundamon: [ナン2局なのだ。] }
|
|
33
|
+
round_south3: { word: ナン3局, plain: [ナン3局。], zundamon: [ナン3局なのだ。] }
|
|
34
|
+
round_south4: { word: ナン4局, plain: [ナン4局。], zundamon: [ナン4局なのだ。] }
|
|
35
|
+
round_west1: { word: シャー1局, plain: [シャー1局。], zundamon: [シャー1局なのだ。] }
|
|
36
|
+
round_west2: { word: シャー2局, plain: [シャー2局。], zundamon: [シャー2局なのだ。] }
|
|
37
|
+
round_west3: { word: シャー3局, plain: [シャー3局。], zundamon: [シャー3局なのだ。] }
|
|
38
|
+
round_west4: { word: シャー4局, plain: [シャー4局。], zundamon: [シャー4局なのだ。] }
|
|
39
|
+
riichi:
|
|
40
|
+
word: リーチ
|
|
41
|
+
plain: [リーチ!]
|
|
42
|
+
zundamon: [リーチなのだ!, リーチしたのだ!]
|
|
43
|
+
chi:
|
|
44
|
+
word: チー
|
|
45
|
+
plain: [チー!]
|
|
46
|
+
zundamon: [チーなのだ!]
|
|
47
|
+
pon:
|
|
48
|
+
word: ポン
|
|
49
|
+
plain: [ポン!]
|
|
50
|
+
zundamon: [ポンなのだ!]
|
|
51
|
+
kan:
|
|
52
|
+
word: カン
|
|
53
|
+
plain: [カン!]
|
|
54
|
+
zundamon: [カンなのだ!]
|
|
55
|
+
ron:
|
|
56
|
+
word: ロン
|
|
57
|
+
plain: [ロン!]
|
|
58
|
+
zundamon: [ロンなのだ!]
|
|
59
|
+
tsumo:
|
|
60
|
+
word: ツモ
|
|
61
|
+
plain: [ツモ!]
|
|
62
|
+
zundamon: [ツモなのだ!]
|
|
63
|
+
mangan:
|
|
64
|
+
plain: [満貫!]
|
|
65
|
+
zundamon: [満貫なのだ!]
|
|
66
|
+
haneman:
|
|
67
|
+
plain: [跳満!]
|
|
68
|
+
zundamon: [跳満なのだ!]
|
|
69
|
+
baiman:
|
|
70
|
+
plain: [倍満!]
|
|
71
|
+
zundamon: [倍満なのだ!]
|
|
72
|
+
sanbaiman:
|
|
73
|
+
plain: [三倍満!]
|
|
74
|
+
zundamon: [三倍満なのだ!]
|
|
75
|
+
yakuman:
|
|
76
|
+
plain: [役満!]
|
|
77
|
+
zundamon: [役満なのだ!, 役満が出たのだ!]
|
|
78
|
+
ryuukyoku:
|
|
79
|
+
word: 流局
|
|
80
|
+
plain: [流局。]
|
|
81
|
+
zundamon: [流局なのだ。]
|
|
82
|
+
abort:
|
|
83
|
+
word: 途中流局
|
|
84
|
+
plain: [途中流局。]
|
|
85
|
+
zundamon: [途中流局なのだ。]
|
|
86
|
+
lead_change:
|
|
87
|
+
word: 逆転
|
|
88
|
+
plain: [逆転!]
|
|
89
|
+
zundamon: [逆転なのだ!]
|
|
90
|
+
game_over:
|
|
91
|
+
word: 終局
|
|
92
|
+
plain: [終局!]
|
|
93
|
+
zundamon: [終局なのだ!]
|