dou-cl 1.0.1 → 1.0.2
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/PUBLISH.md +3 -3
- package/README.md +7 -4
- package/bin/dou-cl.js +50 -30
- package/package.json +1 -1
- package/src/app.js +30 -43
- package/src/net/client.js +268 -130
- package/src/net/server.js +278 -309
- package/src/ui.js +3 -3
package/src/net/client.js
CHANGED
|
@@ -1,13 +1,26 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
/*
|
|
4
|
+
* 联网客户端 —— 连上一个地址就是进入那个房间。
|
|
5
|
+
*
|
|
6
|
+
* npx dou-cl --join 192.168.1.10:8080
|
|
7
|
+
*
|
|
8
|
+
* 规则:**只有「出牌」需要服务器**。
|
|
9
|
+
* 换皮肤 / reveal / redraw / 帮助 / 退出这些都是纯本地的,任何时刻都能用 ——
|
|
10
|
+
* 哪怕是别人的回合、你正在等他们出牌。
|
|
11
|
+
*/
|
|
12
|
+
|
|
3
13
|
const WebSocket = require('ws');
|
|
4
14
|
const ui = require('../ui');
|
|
5
15
|
const bot = require('../bot');
|
|
6
|
-
const gamesMod = require('../games');
|
|
7
16
|
const rulesMod = require('../core/rules');
|
|
17
|
+
const comboMod = require('../core/combo');
|
|
18
|
+
const skinsMod = require('../skins');
|
|
8
19
|
|
|
9
20
|
const colors = ui.colors;
|
|
10
21
|
|
|
22
|
+
/* ------------------------------------------------------------------ */
|
|
23
|
+
|
|
11
24
|
function hintIds(view, seat) {
|
|
12
25
|
const act = bot.hintFromView(view, seat);
|
|
13
26
|
if (act && act.type === 'play' && act.ids && act.ids.length) return act.ids;
|
|
@@ -18,245 +31,370 @@ function asciiName(s, fallback) {
|
|
|
18
31
|
return /^[\x20-\x7e]*$/.test(s || '') ? s : fallback;
|
|
19
32
|
}
|
|
20
33
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
34
|
+
function toUrl(s) {
|
|
35
|
+
let v = String(s || '').trim();
|
|
36
|
+
if (!v) v = '127.0.0.1:8080';
|
|
37
|
+
if (v.indexOf('ws://') === 0 || v.indexOf('wss://') === 0) return v;
|
|
38
|
+
if (v.indexOf(':') < 0) v += ':8080';
|
|
39
|
+
return 'ws://' + v;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/* ------------------------------------------------------------------ */
|
|
25
43
|
|
|
44
|
+
async function runClient(io, opts) {
|
|
45
|
+
const url = toUrl(opts.server);
|
|
46
|
+
const addr = url.replace(/^wss?:\/\//, '');
|
|
26
47
|
const skin0 = ui.skinActive();
|
|
27
|
-
|
|
28
|
-
|
|
48
|
+
|
|
49
|
+
if (skin0) io.block(ui.skinMessage('connect svc=deck-sync addr=' + url));
|
|
50
|
+
else io.say(colors.dim('连接中 ' + addr + ' …'));
|
|
29
51
|
|
|
30
52
|
const ws = new WebSocket(url);
|
|
31
53
|
let mySeat = -1;
|
|
32
54
|
let lastView = null;
|
|
55
|
+
let roomInfo = null;
|
|
56
|
+
let gameKey = opts.game || 'doudizhu';
|
|
57
|
+
let leaveSent = false;
|
|
58
|
+
let sigHandler = null;
|
|
33
59
|
|
|
34
60
|
function sendMsg(o) {
|
|
35
61
|
if (ws.readyState === 1) ws.send(JSON.stringify(o));
|
|
36
62
|
}
|
|
37
63
|
|
|
38
|
-
|
|
39
|
-
|
|
64
|
+
/** 摸鱼模式下只输出 ASCII —— 有 en 就用 en,避免漏出中文 */
|
|
65
|
+
function pick(msg, en) {
|
|
66
|
+
return (ui.skinActive() && en) ? en : msg;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function notify(msg, en) {
|
|
70
|
+
const body = pick(msg, en);
|
|
71
|
+
const m = ui.skinMessage(body);
|
|
40
72
|
if (m !== null) io.block(m);
|
|
41
|
-
else io.say(colors.dim('· ' +
|
|
73
|
+
else io.say(colors.dim('· ' + body));
|
|
42
74
|
}
|
|
43
75
|
|
|
44
|
-
function complain(msg) {
|
|
45
|
-
|
|
46
|
-
|
|
76
|
+
function complain(msg, en) {
|
|
77
|
+
const body = pick(msg, en);
|
|
78
|
+
if (ui.skinActive()) io.block(ui.skinMessage('! ' + body));
|
|
79
|
+
else io.say(colors.red(body));
|
|
47
80
|
}
|
|
48
81
|
|
|
49
|
-
function
|
|
82
|
+
function quit() {
|
|
83
|
+
if (leaveSent) return;
|
|
84
|
+
leaveSent = true;
|
|
85
|
+
sendMsg({ t: 'leave' });
|
|
86
|
+
try { ws.close(); } catch (e) { /* ignore */ }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isQuit(line) {
|
|
90
|
+
const low = (line || '').trim().toLowerCase();
|
|
91
|
+
return low === 'q' || low === 'quit' || low === 'exit' || low === 'leave';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* ---- Ctrl+C:先说一声再走(别人 / 服务器立刻知道) ---- */
|
|
95
|
+
|
|
96
|
+
sigHandler = function () {
|
|
97
|
+
if (leaveSent) { process.exit(0); return; }
|
|
98
|
+
leaveSent = true;
|
|
99
|
+
sendMsg({ t: 'leave' });
|
|
100
|
+
// 给 socket 一点时间把这句话送出去,再退出
|
|
101
|
+
setTimeout(function () { process.exit(0); }, 150);
|
|
102
|
+
};
|
|
103
|
+
process.on('SIGINT', sigHandler);
|
|
104
|
+
|
|
105
|
+
function dropSigint() {
|
|
106
|
+
if (sigHandler) { process.removeListener('SIGINT', sigHandler); sigHandler = null; }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/* ---- 帮助 ---- */
|
|
110
|
+
|
|
111
|
+
function printHelp(key) {
|
|
112
|
+
const r = rulesMod.get(key || gameKey);
|
|
113
|
+
const lines = [r.name + ' ' + r.tagline].concat(r.help || []);
|
|
114
|
+
lines.push('');
|
|
115
|
+
lines.push('出牌:34567 或 3 4 5 6 7 ;带花色 5s 5h 5c');
|
|
116
|
+
lines.push('p=不要 h=提示 ?=帮助 q=离开');
|
|
50
117
|
if (ui.skinActive()) {
|
|
51
|
-
|
|
52
|
-
return;
|
|
118
|
+
const s = ui.skinHelp(lines);
|
|
119
|
+
if (s !== null) { process.stdout.write(s); return; }
|
|
53
120
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
io.ask(prompt, function (line) { handleInput(line, view); });
|
|
121
|
+
io.say('');
|
|
122
|
+
lines.forEach(function (l) { io.say(' ' + l); });
|
|
123
|
+
io.say('');
|
|
58
124
|
}
|
|
59
125
|
|
|
60
|
-
|
|
61
|
-
|
|
126
|
+
/* ---- 纯本地命令:任何时刻都该能用 ---- */
|
|
127
|
+
|
|
128
|
+
/** @returns true 表示这条输入已经被本地处理掉,不要当作出牌 */
|
|
129
|
+
function localCommand(line, view) {
|
|
130
|
+
const low = (line || '').trim().toLowerCase();
|
|
62
131
|
const skin = ui.skinActive();
|
|
63
|
-
if (!line) { promptTurn(view); return; }
|
|
64
132
|
|
|
65
133
|
if (skin) {
|
|
66
134
|
if (low === 'reveal' || low === 'real') {
|
|
67
|
-
process.stdout.write(ui.renderTable(view, mySeat, {}) + '\n');
|
|
68
|
-
|
|
69
|
-
return;
|
|
135
|
+
process.stdout.write(ui.renderTable(view || lastView, mySeat, {}) + '\n');
|
|
136
|
+
return true;
|
|
70
137
|
}
|
|
71
138
|
if (low === 'redraw' || low === 'rr') {
|
|
72
|
-
io.block(ui.renderSkin(view, mySeat, {}));
|
|
73
|
-
|
|
74
|
-
return;
|
|
139
|
+
if (view || lastView) io.block(ui.renderSkin(view || lastView, mySeat, {}));
|
|
140
|
+
return true;
|
|
75
141
|
}
|
|
76
142
|
if (low.indexOf('skin') === 0) {
|
|
77
143
|
const k = low.split(/\s+/)[1];
|
|
78
144
|
if (!k) {
|
|
79
|
-
ui.skinList().
|
|
145
|
+
notify('skins: ' + ui.skinList().map(function (s) { return s.key; }).join(' '),
|
|
146
|
+
'skins: ' + ui.skinList().map(function (s) { return s.key; }).join(' '));
|
|
80
147
|
} else {
|
|
81
|
-
try { ui.setSkin(k); notify('skin -> ' + ui.skinKey()); }
|
|
82
|
-
catch (e) { notify(
|
|
148
|
+
try { ui.setSkin(k); notify('skin -> ' + ui.skinKey(), 'skin -> ' + ui.skinKey()); }
|
|
149
|
+
catch (e) { notify('no such skin: ' + k, 'no such skin: ' + k); }
|
|
83
150
|
}
|
|
84
|
-
|
|
85
|
-
return;
|
|
151
|
+
return true;
|
|
86
152
|
}
|
|
87
153
|
}
|
|
154
|
+
if (low === 'help' || low === '?' || low === 'rules') { printHelp(view && view.key); return true; }
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/* ---- 等待界面 ---- */
|
|
159
|
+
|
|
160
|
+
function askWaiting() {
|
|
161
|
+
const skin = ui.skinActive();
|
|
162
|
+
const more = !roomInfo || roomInfo.humans < roomInfo.seats;
|
|
163
|
+
const hint = more ? '[s] 用电脑补齐先开局 [q] 退出' : '[q] 退出';
|
|
164
|
+
io.ask(skin ? ui.skinPrompt() : '\n' + colors.dim(hint) + ' > ', function (line) {
|
|
165
|
+
const low = (line || '').trim().toLowerCase();
|
|
166
|
+
if (isQuit(low)) { quit(); return; }
|
|
167
|
+
if (low === 's' || low === 'start') { sendMsg({ t: 'start' }); return; }
|
|
168
|
+
if (low === '?' || low === 'help') { printHelp(roomInfo && roomInfo.game); }
|
|
169
|
+
askWaiting();
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function renderLobby(room) {
|
|
174
|
+
roomInfo = room;
|
|
175
|
+
gameKey = room.game;
|
|
176
|
+
const skin = ui.skinActive();
|
|
88
177
|
|
|
89
|
-
if (
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
178
|
+
if (skin) {
|
|
179
|
+
notify('room=' + addr + ' svc=deck-sync game=' + room.game +
|
|
180
|
+
' seats=' + room.seats + ' ready=' + room.humans + '/' + room.seats);
|
|
181
|
+
const nodes = [];
|
|
182
|
+
for (let i = 0; i < room.seats; i++) {
|
|
183
|
+
const p = room.players[i];
|
|
184
|
+
if (!p) nodes.push('slot' + i + '=empty');
|
|
185
|
+
else if (p.bot) nodes.push('slot' + i + '=worker-' + i);
|
|
186
|
+
else if (i === mySeat) nodes.push('slot' + i + '=self');
|
|
187
|
+
else nodes.push('slot' + i + '=' + asciiName(p.name, 'peer' + i));
|
|
188
|
+
}
|
|
189
|
+
notify('nodes=[' + nodes.join(' ') + ']');
|
|
190
|
+
if (room.humans < room.seats) notify('waiting for peers ... (s=fill workers, q=leave)');
|
|
191
|
+
else notify('lobby ready, starting ...');
|
|
192
|
+
askWaiting();
|
|
94
193
|
return;
|
|
95
194
|
}
|
|
195
|
+
|
|
196
|
+
io.clear();
|
|
197
|
+
io.say('');
|
|
198
|
+
io.say(colors.bold(' 房间 ' + addr) + ' ' + colors.dim(room.gameName + ' · ' + room.seats + ' 人'));
|
|
199
|
+
io.say(' ' + '\u2500'.repeat(52));
|
|
200
|
+
for (let i = 0; i < room.seats; i++) {
|
|
201
|
+
const p = room.players[i];
|
|
202
|
+
let who;
|
|
203
|
+
if (!p) who = colors.dim('空 · 等待加入');
|
|
204
|
+
else if (p.bot) who = colors.dim(p.name + '(电脑)');
|
|
205
|
+
else if (i === mySeat) who = colors.green(p.name + '(你)');
|
|
206
|
+
else who = p.name;
|
|
207
|
+
io.say(' ' + (i + 1) + ' 号位 ' + who);
|
|
208
|
+
}
|
|
209
|
+
io.say(' ' + '\u2500'.repeat(52));
|
|
210
|
+
if (room.humans < room.seats) {
|
|
211
|
+
io.say(colors.dim(' 等待玩家 ' + room.humans + '/' + room.seats +
|
|
212
|
+
' 朋友加入: npx dou-cl --join ' + addr));
|
|
213
|
+
} else {
|
|
214
|
+
io.say(colors.dim(' 人齐了,马上开始…'));
|
|
215
|
+
}
|
|
216
|
+
askWaiting();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/* ---- 牌局 ---- */
|
|
220
|
+
|
|
221
|
+
function promptTurn(view) {
|
|
222
|
+
if (ui.skinActive()) {
|
|
223
|
+
io.ask(ui.skinPrompt(), function (line) { onTurnInput(line, view); });
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const prompt = view.phase === 'bid'
|
|
227
|
+
? '\n叫分 (0=不叫 1/2/3=叫分, ?=帮助) > '
|
|
228
|
+
: '\n出牌 (例: 34567 或 5 5 5 6 / p 不要 / h 提示 / ? 帮助) > ';
|
|
229
|
+
io.ask(prompt, function (line) { onTurnInput(line, view); });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** 轮到我:本地命令优先,其余当作出牌 */
|
|
233
|
+
function onTurnInput(line, view) {
|
|
234
|
+
if (!line) { promptTurn(view); return; }
|
|
235
|
+
if (isQuit(line)) { quit(); return; }
|
|
236
|
+
if (localCommand(line, view)) { promptTurn(view); return; }
|
|
237
|
+
playInput(line, view);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** 不是我的回合:本地命令照样能用,想打牌就告诉他还没轮到你 */
|
|
241
|
+
function askIdle(view) {
|
|
242
|
+
const prompt = ui.skinActive() ? ui.skinPrompt()
|
|
243
|
+
: '\n' + colors.dim('[q] 退出 本地命令随时可用: reveal / redraw / skin <名> / ?') + ' > ';
|
|
244
|
+
io.ask(prompt, function (line) {
|
|
245
|
+
if (isQuit(line)) { quit(); return; }
|
|
246
|
+
if (localCommand(line, view)) { askIdle(view); return; }
|
|
247
|
+
if ((line || '').trim()) {
|
|
248
|
+
const hint = ui.skinActive()
|
|
249
|
+
? 'not your turn yet (local: reveal / redraw / skin <name> / help)'
|
|
250
|
+
: '还没轮到你。本地命令随时可用: reveal / redraw / skin <名> / ?';
|
|
251
|
+
notify(hint, hint);
|
|
252
|
+
}
|
|
253
|
+
askIdle(view);
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function playInput(line, view) {
|
|
258
|
+
const low = line.toLowerCase();
|
|
259
|
+
const skin = ui.skinActive();
|
|
260
|
+
|
|
96
261
|
if (view.phase === 'bid') {
|
|
97
262
|
const v = parseInt(line, 10);
|
|
98
|
-
if (isNaN(v) || v < 0 || v > 3) {
|
|
263
|
+
if (isNaN(v) || v < 0 || v > 3) {
|
|
264
|
+
complain('叫分只能是 0/1/2/3', 'bid must be 0 / 1 / 2 / 3');
|
|
265
|
+
promptTurn(view);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
99
268
|
sendMsg({ t: 'act', action: { type: 'bid', score: v } });
|
|
100
269
|
return;
|
|
101
270
|
}
|
|
102
|
-
if (low === 'p' || low === 'pass' || line === '不要' || line === '过') {
|
|
103
|
-
if (!view.canPass) {
|
|
271
|
+
if (low === 'p' || low === 'pass' || line === '不要' || line === '过' || line === '不出') {
|
|
272
|
+
if (!view.canPass) {
|
|
273
|
+
complain('你是先手,必须出牌', 'you lead this round, must play');
|
|
274
|
+
promptTurn(view);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
104
277
|
sendMsg({ t: 'act', action: { type: 'pass' } });
|
|
105
278
|
return;
|
|
106
279
|
}
|
|
107
280
|
if (low === 'h' || low === 'hint' || line === '提示') {
|
|
108
281
|
const ids = hintIds(view, mySeat);
|
|
109
282
|
if (!ids || !ids.length) {
|
|
110
|
-
complain('没有能管上的牌,只能不要(pass)');
|
|
283
|
+
complain('没有能管上的牌,只能不要(pass)', 'nothing beats it, must pass');
|
|
111
284
|
} else {
|
|
112
285
|
const cards = view.hand.filter(function (c) { return ids.indexOf(c.id) >= 0; });
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
286
|
+
const co = comboMod.analyze(cards, rulesMod.get(view.key));
|
|
287
|
+
if (skin) {
|
|
288
|
+
const txt = 'hint ' + cards.map(function (c) { return skinsMod.packet.cardCode(c); }).join(' ') +
|
|
289
|
+
' [' + (co ? co.type : '') + ']';
|
|
290
|
+
io.block(ui.skinMessage(txt));
|
|
291
|
+
} else {
|
|
292
|
+
io.say('hint: ' + ui.handText(cards) + ' [' + (co ? comboMod.typeName(co.type) : '') + ']');
|
|
293
|
+
}
|
|
117
294
|
}
|
|
118
295
|
promptTurn(view);
|
|
119
296
|
return;
|
|
120
297
|
}
|
|
121
298
|
|
|
122
299
|
const parsed = ui.parseCardInput(line, view.hand);
|
|
123
|
-
if (!parsed.ok) { complain(parsed.err); promptTurn(view); return; }
|
|
300
|
+
if (!parsed.ok) { complain(parsed.err, parsed.en); promptTurn(view); return; }
|
|
124
301
|
sendMsg({ t: 'act', action: { type: 'play', ids: parsed.ids } });
|
|
125
302
|
}
|
|
126
303
|
|
|
127
|
-
function printHelp(key) {
|
|
128
|
-
const r = rulesMod.get(key);
|
|
129
|
-
const lines = [r.name + ' ' + r.tagline].concat(r.help);
|
|
130
|
-
lines.push('');
|
|
131
|
-
lines.push('出牌:34567 或 3 4 5 6 7 ;带花色 5s 5h 5c');
|
|
132
|
-
lines.push('p=不要 h=提示 ?=帮助 quit=离开');
|
|
133
|
-
if (ui.skinActive()) {
|
|
134
|
-
const s = ui.skinHelp(lines);
|
|
135
|
-
if (s !== null) { process.stdout.write(s); return; }
|
|
136
|
-
}
|
|
137
|
-
io.say('');
|
|
138
|
-
lines.forEach(function (l) { io.say(' ' + l); });
|
|
139
|
-
io.say('');
|
|
140
|
-
}
|
|
141
|
-
|
|
142
304
|
function renderState(view) {
|
|
143
305
|
lastView = view;
|
|
144
306
|
const skin = ui.skinActive();
|
|
145
307
|
const skinned = ui.renderSkin(view, mySeat, {});
|
|
146
|
-
if (skinned !== null)
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
308
|
+
if (skinned !== null) io.block(skinned);
|
|
309
|
+
else { io.clear(); process.stdout.write(ui.renderTable(view, mySeat, {}) + '\n'); }
|
|
310
|
+
|
|
311
|
+
if (view.over) {
|
|
312
|
+
const txt = (view.result && view.result.text) || '';
|
|
313
|
+
if (skin) notify('round done next round soon (q=leave)', 'round done next round soon (q=leave)');
|
|
314
|
+
else io.say(colors.dim(' 本局结束,马上开始下一局…(q 退出)'));
|
|
315
|
+
askIdle(view);
|
|
316
|
+
return;
|
|
151
317
|
}
|
|
152
|
-
if (view.over) { askAgain(); return; }
|
|
153
318
|
if (view.turn === mySeat && view.phase !== 'over') {
|
|
154
319
|
promptTurn(view);
|
|
155
320
|
} else {
|
|
156
|
-
io.handler = null;
|
|
157
321
|
const who = view.seats[view.turn] ? view.seats[view.turn].name : '?';
|
|
158
|
-
if (skin)
|
|
322
|
+
if (skin) notify('waiting for ' + asciiName(who, 'peer') + ' ...',
|
|
323
|
+
'waiting for ' + asciiName(who, 'peer') + ' ...');
|
|
159
324
|
else process.stdout.write(colors.dim(' 等待 ' + who + (view.phase === 'bid' ? ' 叫分…' : ' 出牌…')) + '\n');
|
|
325
|
+
askIdle(view);
|
|
160
326
|
}
|
|
161
327
|
}
|
|
162
328
|
|
|
163
|
-
|
|
164
|
-
io.ask(ui.skinActive() ? ui.skinPrompt() : '\n再来一局?(y/n) > ', function (line) {
|
|
165
|
-
sendMsg({ t: 'again', yes: /^y/i.test(line.trim()) });
|
|
166
|
-
});
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
function renderLobby(room) {
|
|
170
|
-
if (ui.skinActive()) {
|
|
171
|
-
notify('pool=' + room.code + ' svc=deck-sync game=' + room.game + ' seats=' + room.seats);
|
|
172
|
-
const nodes = [];
|
|
173
|
-
for (let i = 0; i < room.seats; i++) {
|
|
174
|
-
const p = room.players[i];
|
|
175
|
-
if (!p) nodes.push('slot' + i + '=empty');
|
|
176
|
-
else if (p.bot) nodes.push('slot' + i + '=worker-' + i);
|
|
177
|
-
else nodes.push('slot' + i + '=' + asciiName(p.name, i === room.hostSeat ? 'self' : ('peer' + i)));
|
|
178
|
-
}
|
|
179
|
-
notify('nodes=[' + nodes.join(' ') + ']');
|
|
180
|
-
return;
|
|
181
|
-
}
|
|
182
|
-
io.clear();
|
|
183
|
-
io.say('');
|
|
184
|
-
io.say(colors.bold(' 房间 ' + room.code) + ' ' + colors.dim(room.gameName + ' · ' + room.seats + ' 人'));
|
|
185
|
-
io.say(' ' + '\u2500'.repeat(50));
|
|
186
|
-
for (let i = 0; i < room.seats; i++) {
|
|
187
|
-
const p = room.players[i];
|
|
188
|
-
io.say(' ' + (i + 1) + '号位 ' + (p ? (p.bot ? colors.dim(p.name + '(电脑)') : p.name) : colors.dim('空')));
|
|
189
|
-
}
|
|
190
|
-
io.say(' ' + '\u2500'.repeat(50));
|
|
191
|
-
io.say(colors.dim(' 把房间号发给朋友,或让他们执行: npx dou-cl --join <地址> --code ' + room.code));
|
|
192
|
-
}
|
|
329
|
+
/* ---- 连接 ---- */
|
|
193
330
|
|
|
194
331
|
ws.on('open', function () {
|
|
195
|
-
if (!ui.skinActive()) { io.clear(); io.say(colors.green('
|
|
196
|
-
|
|
197
|
-
else if (opts.mode === 'join') sendMsg({ t: 'join', code: opts.code, name: opts.name });
|
|
198
|
-
else sendMsg({ t: 'create', game: opts.game, name: opts.name, public: false });
|
|
332
|
+
if (!ui.skinActive()) { io.clear(); io.say(colors.green('已连上 ' + addr + ',正在进入房间…')); }
|
|
333
|
+
sendMsg({ t: 'join', name: opts.name });
|
|
199
334
|
});
|
|
200
335
|
|
|
201
336
|
ws.on('message', function (raw) {
|
|
202
337
|
let m;
|
|
203
338
|
try { m = JSON.parse(String(raw)); } catch (e) { return; }
|
|
204
339
|
|
|
205
|
-
if (m.t === 'hello') {
|
|
340
|
+
if (m.t === 'hello') { gameKey = m.game; return; }
|
|
206
341
|
|
|
207
|
-
if (m.t === '
|
|
342
|
+
if (m.t === 'seated') {
|
|
208
343
|
mySeat = m.seat;
|
|
209
|
-
notify('
|
|
344
|
+
if (ui.skinActive()) notify('seated room=' + addr + ' slot=' + m.seat + ' game=' + gameKey);
|
|
210
345
|
return;
|
|
211
346
|
}
|
|
212
347
|
|
|
213
|
-
if (m.t === '
|
|
214
|
-
if (lastView &&
|
|
348
|
+
if (m.t === 'lobby') {
|
|
349
|
+
if (lastView && lastView.over) lastView = null;
|
|
350
|
+
if (m.room.started) return; // 已经开局了,等 state
|
|
215
351
|
renderLobby(m.room);
|
|
216
|
-
const isHost = mySeat >= 0 && m.room.hostSeat === mySeat;
|
|
217
|
-
if (isHost) {
|
|
218
|
-
io.ask(ui.skinActive() ? ui.skinPrompt()
|
|
219
|
-
: '\n' + colors.bold('按 Enter 开始游戏(不足的位置由电脑补上)> '),
|
|
220
|
-
function (line) {
|
|
221
|
-
const low = line.trim().toLowerCase();
|
|
222
|
-
if (low === 'q' || low === 'quit') { sendMsg({ t: 'leave' }); ws.close(); return; }
|
|
223
|
-
sendMsg({ t: 'start' });
|
|
224
|
-
});
|
|
225
|
-
} else {
|
|
226
|
-
io.handler = null;
|
|
227
|
-
if (ui.skinActive()) io.block(ui.skinMessage('waiting for host ...'));
|
|
228
|
-
else io.say(colors.dim(' 等房主开始…(输入 quit 离开)'));
|
|
229
|
-
}
|
|
230
352
|
return;
|
|
231
353
|
}
|
|
232
354
|
|
|
233
355
|
if (m.t === 'state') { renderState(m.view); return; }
|
|
234
|
-
if (m.t === 'notice') { notify(m.msg); return; }
|
|
356
|
+
if (m.t === 'notice') { notify(m.msg, m.en); return; }
|
|
357
|
+
|
|
235
358
|
if (m.t === 'chat') {
|
|
236
359
|
if (ui.skinActive()) notify(m.who + ': ' + m.msg);
|
|
237
360
|
else io.say(colors.cyan('[' + m.who + '] ') + m.msg);
|
|
238
361
|
return;
|
|
239
362
|
}
|
|
363
|
+
|
|
364
|
+
if (m.t === 'full') {
|
|
365
|
+
complain(m.msg, m.en);
|
|
366
|
+
if (!ui.skinActive()) {
|
|
367
|
+
io.say(colors.dim(' 换一个端口,或稍后再来(房间空了就能进)。'));
|
|
368
|
+
}
|
|
369
|
+
setTimeout(function () { try { ws.close(); } catch (e) { /* ignore */ } }, 150);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
|
|
240
373
|
if (m.t === 'error') {
|
|
241
|
-
complain(m.msg
|
|
242
|
-
if (mySeat < 0) ws.close();
|
|
243
|
-
else if (lastView) promptTurn(lastView);
|
|
374
|
+
complain(m.msg, m.en);
|
|
375
|
+
if (mySeat < 0) setTimeout(function () { try { ws.close(); } catch (e) { /* ignore */ } }, 150);
|
|
376
|
+
else if (lastView && !lastView.over) promptTurn(lastView);
|
|
244
377
|
return;
|
|
245
378
|
}
|
|
246
379
|
});
|
|
247
380
|
|
|
248
381
|
ws.on('close', function () {
|
|
382
|
+
dropSigint();
|
|
249
383
|
if (!ui.skinActive()) io.say(colors.dim('与服务器的连接已断开。'));
|
|
250
384
|
io.close();
|
|
251
385
|
});
|
|
252
386
|
|
|
253
387
|
ws.on('error', function (e) {
|
|
388
|
+
dropSigint();
|
|
254
389
|
if (!ui.skinActive()) {
|
|
255
390
|
io.say(colors.red('连接失败: ' + e.message));
|
|
256
|
-
io.say(colors.dim('
|
|
391
|
+
io.say(colors.dim('确认地址/端口正确,且那边已经开了房间(npx dou-cl --serve --port ' +
|
|
392
|
+
(addr.split(':')[1] || '8080') + ')。'));
|
|
393
|
+
} else {
|
|
394
|
+
io.block(ui.skinMessage('! connect failed: ' + e.message));
|
|
257
395
|
}
|
|
258
396
|
io.close();
|
|
259
397
|
});
|
|
260
398
|
}
|
|
261
399
|
|
|
262
|
-
module.exports = { runClient: runClient };
|
|
400
|
+
module.exports = { runClient: runClient, toUrl: toUrl };
|