@johnmorrisdotca/domino 1.0.0 → 1.1.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.
- package/CHANGELOG.md +33 -0
- package/README.md +369 -17
- package/bin/domino.mjs +22 -0
- package/dist/cli.d.ts +33 -0
- package/dist/cli.js +243 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +3 -0
- package/dist/sounds.d.ts +10 -0
- package/dist/sounds.js +18 -0
- package/dist/strings.d.ts +93 -0
- package/dist/strings.js +214 -0
- package/dist/tile-sounds.d.ts +68 -0
- package/dist/tile-sounds.js +212 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/words.d.ts +26 -0
- package/dist/words.js +83 -0
- package/docs/credits.md +49 -0
- package/package.json +48 -7
package/dist/strings.js
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every word Domino says to a person, in English and Japanese: what a table
|
|
3
|
+
* says (whose turn it is, what was laid, how the round ended) and what the
|
|
4
|
+
* command line says. One table, so the two languages are kept side by side
|
|
5
|
+
* and a test can hold them together. `{n}`, `{who}` and the other braces are
|
|
6
|
+
* filled in with `dominoSay`; a table of your own must keep them.
|
|
7
|
+
*/
|
|
8
|
+
/** Every string, in both languages. The Japanese has not yet been read by a native reader. */
|
|
9
|
+
export const DOMINO_STRINGS = {
|
|
10
|
+
en: {
|
|
11
|
+
set9: "Double-nine",
|
|
12
|
+
set12: "Double-twelve",
|
|
13
|
+
set15: "Double-fifteen",
|
|
14
|
+
lengthFull: "Every double",
|
|
15
|
+
lengthShort: "Short",
|
|
16
|
+
doublesOne: "Cover one",
|
|
17
|
+
doublesChain: "Chain",
|
|
18
|
+
mexicanAny: "Anyone, any time",
|
|
19
|
+
mexicanOwnFirst: "After your own",
|
|
20
|
+
you: "You",
|
|
21
|
+
computer: "Computer {n}",
|
|
22
|
+
player: "Player {n}",
|
|
23
|
+
mexican: "Mexican Train",
|
|
24
|
+
trainYours: "Your train",
|
|
25
|
+
trainOf: "{who}'s train",
|
|
26
|
+
open: "open",
|
|
27
|
+
closed: "closed",
|
|
28
|
+
tilesLeft: "{n} left",
|
|
29
|
+
boneyard: "Boneyard: {n}",
|
|
30
|
+
round: "Round {n} of {total}, hub double {engine}",
|
|
31
|
+
needs: "Next: {n}",
|
|
32
|
+
hub: "Hub",
|
|
33
|
+
total: "Total",
|
|
34
|
+
pips: "{n} pips",
|
|
35
|
+
layHere: "Lay here",
|
|
36
|
+
draw: "Draw a tile",
|
|
37
|
+
pass: "Pass",
|
|
38
|
+
nextRound: "Next round",
|
|
39
|
+
again: "Play again",
|
|
40
|
+
yourTurn: "Your turn. Tap a tile to lay it.",
|
|
41
|
+
yourTurnCover: "Your turn. Cover the double.",
|
|
42
|
+
yourTurnDraw: "Nothing fits. Draw a tile.",
|
|
43
|
+
yourTurnPass: "Nothing fits. Pass.",
|
|
44
|
+
yourTurnChoose: "Choose a train for the tile.",
|
|
45
|
+
thinking: "{who} to play.",
|
|
46
|
+
roundOver: "Round over. {why}",
|
|
47
|
+
outBy: "{who} played out.",
|
|
48
|
+
blocked: "Nobody can play.",
|
|
49
|
+
gameOver: "Game over. {who} won with {pips} pips.",
|
|
50
|
+
laid: "{who} laid {tile} on {train}.",
|
|
51
|
+
drew: "{who} drew a tile.",
|
|
52
|
+
passed: "{who} passed.",
|
|
53
|
+
dealt: "Round {n} dealt: the hub double is {engine}.",
|
|
54
|
+
cliUsage: `Usage: domino <command> [options]
|
|
55
|
+
|
|
56
|
+
Dominoes and Mexican Train: the same deal for the same seed, on every machine.
|
|
57
|
+
|
|
58
|
+
domino deal --seed 2026 --players 4 the deal a seed makes: every hand, and the hub double
|
|
59
|
+
domino play --seed 2026 --players 4 a game played out by computers, with its scores
|
|
60
|
+
domino play --seed 2026 --moves ...and every move, in words
|
|
61
|
+
domino play --seed 2026 --save ...and the saved game, as text
|
|
62
|
+
domino check "<saved game>" read a saved game back through the rules, and say where it stands
|
|
63
|
+
domino replay "<saved game>" every move of a saved game, in words
|
|
64
|
+
|
|
65
|
+
Options:
|
|
66
|
+
--set <9|12|15> the set, by its highest double (12 unless said)
|
|
67
|
+
--players <2..8> how many sit at the table (4 unless said)
|
|
68
|
+
-s, --seed <n> a whole number (one is drawn, and named on standard error, unless given)
|
|
69
|
+
--length <full|short> every round, or half of them (full unless said)
|
|
70
|
+
--doubles <one|chain> cover each double at once, or chain doubles (one unless said)
|
|
71
|
+
--mexican <any|own-first>
|
|
72
|
+
the Mexican Train open to all, or only once you have laid on your own
|
|
73
|
+
--moves play: print every move
|
|
74
|
+
--save play: print the saved game on the last line
|
|
75
|
+
--stdin check, replay: read the saved game from standard input
|
|
76
|
+
-j, --json print JSON (format 1) for deal, play and check
|
|
77
|
+
--lang <en|ja> English or Japanese (default: your system's)
|
|
78
|
+
-h, --help this help
|
|
79
|
+
-v, --version the version
|
|
80
|
+
|
|
81
|
+
Exit codes: 0 done, 1 what was asked for could not be done (a saved game the
|
|
82
|
+
rules refuse), 2 the command was wrong.
|
|
83
|
+
`,
|
|
84
|
+
cliUnknown: "unknown option {part}",
|
|
85
|
+
cliNeeds: "{part} needs a value",
|
|
86
|
+
cliTryHelp: "Try `domino --help`.",
|
|
87
|
+
cliLangBad: "--lang takes en or ja",
|
|
88
|
+
cliNoCommand: "say what to do: deal, play, check or replay",
|
|
89
|
+
cliCommandBad: "“{part}” is not a command: deal, play, check or replay",
|
|
90
|
+
cliSetBad: "--set takes 9, 12 or 15",
|
|
91
|
+
cliPlayersBad: "--players takes a whole number from 2 to 8",
|
|
92
|
+
cliSeedBad: "--seed takes a whole number from 0 to 4294967295",
|
|
93
|
+
cliValueBad: "{part} takes one of: {choices}",
|
|
94
|
+
cliNoSaved: "there is no saved game to read: give it after the command, or with --stdin",
|
|
95
|
+
cliSavedBad: "that is not a game these rules can play out again",
|
|
96
|
+
cliFresh: "seed {seed} (pass --seed {seed} to repeat this)",
|
|
97
|
+
cliDeal: "{set}, {players} players, seed {seed}",
|
|
98
|
+
cliSeat: "{who}: {tiles}",
|
|
99
|
+
cliBoneyard: "Boneyard: {n} tiles",
|
|
100
|
+
cliPlayed: "{set}, {players} players, seed {seed}, {rounds} rounds; moves made: {moves}",
|
|
101
|
+
cliWinner: "Winner: {who} with {pips} pips.",
|
|
102
|
+
cliWinners: "Winners: {who}, each with {pips} pips.",
|
|
103
|
+
cliSaved: "Saved game:",
|
|
104
|
+
cliPhasePlaying: "Round {n} of {total} is being played; {who} is to play.",
|
|
105
|
+
cliPhaseRoundOver: "Round {n} of {total} is over; the next is to be dealt.",
|
|
106
|
+
cliPhaseFinished: "The game is over.",
|
|
107
|
+
},
|
|
108
|
+
ja: {
|
|
109
|
+
set9: "ダブルナイン",
|
|
110
|
+
set12: "ダブルトゥエルブ",
|
|
111
|
+
set15: "ダブルフィフティーン",
|
|
112
|
+
lengthFull: "すべてのダブル",
|
|
113
|
+
lengthShort: "短め",
|
|
114
|
+
doublesOne: "1枚で覆う",
|
|
115
|
+
doublesChain: "連続",
|
|
116
|
+
mexicanAny: "いつでも誰でも",
|
|
117
|
+
mexicanOwnFirst: "自分の道のあと",
|
|
118
|
+
you: "あなた",
|
|
119
|
+
computer: "コンピューター{n}",
|
|
120
|
+
player: "プレイヤー{n}",
|
|
121
|
+
mexican: "メキシカントレイン",
|
|
122
|
+
trainYours: "あなたの道",
|
|
123
|
+
trainOf: "{who}の道",
|
|
124
|
+
open: "開放",
|
|
125
|
+
closed: "閉鎖",
|
|
126
|
+
tilesLeft: "残り{n}枚",
|
|
127
|
+
boneyard: "山: {n}枚",
|
|
128
|
+
round: "第{n}ラウンド(全{total})、ハブのダブル {engine}",
|
|
129
|
+
needs: "次: {n}",
|
|
130
|
+
hub: "ハブ",
|
|
131
|
+
total: "合計",
|
|
132
|
+
pips: "{n}点",
|
|
133
|
+
layHere: "ここに置く",
|
|
134
|
+
draw: "1枚引く",
|
|
135
|
+
pass: "パス",
|
|
136
|
+
nextRound: "次のラウンド",
|
|
137
|
+
again: "もう一度",
|
|
138
|
+
yourTurn: "あなたの番です。牌をタップして置きます。",
|
|
139
|
+
yourTurnCover: "あなたの番です。ダブルを覆ってください。",
|
|
140
|
+
yourTurnDraw: "置ける牌がありません。1枚引いてください。",
|
|
141
|
+
yourTurnPass: "置ける牌がありません。パスしてください。",
|
|
142
|
+
yourTurnChoose: "牌を置く道を選んでください。",
|
|
143
|
+
thinking: "{who}の番です。",
|
|
144
|
+
roundOver: "ラウンド終了。{why}",
|
|
145
|
+
outBy: "{who}が出し切りました。",
|
|
146
|
+
blocked: "誰も置けません。",
|
|
147
|
+
gameOver: "ゲーム終了。{who}の勝ち({pips}点)。",
|
|
148
|
+
laid: "{who}が{tile}を{train}に置きました。",
|
|
149
|
+
drew: "{who}が1枚引きました。",
|
|
150
|
+
passed: "{who}はパスしました。",
|
|
151
|
+
dealt: "第{n}ラウンドを配りました。ハブのダブルは{engine}です。",
|
|
152
|
+
cliUsage: `使い方: domino <コマンド> [オプション]
|
|
153
|
+
|
|
154
|
+
ドミノとメキシカントレイン。同じシードなら、どの環境でも同じ配りになります。
|
|
155
|
+
|
|
156
|
+
domino deal --seed 2026 --players 4 シードが作る配り(全員の手牌とハブのダブル)
|
|
157
|
+
domino play --seed 2026 --players 4 コンピューターだけで最後まで遊び、得点を表示
|
|
158
|
+
domino play --seed 2026 --moves ...さらに、すべての手を言葉で表示
|
|
159
|
+
domino play --seed 2026 --save ...さらに、保存したゲームをテキストで表示
|
|
160
|
+
domino check "<保存したゲーム>" 保存したゲームをルールどおりに読み直し、状況を表示
|
|
161
|
+
domino replay "<保存したゲーム>" 保存したゲームのすべての手を言葉で表示
|
|
162
|
+
|
|
163
|
+
オプション:
|
|
164
|
+
--set <9|12|15> セット(最大のダブルで指定。指定なしは12)
|
|
165
|
+
--players <2..8> 卓の人数(指定なしは4)
|
|
166
|
+
-s, --seed <n> 整数(指定がなければ決めて、標準エラーに表示します)
|
|
167
|
+
--length <full|short> すべてのラウンド、またはその半分(指定なしは full)
|
|
168
|
+
--doubles <one|chain> ダブルをすぐ覆う、または連続で出せる(指定なしは one)
|
|
169
|
+
--mexican <any|own-first>
|
|
170
|
+
メキシカントレインを誰でも使える、または自分の道に置いたあと
|
|
171
|
+
--moves play: すべての手を表示
|
|
172
|
+
--save play: 最後の行に保存したゲームを表示
|
|
173
|
+
--stdin check, replay: 保存したゲームを標準入力から読む
|
|
174
|
+
-j, --json deal, play, check の結果をJSON(形式1)で表示
|
|
175
|
+
--lang <en|ja> 英語または日本語(指定なしはシステムの言語)
|
|
176
|
+
-h, --help このヘルプ
|
|
177
|
+
-v, --version バージョン
|
|
178
|
+
|
|
179
|
+
終了コード: 0 完了、1 できなかった(ルールが受け付けない保存ゲーム)、
|
|
180
|
+
2 コマンドの誤り。
|
|
181
|
+
`,
|
|
182
|
+
cliUnknown: "不明なオプションです: {part}",
|
|
183
|
+
cliNeeds: "{part} には値が必要です",
|
|
184
|
+
cliTryHelp: "`domino --help` をご覧ください。",
|
|
185
|
+
cliLangBad: "--lang は en か ja です",
|
|
186
|
+
cliNoCommand: "コマンドを指定してください: deal, play, check, replay",
|
|
187
|
+
cliCommandBad: "「{part}」はコマンドではありません: deal, play, check, replay",
|
|
188
|
+
cliSetBad: "--set は 9、12、15 のいずれかです",
|
|
189
|
+
cliPlayersBad: "--players は2〜8の整数です",
|
|
190
|
+
cliSeedBad: "--seed は0〜4294967295の整数です",
|
|
191
|
+
cliValueBad: "{part} は次のいずれかです: {choices}",
|
|
192
|
+
cliNoSaved: "読む保存ゲームがありません。コマンドの後ろに書くか、--stdin を使ってください",
|
|
193
|
+
cliSavedBad: "このルールでは最後まで再現できないゲームです",
|
|
194
|
+
cliFresh: "シード {seed}(--seed {seed} で同じ結果を再現できます)",
|
|
195
|
+
cliDeal: "{set}、{players}人、シード {seed}",
|
|
196
|
+
cliSeat: "{who}: {tiles}",
|
|
197
|
+
cliBoneyard: "山: {n}枚",
|
|
198
|
+
cliPlayed: "{set}、{players}人、シード {seed}、{rounds}ラウンド、手数: {moves}",
|
|
199
|
+
cliWinner: "勝者: {who}({pips}点)。",
|
|
200
|
+
cliWinners: "勝者: {who}(それぞれ{pips}点)。",
|
|
201
|
+
cliSaved: "保存したゲーム:",
|
|
202
|
+
cliPhasePlaying: "第{n}ラウンド(全{total})の途中です。{who}の番です。",
|
|
203
|
+
cliPhaseRoundOver: "第{n}ラウンド(全{total})が終わりました。次のラウンドを配ります。",
|
|
204
|
+
cliPhaseFinished: "ゲームは終了しています。",
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
/** A string with its braces filled in from `values`; a brace with no value is left as it is. */
|
|
208
|
+
export function dominoSay(text, values = {}) {
|
|
209
|
+
return text.replace(/\{(\w+)\}/g, (whole, name) => (name in values ? String(values[name]) : whole));
|
|
210
|
+
}
|
|
211
|
+
/** The language a person asked for, by `en` or `ja` or a tag that begins with one: English for anything else. */
|
|
212
|
+
export function dominoLanguage(tag) {
|
|
213
|
+
return (tag ?? "en").toLowerCase().startsWith("ja") ? "ja" : "en";
|
|
214
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE SOUNDS OF A DOMINO TABLE: tiles shuffled, a tile drawn from the
|
|
3
|
+
* boneyard, a tile laid, a knock on the table for a pass. The recordings are
|
|
4
|
+
* poker chips set down and handled (Kenney's Casino Audio, CC0; see
|
|
5
|
+
* docs/credits.md), the nearest recorded sound of a hard tile on a table that
|
|
6
|
+
* is free to use. They are loaded the first time a sound is played and never
|
|
7
|
+
* before, so a page that stays silent never fetches them. Where they cannot be
|
|
8
|
+
* loaded or decoded, a short sound made in the browser stands in. Nothing here
|
|
9
|
+
* throws: a platform with no audio, a context the browser holds still, or a
|
|
10
|
+
* failed decode is simply silent.
|
|
11
|
+
*
|
|
12
|
+
* Domino never makes a sound by itself. A table asks for one with `play`.
|
|
13
|
+
*/
|
|
14
|
+
/** Every kind of sound, in the order a game meets them. */
|
|
15
|
+
export declare const TILE_SOUND_KINDS: readonly ["shuffle", "draw", "lay", "knock"];
|
|
16
|
+
/** One kind of sound: `shuffle` the tiles stirred face down, `draw` a tile taken from the boneyard, `lay` a tile set down on a train, `knock` a rap on the table, which is how a pass is announced. */
|
|
17
|
+
export type TileSoundKind = (typeof TILE_SOUND_KINDS)[number];
|
|
18
|
+
/** The recordings, by name (`lay-2`), as base64 AAC. */
|
|
19
|
+
export type TileSoundData = Readonly<Record<string, string>>;
|
|
20
|
+
/** The parts of a window the sounds use: an audio context and `atob`. Any of them may be missing. */
|
|
21
|
+
export type TileSoundWindow = {
|
|
22
|
+
AudioContext?: typeof AudioContext;
|
|
23
|
+
webkitAudioContext?: typeof AudioContext;
|
|
24
|
+
atob?: (text: string) => string;
|
|
25
|
+
};
|
|
26
|
+
/** How a table's sounds are made. Every field may be left out. */
|
|
27
|
+
export type TileSoundsOptions = {
|
|
28
|
+
/** Start muted: nothing plays, and nothing is fetched, until `setMuted(false)`. Unless said, not muted. */
|
|
29
|
+
muted?: boolean;
|
|
30
|
+
/** How loud, from 0 to 1. Unless said, 0.6. */
|
|
31
|
+
volume?: number;
|
|
32
|
+
/** Where the recordings come from: the package's own module unless another is handed in. */
|
|
33
|
+
load?: () => Promise<{
|
|
34
|
+
TILE_SOUND_DATA: TileSoundData;
|
|
35
|
+
}>;
|
|
36
|
+
/** The window to make sound in: the page's own unless another is handed in (a test's, or none for silence). */
|
|
37
|
+
window?: TileSoundWindow | null;
|
|
38
|
+
};
|
|
39
|
+
/** How one sound is played. */
|
|
40
|
+
export type PlayTileSoundOptions = {
|
|
41
|
+
/** How many tiles: `draw` with 7 is seven tiles taken one after another (heard as at most `MOST_SOUNDS_AT_ONCE`). Unless said, one. */
|
|
42
|
+
count?: number;
|
|
43
|
+
/** Milliseconds between one tile's sound and the next. Unless said, 85. */
|
|
44
|
+
gap?: number;
|
|
45
|
+
/** Milliseconds to wait before the first. Unless said, none. */
|
|
46
|
+
delay?: number;
|
|
47
|
+
};
|
|
48
|
+
/** A table's sounds: `play` one, mute and unmute, change the volume, `close` when the table goes. */
|
|
49
|
+
export type TileSounds = {
|
|
50
|
+
/** Play a sound, or several of one kind in a row; nothing while muted or closed. */
|
|
51
|
+
play(kind: TileSoundKind, options?: PlayTileSoundOptions): void;
|
|
52
|
+
/** Fetch and decode the recordings now, rather than at the first sound. True once they are ready; false where they cannot be had. */
|
|
53
|
+
load(): Promise<boolean>;
|
|
54
|
+
/** Whether it is muted. */
|
|
55
|
+
readonly muted: boolean;
|
|
56
|
+
/** Mute or unmute. Muting stops nothing already playing; it only keeps anything new from starting. */
|
|
57
|
+
setMuted(muted: boolean): void;
|
|
58
|
+
/** How loud, from 0 to 1. */
|
|
59
|
+
volume: number;
|
|
60
|
+
/** Stop for good, and let the audio context go. */
|
|
61
|
+
close(): void;
|
|
62
|
+
};
|
|
63
|
+
/** As many sounds as one call plays: fifteen tiles drawn are eight clicks, not a wall of noise. */
|
|
64
|
+
export declare const MOST_SOUNDS_AT_ONCE = 8;
|
|
65
|
+
/** When each of `count` sounds starts, in milliseconds from the first: one every `gap`, and no more than `MOST_SOUNDS_AT_ONCE`, spread over the same time. */
|
|
66
|
+
export declare function soundTimes(count: number, gap?: number): number[];
|
|
67
|
+
/** A table's tile sounds. Nothing is fetched and no audio context is made until the first sound. */
|
|
68
|
+
export declare function createTileSounds(options?: TileSoundsOptions): TileSounds;
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE SOUNDS OF A DOMINO TABLE: tiles shuffled, a tile drawn from the
|
|
3
|
+
* boneyard, a tile laid, a knock on the table for a pass. The recordings are
|
|
4
|
+
* poker chips set down and handled (Kenney's Casino Audio, CC0; see
|
|
5
|
+
* docs/credits.md), the nearest recorded sound of a hard tile on a table that
|
|
6
|
+
* is free to use. They are loaded the first time a sound is played and never
|
|
7
|
+
* before, so a page that stays silent never fetches them. Where they cannot be
|
|
8
|
+
* loaded or decoded, a short sound made in the browser stands in. Nothing here
|
|
9
|
+
* throws: a platform with no audio, a context the browser holds still, or a
|
|
10
|
+
* failed decode is simply silent.
|
|
11
|
+
*
|
|
12
|
+
* Domino never makes a sound by itself. A table asks for one with `play`.
|
|
13
|
+
*/
|
|
14
|
+
/** Every kind of sound, in the order a game meets them. */
|
|
15
|
+
export const TILE_SOUND_KINDS = ["shuffle", "draw", "lay", "knock"];
|
|
16
|
+
/** As many sounds as one call plays: fifteen tiles drawn are eight clicks, not a wall of noise. */
|
|
17
|
+
export const MOST_SOUNDS_AT_ONCE = 8;
|
|
18
|
+
/** When each of `count` sounds starts, in milliseconds from the first: one every `gap`, and no more than `MOST_SOUNDS_AT_ONCE`, spread over the same time. */
|
|
19
|
+
export function soundTimes(count, gap = 85) {
|
|
20
|
+
const whole = Math.max(1, Math.floor(Number.isFinite(count) ? count : 1));
|
|
21
|
+
const span = (whole - 1) * Math.max(0, gap);
|
|
22
|
+
const heard = Math.min(whole, MOST_SOUNDS_AT_ONCE);
|
|
23
|
+
if (heard === 1)
|
|
24
|
+
return [0];
|
|
25
|
+
return Array.from({ length: heard }, (_, at) => Math.round((at * span) / (heard - 1)));
|
|
26
|
+
}
|
|
27
|
+
const clampVolume = (value) => (Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : 0.6);
|
|
28
|
+
function bytesOf(base64, atob) {
|
|
29
|
+
const text = atob(base64);
|
|
30
|
+
const bytes = new Uint8Array(text.length);
|
|
31
|
+
for (let i = 0; i < text.length; i++)
|
|
32
|
+
bytes[i] = text.charCodeAt(i);
|
|
33
|
+
return bytes.buffer;
|
|
34
|
+
}
|
|
35
|
+
/** Decoding as a promise, on browsers that take callbacks and on those that return one. */
|
|
36
|
+
function decode(ctx, data) {
|
|
37
|
+
return new Promise((resolve, reject) => {
|
|
38
|
+
const pending = ctx.decodeAudioData(data, resolve, reject);
|
|
39
|
+
if (pending !== undefined && typeof pending.then === "function")
|
|
40
|
+
pending.then(resolve, reject);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
/** What a sound made here is like, for when the recordings cannot be had: bursts of noise, each band-passed and cut off fast. */
|
|
44
|
+
const MADE = {
|
|
45
|
+
lay: { ticks: 1, over: 0, length: 0.04, pitch: 2200 },
|
|
46
|
+
draw: { ticks: 2, over: 0.04, length: 0.035, pitch: 1800 },
|
|
47
|
+
knock: { ticks: 1, over: 0, length: 0.05, pitch: 1100 },
|
|
48
|
+
shuffle: { ticks: 18, over: 0.9, length: 0.03, pitch: 2000 },
|
|
49
|
+
};
|
|
50
|
+
/** A table's tile sounds. Nothing is fetched and no audio context is made until the first sound. */
|
|
51
|
+
export function createTileSounds(options = {}) {
|
|
52
|
+
const win = options.window === null ? undefined : (options.window ?? (typeof window === "undefined" ? undefined : window));
|
|
53
|
+
const load = options.load ?? (() => import("./sounds.js"));
|
|
54
|
+
let muted = options.muted === true;
|
|
55
|
+
let volume = clampVolume(options.volume ?? 0.6);
|
|
56
|
+
let ctx = null;
|
|
57
|
+
let out = null;
|
|
58
|
+
let loaded = null;
|
|
59
|
+
let closed = false;
|
|
60
|
+
function context() {
|
|
61
|
+
if (ctx !== null)
|
|
62
|
+
return ctx;
|
|
63
|
+
const Context = win?.AudioContext ?? win?.webkitAudioContext;
|
|
64
|
+
if (Context === undefined)
|
|
65
|
+
return null;
|
|
66
|
+
ctx = new Context();
|
|
67
|
+
out = ctx.createGain();
|
|
68
|
+
out.gain.value = volume;
|
|
69
|
+
out.connect(ctx.destination);
|
|
70
|
+
return ctx;
|
|
71
|
+
}
|
|
72
|
+
function recordings(audio) {
|
|
73
|
+
loaded ?? (loaded = (async () => {
|
|
74
|
+
try {
|
|
75
|
+
const atob = win?.atob ?? globalThis.atob;
|
|
76
|
+
const { TILE_SOUND_DATA } = await load();
|
|
77
|
+
const names = Object.keys(TILE_SOUND_DATA).sort();
|
|
78
|
+
const buffers = await Promise.all(names.map((name) => decode(audio, bytesOf(TILE_SOUND_DATA[name], atob))));
|
|
79
|
+
const made = {};
|
|
80
|
+
names.forEach((name, at) => {
|
|
81
|
+
const kind = name.replace(/-\d+$/, "");
|
|
82
|
+
if (TILE_SOUND_KINDS.includes(kind))
|
|
83
|
+
(made[kind] ?? (made[kind] = [])).push(buffers[at]);
|
|
84
|
+
});
|
|
85
|
+
return Object.keys(made).length > 0 ? made : null;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
})());
|
|
91
|
+
return loaded;
|
|
92
|
+
}
|
|
93
|
+
function recorded(audio, buffer, at, gain) {
|
|
94
|
+
const source = audio.createBufferSource();
|
|
95
|
+
source.buffer = buffer;
|
|
96
|
+
source.playbackRate.value = 0.94 + Math.random() * 0.12;
|
|
97
|
+
const level = audio.createGain();
|
|
98
|
+
level.gain.value = gain;
|
|
99
|
+
source.connect(level);
|
|
100
|
+
level.connect(out);
|
|
101
|
+
source.start(at);
|
|
102
|
+
}
|
|
103
|
+
function madeHere(audio, kind, at, gain) {
|
|
104
|
+
const shape = MADE[kind];
|
|
105
|
+
const length = Math.max(1, Math.round(audio.sampleRate * shape.length));
|
|
106
|
+
for (let tick = 0; tick < shape.ticks; tick++) {
|
|
107
|
+
const buffer = audio.createBuffer(1, length, audio.sampleRate);
|
|
108
|
+
const data = buffer.getChannelData(0);
|
|
109
|
+
for (let i = 0; i < length; i++)
|
|
110
|
+
data[i] = (Math.random() * 2 - 1) * (1 - i / length) ** 3;
|
|
111
|
+
const source = audio.createBufferSource();
|
|
112
|
+
source.buffer = buffer;
|
|
113
|
+
const band = audio.createBiquadFilter();
|
|
114
|
+
band.type = "bandpass";
|
|
115
|
+
band.frequency.value = shape.pitch * (0.9 + Math.random() * 0.2);
|
|
116
|
+
band.Q.value = 1.2;
|
|
117
|
+
const level = audio.createGain();
|
|
118
|
+
level.gain.value = gain * 1.4;
|
|
119
|
+
source.connect(band);
|
|
120
|
+
band.connect(level);
|
|
121
|
+
level.connect(out);
|
|
122
|
+
source.start(at + (shape.ticks === 1 ? 0 : (tick * shape.over) / (shape.ticks - 1)) + Math.random() * 0.006);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function schedule(audio, sounds, kind, times, began) {
|
|
126
|
+
const pick = (list) => list[Math.floor(Math.random() * list.length)];
|
|
127
|
+
const each = 0.9 / Math.sqrt(times.length);
|
|
128
|
+
// The first sound waits for the recordings to be fetched and decoded; the rest keep their spacing from it, so nothing is lost or bunched.
|
|
129
|
+
const late = Math.max(0, audio.currentTime - began);
|
|
130
|
+
for (const ms of times) {
|
|
131
|
+
const at = began + late + ms / 1000;
|
|
132
|
+
const gain = each * (0.8 + Math.random() * 0.4);
|
|
133
|
+
const takes = sounds?.[kind];
|
|
134
|
+
if (takes !== undefined && takes.length > 0)
|
|
135
|
+
recorded(audio, pick(takes), at, gain);
|
|
136
|
+
else
|
|
137
|
+
madeHere(audio, kind, at, gain);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
play(kind, how = {}) {
|
|
142
|
+
if (closed || muted || !TILE_SOUND_KINDS.includes(kind))
|
|
143
|
+
return;
|
|
144
|
+
try {
|
|
145
|
+
const audio = context();
|
|
146
|
+
if (audio === null)
|
|
147
|
+
return;
|
|
148
|
+
const times = soundTimes(how.count ?? 1, how.gap ?? 85).map((ms) => ms + Math.max(0, how.delay ?? 0));
|
|
149
|
+
const go = () => {
|
|
150
|
+
const began = audio.currentTime;
|
|
151
|
+
void recordings(audio).then((sounds) => {
|
|
152
|
+
try {
|
|
153
|
+
if (!closed && !muted)
|
|
154
|
+
schedule(audio, sounds, kind, times, began);
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
// A node that will not start is a tile without its sound, nothing more.
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
};
|
|
161
|
+
// A browser holds a context still until the page has been touched; a sound asked for by code before then stays silent.
|
|
162
|
+
if (audio.state === "running")
|
|
163
|
+
go();
|
|
164
|
+
else
|
|
165
|
+
void audio.resume().then(() => {
|
|
166
|
+
if (audio.state === "running")
|
|
167
|
+
go();
|
|
168
|
+
}, () => undefined);
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
// No audio here, or none allowed: the game goes on.
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
async load() {
|
|
175
|
+
if (closed)
|
|
176
|
+
return false;
|
|
177
|
+
try {
|
|
178
|
+
const audio = context();
|
|
179
|
+
if (audio === null)
|
|
180
|
+
return false;
|
|
181
|
+
return (await recordings(audio)) !== null;
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
get muted() {
|
|
188
|
+
return muted;
|
|
189
|
+
},
|
|
190
|
+
setMuted(next) {
|
|
191
|
+
muted = next === true;
|
|
192
|
+
},
|
|
193
|
+
get volume() {
|
|
194
|
+
return volume;
|
|
195
|
+
},
|
|
196
|
+
set volume(next) {
|
|
197
|
+
volume = clampVolume(next);
|
|
198
|
+
if (out !== null)
|
|
199
|
+
out.gain.value = volume;
|
|
200
|
+
},
|
|
201
|
+
close() {
|
|
202
|
+
closed = true;
|
|
203
|
+
try {
|
|
204
|
+
void ctx?.close().catch(() => undefined);
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
// Already closed.
|
|
208
|
+
}
|
|
209
|
+
ctx = null;
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
}
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The package's version. */
|
|
2
|
-
export declare const VERSION = "1.
|
|
2
|
+
export declare const VERSION = "1.1.1";
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The package's version. */
|
|
2
|
-
export const VERSION = "1.
|
|
2
|
+
export const VERSION = "1.1.1";
|
package/dist/words.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { TrainGame, TrainSeat } from "./mexicanTrain/mexicanTrain.types.ts";
|
|
2
|
+
import { type Language } from "./strings.ts";
|
|
3
|
+
/**
|
|
4
|
+
* A TABLE'S WORDS, in English or Japanese: who is who, what just happened,
|
|
5
|
+
* and what is wanted of whoever is to play. They read a game and say what its
|
|
6
|
+
* rules have already decided, so they can never disagree with the rules; a
|
|
7
|
+
* table draws them under its trains, and the command line prints them.
|
|
8
|
+
*
|
|
9
|
+
* `you` is the seat of the person at this device. Where there is none (a
|
|
10
|
+
* computers-only game, or a command line) it is left out, and every seat is
|
|
11
|
+
* named the same way.
|
|
12
|
+
*/
|
|
13
|
+
/** The set's name by its highest double ("Double-twelve"), or null for a number that is no set offered. */
|
|
14
|
+
export declare function trainSetLabel(set: number, language: Language): string | null;
|
|
15
|
+
/** A seat's name: the one given at the table, or "You" for the person at this device, or "Computer 3" or "Player 3" for the seat's number. */
|
|
16
|
+
export declare function trainSeatName(game: Pick<TrainGame, "players" | "computers">, seat: TrainSeat, language: Language, you?: TrainSeat): string;
|
|
17
|
+
/** A train's name: "Your train", "Computer 2's train", or the Mexican Train. */
|
|
18
|
+
export declare function trainName(game: Pick<TrainGame, "players" | "computers">, train: number, language: Language, you?: TrainSeat): string;
|
|
19
|
+
/** What the last move did, in a line, or "" before there is one: "Computer 2 laid 9–4 on Your train." */
|
|
20
|
+
export declare function trainNews(game: TrainGame, language: Language, you?: TrainSeat): string;
|
|
21
|
+
/**
|
|
22
|
+
* What is going on, in a line: who is to play, or what the person at `you` is
|
|
23
|
+
* asked to do (lay, cover a double, draw, pass), or how the round or the game
|
|
24
|
+
* ended.
|
|
25
|
+
*/
|
|
26
|
+
export declare function trainStatus(game: TrainGame, language: Language, you?: TrainSeat): string;
|
package/dist/words.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { tileWords } from "./mexicanTrain/dominoes.js";
|
|
2
|
+
import { TRAIN_PHASES, mexicanOf, trainMoves, trainTotals } from "./mexicanTrain/mexicanTrain.js";
|
|
3
|
+
import { DOMINO_STRINGS, dominoSay } from "./strings.js";
|
|
4
|
+
/**
|
|
5
|
+
* A TABLE'S WORDS, in English or Japanese: who is who, what just happened,
|
|
6
|
+
* and what is wanted of whoever is to play. They read a game and say what its
|
|
7
|
+
* rules have already decided, so they can never disagree with the rules; a
|
|
8
|
+
* table draws them under its trains, and the command line prints them.
|
|
9
|
+
*
|
|
10
|
+
* `you` is the seat of the person at this device. Where there is none (a
|
|
11
|
+
* computers-only game, or a command line) it is left out, and every seat is
|
|
12
|
+
* named the same way.
|
|
13
|
+
*/
|
|
14
|
+
/** The set's name by its highest double ("Double-twelve"), or null for a number that is no set offered. */
|
|
15
|
+
export function trainSetLabel(set, language) {
|
|
16
|
+
const t = DOMINO_STRINGS[language];
|
|
17
|
+
return { 9: t.set9, 12: t.set12, 15: t.set15 }[set] ?? null;
|
|
18
|
+
}
|
|
19
|
+
/** A seat's name: the one given at the table, or "You" for the person at this device, or "Computer 3" or "Player 3" for the seat's number. */
|
|
20
|
+
export function trainSeatName(game, seat, language, you) {
|
|
21
|
+
const t = DOMINO_STRINGS[language];
|
|
22
|
+
const given = game.players[seat]?.trim() ?? "";
|
|
23
|
+
if (given !== "")
|
|
24
|
+
return given;
|
|
25
|
+
if (seat === you)
|
|
26
|
+
return t.you;
|
|
27
|
+
return dominoSay(game.computers[seat] ? t.computer : t.player, { n: seat + 1 });
|
|
28
|
+
}
|
|
29
|
+
/** A train's name: "Your train", "Computer 2's train", or the Mexican Train. */
|
|
30
|
+
export function trainName(game, train, language, you) {
|
|
31
|
+
const t = DOMINO_STRINGS[language];
|
|
32
|
+
if (train === mexicanOf(game))
|
|
33
|
+
return t.mexican;
|
|
34
|
+
if (train === you)
|
|
35
|
+
return t.trainYours;
|
|
36
|
+
return dominoSay(t.trainOf, { who: trainSeatName(game, train, language, you) });
|
|
37
|
+
}
|
|
38
|
+
/** What the last move did, in a line, or "" before there is one: "Computer 2 laid 9–4 on Your train." */
|
|
39
|
+
export function trainNews(game, language, you) {
|
|
40
|
+
const t = DOMINO_STRINGS[language];
|
|
41
|
+
const last = game.last;
|
|
42
|
+
if (last === null)
|
|
43
|
+
return "";
|
|
44
|
+
const who = trainSeatName(game, last.seat, language, you);
|
|
45
|
+
switch (last.move.kind) {
|
|
46
|
+
case "play":
|
|
47
|
+
return dominoSay(t.laid, { who, tile: tileWords(last.move.tile), train: trainName(game, last.move.train, language, you) });
|
|
48
|
+
case "draw":
|
|
49
|
+
return dominoSay(t.drew, { who });
|
|
50
|
+
case "pass":
|
|
51
|
+
return dominoSay(t.passed, { who });
|
|
52
|
+
case "next":
|
|
53
|
+
return dominoSay(t.dealt, { n: game.round + 1, engine: game.engine });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** Why a round ended, in a line: who played out, or that nobody could play. */
|
|
57
|
+
function why(game, language, you) {
|
|
58
|
+
const t = DOMINO_STRINGS[language];
|
|
59
|
+
const result = game.results[game.results.length - 1];
|
|
60
|
+
return result !== undefined && result.ending === "domino" && result.out !== null ? dominoSay(t.outBy, { who: trainSeatName(game, result.out, language, you) }) : t.blocked;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* What is going on, in a line: who is to play, or what the person at `you` is
|
|
64
|
+
* asked to do (lay, cover a double, draw, pass), or how the round or the game
|
|
65
|
+
* ended.
|
|
66
|
+
*/
|
|
67
|
+
export function trainStatus(game, language, you) {
|
|
68
|
+
const t = DOMINO_STRINGS[language];
|
|
69
|
+
if (game.phase === TRAIN_PHASES.finished) {
|
|
70
|
+
const who = game.winners.map((seat) => trainSeatName(game, seat, language, you)).join(" & ");
|
|
71
|
+
return dominoSay(t.gameOver, { who, pips: Math.min(...trainTotals(game)) });
|
|
72
|
+
}
|
|
73
|
+
if (game.phase === TRAIN_PHASES.roundOver)
|
|
74
|
+
return dominoSay(t.roundOver, { why: why(game, language, you) });
|
|
75
|
+
if (you === undefined || game.toPlay !== you)
|
|
76
|
+
return dominoSay(t.thinking, { who: trainSeatName(game, game.toPlay, language, you) });
|
|
77
|
+
const first = trainMoves(game)[0];
|
|
78
|
+
if (first?.kind === "draw")
|
|
79
|
+
return t.yourTurnDraw;
|
|
80
|
+
if (first?.kind === "pass")
|
|
81
|
+
return t.yourTurnPass;
|
|
82
|
+
return game.uncovered.length > 0 ? t.yourTurnCover : t.yourTurn;
|
|
83
|
+
}
|
package/docs/credits.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Credits: where the sounds come from
|
|
2
|
+
|
|
3
|
+
Everything in Domino was written for it, under its MIT licence, except what
|
|
4
|
+
this page names. Each item says where it came from, its licence as read at
|
|
5
|
+
its source, and the day that was checked. Nothing here is GPL or LGPL, and
|
|
6
|
+
nothing is used whose licence has not been read.
|
|
7
|
+
|
|
8
|
+
## The tile sounds
|
|
9
|
+
|
|
10
|
+
**Casino Audio (1.1)** by Kenney Vleugels,
|
|
11
|
+
[https://kenney.nl/assets/casino-audio](https://kenney.nl/assets/casino-audio).
|
|
12
|
+
|
|
13
|
+
- **Licence:** Creative Commons Zero, CC0 1.0
|
|
14
|
+
([creativecommons.org/publicdomain/zero/1.0](http://creativecommons.org/publicdomain/zero/1.0/)),
|
|
15
|
+
as the pack's page says and as its own `License.txt` states: "You may use
|
|
16
|
+
these assets in personal and commercial projects. Credit (Kenney or
|
|
17
|
+
www.kenney.nl) would be nice but is not mandatory."
|
|
18
|
+
- **Checked:** 2026-10-01, on the pack's page and in `License.txt` inside
|
|
19
|
+
`kenney_casino-audio.zip` (SHA-256
|
|
20
|
+
`f36250766ac5bc378c13708ddf12a23a8e54a3251f8d482c7536e51b5dbafa18`).
|
|
21
|
+
|
|
22
|
+
**What they are, said plainly.** The pack has no dominoes. These are
|
|
23
|
+
recordings of poker chips, which are hard and click on a table the way a
|
|
24
|
+
tile does, and they are the nearest sound that is free to use. Nine of the
|
|
25
|
+
pack's recordings are used, each cut short and re-encoded:
|
|
26
|
+
|
|
27
|
+
| File here | From the pack | What it is | Length | Size |
|
|
28
|
+
| --- | --- | --- | --- | --- |
|
|
29
|
+
| `sounds/lay-1.m4a` | `Audio/chip-lay-1.ogg` | a chip set down on a table: a tile laid | 0.09 s | 1,681 bytes |
|
|
30
|
+
| `sounds/lay-2.m4a` | `Audio/chip-lay-2.ogg` | a chip set down on a table: a tile laid | 0.15 s | 1,901 bytes |
|
|
31
|
+
| `sounds/lay-3.m4a` | `Audio/chip-lay-3.ogg` | a chip set down on a table: a tile laid | 0.15 s | 1,947 bytes |
|
|
32
|
+
| `sounds/draw-1.m4a` | `Audio/chips-handle-3.ogg` | a chip picked up: a tile taken from the boneyard | 0.19 s | 2,293 bytes |
|
|
33
|
+
| `sounds/draw-2.m4a` | `Audio/chips-handle-4.ogg` | a chip picked up: a tile taken from the boneyard | 0.30 s | 3,174 bytes |
|
|
34
|
+
| `sounds/shuffle-1.m4a` | `Audio/chips-handle-5.ogg` | chips stirred about: the tiles shuffled | 0.81 s | 6,058 bytes |
|
|
35
|
+
| `sounds/shuffle-2.m4a` | `Audio/chips-handle-6.ogg` | chips stirred about: the tiles shuffled | 0.33 s | 3,644 bytes |
|
|
36
|
+
| `sounds/knock-1.m4a` | `Audio/chips-collide-1.ogg` | two chips knocked together: a rap on the table for a pass | 0.08 s | 1,557 bytes |
|
|
37
|
+
| `sounds/knock-2.m4a` | `Audio/chips-collide-4.ogg` | two chips knocked together: a rap on the table for a pass | 0.06 s | 1,520 bytes |
|
|
38
|
+
|
|
39
|
+
23,775 bytes in all.
|
|
40
|
+
|
|
41
|
+
**What was done to them**, by `scripts/sounds-cut.mjs` on a Mac: each
|
|
42
|
+
recording was decoded from Ogg Vorbis, mixed to one channel, cut from where
|
|
43
|
+
it first reaches a tenth of its peak to where it falls quiet (or to a length
|
|
44
|
+
chosen for it), faded at both ends, brought to the same peak level, and
|
|
45
|
+
encoded as AAC at 48 kbit/s in an `.m4a`, which every current browser
|
|
46
|
+
decodes, Safari on an iPhone included. The empty padding the encoder leaves
|
|
47
|
+
in the file was taken out. Then `pnpm sounds` writes them into
|
|
48
|
+
`src/sounds.ts` as base64, and a test fails if that module and the files fall
|
|
49
|
+
out of step, or if a file is not named on this page.
|