@yyyu-star/arcade-kit 0.0.0-stage → 0.10.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/CHANGELOG.md +256 -0
- package/LICENSE +21 -0
- package/README.md +441 -2
- package/dist/auth.d.ts +66 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +86 -0
- package/dist/auth.js.map +1 -0
- package/dist/client.d.ts +194 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +379 -0
- package/dist/client.js.map +1 -0
- package/dist/fun-art.d.ts +41 -0
- package/dist/fun-art.d.ts.map +1 -0
- package/dist/fun-art.js +141 -0
- package/dist/fun-art.js.map +1 -0
- package/dist/fun.d.ts +87 -0
- package/dist/fun.d.ts.map +1 -0
- package/dist/fun.js +512 -0
- package/dist/fun.js.map +1 -0
- package/dist/game.d.ts +160 -0
- package/dist/game.d.ts.map +1 -0
- package/dist/game.js +41 -0
- package/dist/game.js.map +1 -0
- package/dist/guest.d.ts +60 -0
- package/dist/guest.d.ts.map +1 -0
- package/dist/guest.js +87 -0
- package/dist/guest.js.map +1 -0
- package/dist/internal.d.ts +33 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +53 -0
- package/dist/internal.js.map +1 -0
- package/dist/meter.d.ts +102 -0
- package/dist/meter.d.ts.map +1 -0
- package/dist/meter.js +207 -0
- package/dist/meter.js.map +1 -0
- package/dist/protocol.d.ts +227 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +65 -0
- package/dist/protocol.js.map +1 -0
- package/dist/rng.d.ts +40 -0
- package/dist/rng.d.ts.map +1 -0
- package/dist/rng.js +119 -0
- package/dist/rng.js.map +1 -0
- package/dist/room.d.ts +352 -0
- package/dist/room.d.ts.map +1 -0
- package/dist/room.js +1563 -0
- package/dist/room.js.map +1 -0
- package/dist/testing.d.ts +125 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +272 -0
- package/dist/testing.js.map +1 -0
- package/dist/worker.d.ts +92 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +184 -0
- package/dist/worker.js.map +1 -0
- package/package.json +107 -4
- package/src/auth.ts +100 -0
- package/src/client.ts +438 -0
- package/src/fun-art.ts +184 -0
- package/src/fun.ts +626 -0
- package/src/game.ts +200 -0
- package/src/guest.ts +122 -0
- package/src/internal.ts +64 -0
- package/src/meter.ts +255 -0
- package/src/protocol.ts +243 -0
- package/src/rng.ts +131 -0
- package/src/room.ts +1677 -0
- package/src/testing.ts +300 -0
- package/src/worker.ts +234 -0
package/src/client.ts
ADDED
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 浏览器端连接器(docs/platform-backend.md 4.5):
|
|
3
|
+
* - 断线后按指数退避加随机抖动重连;房间每次都回全量快照(welcome),客户端整桌重画,不做增量修补;
|
|
4
|
+
* - 页面从后台切回来(visibilitychange、pageshow)时立刻检查连接:iOS Safari、微信切后台会挂起连接;
|
|
5
|
+
* - 收到 1008、1009 或 4000 段关闭码时不重连(违规、房间关了、被别处顶掉、版本太旧、没登录 4401、别的网站 4403);
|
|
6
|
+
* - 平台暂时不可用(1013)照常按退避重连;
|
|
7
|
+
* - 连着 maxFailures 次(默认 20 次,约 4 分钟)都没进得了房间(没收到 welcome)就停下,状态变成 failed,由界面提示"连不上"、让用户点 retry()。
|
|
8
|
+
* 退避和失败次数都在收到 welcome 时才清零,不在 onopen:被拒的连接也会先 open 再关,按 onopen 清零就会一直快速重连;
|
|
9
|
+
* - 下行消息按 seq 去重,旧的丢掉;上行操作带 moveId 和 baseSeq(做决定时看到的 seq),没收到 ack 的在重连后原样重发:
|
|
10
|
+
* 房间认得出重复的 moveId;牌局在那之后变过的,房间回 stale,不会把旧操作套到新的一手上;
|
|
11
|
+
* - 心跳发纯文本 "ping",房间由运行时自动回 "pong",不唤醒房间。
|
|
12
|
+
*
|
|
13
|
+
* 浏览器里的计时器(重连、心跳)可以用 setTimeout,"只用一个闹钟"是房间(Durable Object)那边的规矩。
|
|
14
|
+
*/
|
|
15
|
+
import { CLOSE, CLOSE_REASON, type CreateError, type DownMsg, type EmotedMsg, isFinalClose, PING, PONG, PROTOCOL_VERSION, type UpMsg } from './protocol.js';
|
|
16
|
+
|
|
17
|
+
export type ClientStatus = 'connecting' | 'open' | 'reconnecting' | 'closed' | 'failed';
|
|
18
|
+
|
|
19
|
+
export interface RoomClientOptions<V> {
|
|
20
|
+
/** 房间的 WebSocket 地址(游戏 Worker 的路径,如 wss://<站点>/games/<id>/rooms/123456/ws) */
|
|
21
|
+
url: string;
|
|
22
|
+
/** 页面的版本号,房间据此判断要不要让旧页面刷新 */
|
|
23
|
+
clientVersion?: number;
|
|
24
|
+
onMessage(msg: DownMsg<V>): void;
|
|
25
|
+
/**
|
|
26
|
+
* 连接状态变化。closeCode:closed、failed 时是让它停下的那个关闭码;reconnecting 时是刚断开的那条连接的关闭码
|
|
27
|
+
* (1006 网络断了、1013 平台暂时认不了人……,0.1.1 起才带);connecting、open 时没有。
|
|
28
|
+
* reason:closed 时房间给的关闭原因(0.2.0);4000 带 CLOSE_REASON.quota 是今天联机额度满了、房间收场,其他时候一般不用看
|
|
29
|
+
*/
|
|
30
|
+
onStatus?(status: ClientStatus, closeCode?: number, reason?: string): void;
|
|
31
|
+
/**
|
|
32
|
+
* 有人发了快捷短语(0.6.0,见 RoomGame.emotes):seat 是房间的座位号,id 是短语编号。不传就丢掉。
|
|
33
|
+
* to(0.7.0):冲着哪个座位发的,没指定时是 undefined;bot(0.10.0):电脑座位发的(GameRoom.botEmote)是 true,一般不用区分
|
|
34
|
+
*/
|
|
35
|
+
onEmote?(seat: number, id: number, to?: number, bot?: boolean): void;
|
|
36
|
+
/** 下面这些是给测试注入的;浏览器里不用传 */
|
|
37
|
+
WebSocketImpl?: { new (url: string): WebSocketLike };
|
|
38
|
+
random?: () => number;
|
|
39
|
+
setTimer?: (fn: () => void, ms: number) => unknown;
|
|
40
|
+
clearTimer?: (t: unknown) => void;
|
|
41
|
+
/** 页面事件来源(document / window),没有就不监听 */
|
|
42
|
+
events?: { addEventListener(type: string, fn: () => void): void; removeEventListener(type: string, fn: () => void): void };
|
|
43
|
+
isHidden?: () => boolean;
|
|
44
|
+
/** 连着几次没进得了房间(没收到 welcome;被拒的连接也会先 open 再关)就停下,默认 20 */
|
|
45
|
+
maxFailures?: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface WebSocketLike {
|
|
49
|
+
readyState: number;
|
|
50
|
+
onopen: ((ev: unknown) => void) | null;
|
|
51
|
+
onmessage: ((ev: { data: unknown }) => void) | null;
|
|
52
|
+
onclose: ((ev: { code: number; reason?: string }) => void) | null;
|
|
53
|
+
onerror: ((ev: unknown) => void) | null;
|
|
54
|
+
send(data: string): void;
|
|
55
|
+
close(code?: number, reason?: string): void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const OPEN = 1;
|
|
59
|
+
const BASE_MS = 500;
|
|
60
|
+
const MAX_MS = 15_000;
|
|
61
|
+
const HEARTBEAT_MS = 25_000;
|
|
62
|
+
/** 快捷短语太快时最多排几个(多的丢最早的) */
|
|
63
|
+
const EMOTE_QUEUE = 2;
|
|
64
|
+
|
|
65
|
+
/** 第 n 次重连(从 0 起)的等待:指数退避,上限 15 秒,乘一个 [0.5, 1) 的随机系数(抖动,免得一桌人同时重连)。 */
|
|
66
|
+
export function backoffMs(attempt: number, random: () => number = Math.random): number {
|
|
67
|
+
const cap = Math.min(MAX_MS, BASE_MS * 2 ** attempt);
|
|
68
|
+
return Math.round(cap * (0.5 + random() / 2));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export class RoomClient<A = unknown, V = unknown> {
|
|
72
|
+
private ws: WebSocketLike | null = null;
|
|
73
|
+
private status: ClientStatus = 'connecting';
|
|
74
|
+
private attempt = 0;
|
|
75
|
+
/** 连着几次没进得了房间(收到 welcome 清零) */
|
|
76
|
+
private failures = 0;
|
|
77
|
+
/** 这条连接收到过 welcome */
|
|
78
|
+
private welcomed = false;
|
|
79
|
+
private lastSeq = -1;
|
|
80
|
+
private retryTimer: unknown = null;
|
|
81
|
+
private beatTimer: unknown = null;
|
|
82
|
+
/** 没收到 ack 的操作:重连后按原 moveId 重发 */
|
|
83
|
+
private pending = new Map<string, UpMsg<A>>();
|
|
84
|
+
private moveCounter = 0;
|
|
85
|
+
/** 快捷短语的本地令牌桶(0.7.0):节奏按房间 welcome 里给的,没收到前按默认 1 秒 1 个、攒 3 个 */
|
|
86
|
+
private emoteRate = { ms: 1000, burst: 3 };
|
|
87
|
+
private emoteTokens = 3;
|
|
88
|
+
private emoteAt = 0;
|
|
89
|
+
/** 太快时排着的(只留最后 EMOTE_QUEUE 个),到下一个令牌时发 */
|
|
90
|
+
private emoteQueue: { id: number; to?: number }[] = [];
|
|
91
|
+
private emoteTimer: unknown = null;
|
|
92
|
+
private readonly opts: Required<Pick<RoomClientOptions<V>, 'random' | 'setTimer' | 'clearTimer'>> & RoomClientOptions<V>;
|
|
93
|
+
private readonly onWake = () => this.wake();
|
|
94
|
+
|
|
95
|
+
constructor(opts: RoomClientOptions<V>) {
|
|
96
|
+
this.opts = {
|
|
97
|
+
random: Math.random,
|
|
98
|
+
setTimer: (fn, ms) => setTimeout(fn, ms),
|
|
99
|
+
clearTimer: (t) => clearTimeout(t as ReturnType<typeof setTimeout>),
|
|
100
|
+
...opts,
|
|
101
|
+
};
|
|
102
|
+
const ev = opts.events ?? (typeof document !== 'undefined' ? document : undefined);
|
|
103
|
+
ev?.addEventListener('visibilitychange', this.onWake);
|
|
104
|
+
ev?.addEventListener('pageshow', this.onWake);
|
|
105
|
+
this.connect();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 发一个操作意图;返回 moveId。没连上时先存着,连上后发。 */
|
|
109
|
+
act(action: A): string {
|
|
110
|
+
const moveId = `${Date.now().toString(36)}-${(this.moveCounter++).toString(36)}-${Math.floor(this.opts.random() * 1e9).toString(36)}`;
|
|
111
|
+
const msg: UpMsg<A> = { t: 'act', moveId, baseSeq: this.lastSeq, action };
|
|
112
|
+
this.pending.set(moveId, msg);
|
|
113
|
+
this.trySend(msg);
|
|
114
|
+
return moveId;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
sit(seat: number): void {
|
|
118
|
+
this.trySend({ t: 'sit', seat });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
leave(): void {
|
|
122
|
+
this.trySend({ t: 'leave' });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** 房主开局;opts 是开局选项(0.3.0,游戏校验),不带就和以前一样 */
|
|
126
|
+
start(opts?: unknown): void {
|
|
127
|
+
this.trySend(opts === undefined ? { t: 'start' } : { t: 'start', opts });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 快捷短语(0.6.0):发游戏短语表里的编号;to(0.7.0)是冲着哪个座位(扔道具、点名嘲讽),不带就是对全桌。
|
|
132
|
+
* 0.7.0 起本地按房间的节奏(welcome.emote)排队:太快的排到下一个令牌,只留最后 2 个,不提示用户。没连上就丢掉,不存着重发(过时的短语没意义);
|
|
133
|
+
* 房间转发回来的 { t: 'emote', seat, id } 经 onEmote 交给页面(不进 onMessage),发的人自己也会收到
|
|
134
|
+
*/
|
|
135
|
+
emote(id: number, to?: number): void {
|
|
136
|
+
this.emoteQueue.push(to === undefined ? { id } : { id, to });
|
|
137
|
+
while (this.emoteQueue.length > EMOTE_QUEUE) this.emoteQueue.shift();
|
|
138
|
+
this.flushEmotes();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** 按令牌发排着的短语;没令牌就约到下一个令牌的时候。用户这边永远不提示"太快了" */
|
|
142
|
+
private flushEmotes(): void {
|
|
143
|
+
const { ms, burst } = this.emoteRate;
|
|
144
|
+
const now = Date.now();
|
|
145
|
+
if (ms > 0) {
|
|
146
|
+
this.emoteTokens = Math.min(burst, this.emoteTokens + (now - this.emoteAt) / ms);
|
|
147
|
+
this.emoteAt = now;
|
|
148
|
+
}
|
|
149
|
+
while (this.emoteQueue.length && (ms <= 0 || this.emoteTokens >= 1)) {
|
|
150
|
+
const e = this.emoteQueue.shift()!;
|
|
151
|
+
if (ms > 0) this.emoteTokens -= 1;
|
|
152
|
+
this.trySend(e.to === undefined ? { t: 'emote', id: e.id } : { t: 'emote', id: e.id, to: e.to });
|
|
153
|
+
}
|
|
154
|
+
if (this.emoteQueue.length && this.emoteTimer === null) {
|
|
155
|
+
this.emoteTimer = this.opts.setTimer(() => {
|
|
156
|
+
this.emoteTimer = null;
|
|
157
|
+
this.flushEmotes();
|
|
158
|
+
}, Math.ceil((1 - this.emoteTokens) * ms) + 5);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** 主动离开:不再重连。 */
|
|
163
|
+
close(): void {
|
|
164
|
+
this.setStatus('closed');
|
|
165
|
+
this.clearTimers();
|
|
166
|
+
const ev = this.opts.events ?? (typeof document !== 'undefined' ? document : undefined);
|
|
167
|
+
ev?.removeEventListener('visibilitychange', this.onWake);
|
|
168
|
+
ev?.removeEventListener('pageshow', this.onWake);
|
|
169
|
+
this.ws?.close(1000, 'bye');
|
|
170
|
+
this.ws = null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** 停下(failed)以后用户点"重试":从头再连。 */
|
|
174
|
+
retry(): void {
|
|
175
|
+
if (this.status !== 'failed') return;
|
|
176
|
+
this.attempt = 0;
|
|
177
|
+
this.failures = 0;
|
|
178
|
+
this.setStatus('connecting');
|
|
179
|
+
this.connect();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
get state(): ClientStatus {
|
|
183
|
+
return this.status;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private connect(): void {
|
|
187
|
+
if (this.status === 'closed') return;
|
|
188
|
+
const Impl = this.opts.WebSocketImpl ?? (globalThis.WebSocket as unknown as { new (url: string): WebSocketLike });
|
|
189
|
+
const url = new URL(this.opts.url);
|
|
190
|
+
url.searchParams.set('cv', String(this.opts.clientVersion ?? 0));
|
|
191
|
+
const ws = new Impl(url.toString());
|
|
192
|
+
this.ws = ws;
|
|
193
|
+
ws.onopen = () => {
|
|
194
|
+
if (this.ws !== ws) return;
|
|
195
|
+
this.welcomed = false;
|
|
196
|
+
this.setStatus('open');
|
|
197
|
+
this.heartbeat();
|
|
198
|
+
// 重发没确认的操作(房间按 moveId 去重)
|
|
199
|
+
for (const msg of this.pending.values()) this.trySend(msg);
|
|
200
|
+
};
|
|
201
|
+
ws.onmessage = (ev) => {
|
|
202
|
+
if (this.ws !== ws || typeof ev.data !== 'string' || ev.data === PONG) return;
|
|
203
|
+
let msg: DownMsg<V> | EmotedMsg;
|
|
204
|
+
try {
|
|
205
|
+
msg = JSON.parse(ev.data) as DownMsg<V> | EmotedMsg;
|
|
206
|
+
} catch {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (msg.t === 'emote') {
|
|
210
|
+
if (Number.isInteger(msg.seat) && Number.isInteger(msg.id)) this.opts.onEmote?.(msg.seat, msg.id, Number.isInteger(msg.to) ? msg.to : undefined, msg.bot === true);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
// 本地已经按节奏排队了,房间偶尔还嫌快(比如刚重连、节奏刚调过):静默丢掉,不打扰页面
|
|
214
|
+
if (msg.t === 'error' && msg.code === 'emote_too_fast' && !msg.moveId) return;
|
|
215
|
+
if (msg.t === 'welcome') {
|
|
216
|
+
if (msg.emote && msg.emote.ms >= 0 && msg.emote.burst >= 1) this.emoteRate = { ms: msg.emote.ms, burst: msg.emote.burst };
|
|
217
|
+
// 快照:整桌重画,从它的 seq 往后接;真的进了房间,退避和失败次数清零
|
|
218
|
+
this.lastSeq = msg.seq;
|
|
219
|
+
this.welcomed = true;
|
|
220
|
+
this.attempt = 0;
|
|
221
|
+
this.failures = 0;
|
|
222
|
+
} else if (msg.t === 'state') {
|
|
223
|
+
const mine = msg.ack !== undefined && this.pending.delete(msg.ack);
|
|
224
|
+
// 旧的、重复的丢掉(ack 已经处理过了)。例外:别人看不出来的一步(0.7.1,quiet),房间只回给我、seq 不加,第一次收到要交给页面
|
|
225
|
+
if (msg.seq < this.lastSeq || (msg.seq === this.lastSeq && !(mine && msg.quiet === true))) return;
|
|
226
|
+
this.lastSeq = msg.seq;
|
|
227
|
+
} else if (msg.t === 'error' && msg.moveId) {
|
|
228
|
+
// 被拒的操作不再重发
|
|
229
|
+
this.pending.delete(msg.moveId);
|
|
230
|
+
}
|
|
231
|
+
this.opts.onMessage(msg);
|
|
232
|
+
};
|
|
233
|
+
ws.onclose = (ev) => {
|
|
234
|
+
if (this.ws !== ws) return;
|
|
235
|
+
const entered = this.status === 'open' && this.welcomed;
|
|
236
|
+
this.ws = null;
|
|
237
|
+
this.clearTimers();
|
|
238
|
+
if (this.status === 'closed') return;
|
|
239
|
+
if (isFinalClose(ev.code)) {
|
|
240
|
+
this.setStatus('closed', ev.code, ev.reason);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
// 升级被拒(4xx/5xx)时浏览器只给 1006,分不出原因;1013 是平台暂时不可用:一直进不了房间就停下,别无限重连
|
|
244
|
+
if (!entered && ++this.failures >= (this.opts.maxFailures ?? 20)) {
|
|
245
|
+
this.setStatus('failed', ev.code);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
this.scheduleRetry(ev.code);
|
|
249
|
+
};
|
|
250
|
+
ws.onerror = () => {
|
|
251
|
+
// 之后一定会有 onclose,在那里处理
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** 带上这次的关闭码:游戏据此区分"网络断了"(1006)和"平台有点忙"(1013)。 */
|
|
256
|
+
private scheduleRetry(code: number): void {
|
|
257
|
+
this.setStatus('reconnecting', code);
|
|
258
|
+
const wait = backoffMs(this.attempt++, this.opts.random);
|
|
259
|
+
this.retryTimer = this.opts.setTimer(() => {
|
|
260
|
+
this.retryTimer = null;
|
|
261
|
+
this.connect();
|
|
262
|
+
}, wait);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** 切回前台:没连着就别等退避,马上连;连着就发个心跳探一下(挂起的连接会因此报错断开,再走重连)。 */
|
|
266
|
+
private wake(): void {
|
|
267
|
+
if (this.status === 'closed' || this.status === 'failed' || this.opts.isHidden?.() || (typeof document !== 'undefined' && document.hidden)) return;
|
|
268
|
+
if (this.ws && this.ws.readyState === OPEN) {
|
|
269
|
+
this.trySend(PING);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (this.retryTimer !== null) {
|
|
273
|
+
this.opts.clearTimer(this.retryTimer);
|
|
274
|
+
this.retryTimer = null;
|
|
275
|
+
this.connect();
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private heartbeat(): void {
|
|
280
|
+
this.beatTimer = this.opts.setTimer(() => {
|
|
281
|
+
this.trySend(PING);
|
|
282
|
+
this.heartbeat();
|
|
283
|
+
}, HEARTBEAT_MS);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private clearTimers(): void {
|
|
287
|
+
if (this.retryTimer !== null) this.opts.clearTimer(this.retryTimer);
|
|
288
|
+
if (this.emoteTimer !== null) this.opts.clearTimer(this.emoteTimer);
|
|
289
|
+
this.emoteTimer = null;
|
|
290
|
+
if (this.beatTimer !== null) this.opts.clearTimer(this.beatTimer);
|
|
291
|
+
this.retryTimer = null;
|
|
292
|
+
this.beatTimer = null;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private trySend(msg: UpMsg<A> | typeof PING): void {
|
|
296
|
+
if (!this.ws || this.ws.readyState !== OPEN) return;
|
|
297
|
+
try {
|
|
298
|
+
this.ws.send(typeof msg === 'string' ? msg : JSON.stringify(msg));
|
|
299
|
+
} catch {
|
|
300
|
+
// 断开中,等 onclose
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
private setStatus(s: ClientStatus, code?: number, reason?: string): void {
|
|
305
|
+
this.status = s;
|
|
306
|
+
this.opts.onStatus?.(s, code, reason);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export { PROTOCOL_VERSION };
|
|
311
|
+
|
|
312
|
+
// ---------- 开房和给玩家看的默认文案(0.8.0) ----------
|
|
313
|
+
|
|
314
|
+
/** 开房的结果:成功回房间码;失败回 acceptCreate 的错误码,网络断了是 offline。has_room 带那个房间的码 */
|
|
315
|
+
export type CreateResult = { code: string } | { error: CreateError | 'offline'; code?: string };
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* 开房:POST 到游戏 Worker 的开房路由(页面在 `/games/<id>/` 下,传相对路径 `'rooms'` 就对),请求体是 `{ opts }`,
|
|
319
|
+
* 游戏 Worker 从里面取出开局选项交给 acceptCreate(worker.ts 的 `CreateOptions.options`)。带 `X-Arcade: 1`(5.2 的 CSRF 规矩)。
|
|
320
|
+
* 回话不是 JSON(网关的错误页之类)当 unavailable;401 一律当 login
|
|
321
|
+
*/
|
|
322
|
+
export async function createRoom(url: string | URL, opts: unknown, fetchImpl: typeof fetch = fetch): Promise<CreateResult> {
|
|
323
|
+
let res: Response;
|
|
324
|
+
try {
|
|
325
|
+
res = await fetchImpl(url, { method: 'POST', headers: { 'x-arcade': '1', 'content-type': 'application/json' }, body: JSON.stringify({ opts }) });
|
|
326
|
+
} catch {
|
|
327
|
+
return { error: 'offline' };
|
|
328
|
+
}
|
|
329
|
+
const body = (await res.json().catch(() => ({}))) as { code?: unknown; error?: unknown };
|
|
330
|
+
if (res.ok && typeof body.code === 'string') return { code: body.code };
|
|
331
|
+
if (res.status === 401) return { error: 'login' };
|
|
332
|
+
const error = typeof body.error === 'string' ? (body.error as CreateError) : 'unavailable';
|
|
333
|
+
return { error, ...(typeof body.code === 'string' ? { code: body.code } : {}) };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* 开房失败、和房间断开时给玩家看的话(docs/platform-backend.md 15.10 的口径:大白话、说清能做什么)。只写各游戏都通用的说法,
|
|
338
|
+
* 游戏按 key 换掉个别措辞(比如"这一手""这一局"、"桌上的筹码结回钱包"),没换的用默认:
|
|
339
|
+
* - 开房失败:key 是 createRoom 的错误码(login、has_room、daily_limit、busy、bad_options、forbidden、unavailable、offline),不认识的按 unavailable;
|
|
340
|
+
* - 断开:key 是 CLOSE 里的名字(roomClosed、noRoom、replaced……),另有 quota(4000 带 quota 原因:今天额度快满、房间收场)和 other(没列出的关闭码)。
|
|
341
|
+
*/
|
|
342
|
+
export const ROOM_TEXT = {
|
|
343
|
+
login: '登录过期了,刷新一下页面',
|
|
344
|
+
forbidden: '请从游戏大厅打开',
|
|
345
|
+
has_room: '你同时开着的房间太多了,先离开几个再开',
|
|
346
|
+
daily_limit: '今天开房的次数用完了,明天再来',
|
|
347
|
+
busy: '今天联机的人太多了,明天再来,单机照常玩',
|
|
348
|
+
bad_options: '开房的设置不对,刷新一下再开',
|
|
349
|
+
unavailable: '暂时开不了房,稍后再试',
|
|
350
|
+
offline: '网络断了,连上再试',
|
|
351
|
+
roomClosed: '房间关了',
|
|
352
|
+
quota: '今天联机的人太多了,房间先散了,单机照常玩',
|
|
353
|
+
replaced: '你在别的页面打开了这个房间,这里先断开',
|
|
354
|
+
noRoom: '没有这个房间:码不对,或者房间已经关了',
|
|
355
|
+
tooOld: '页面有新版本了,刷新一下',
|
|
356
|
+
full: '这个房间看的人太多了,进不去',
|
|
357
|
+
tooManyJoins: '你在这个房间进出、操作太多次了,先歇一会儿',
|
|
358
|
+
userQuota: '你在这个房间进出、操作太多次了,先歇一会儿',
|
|
359
|
+
unauthorized: '登录过期了,刷新一下页面',
|
|
360
|
+
other: '和房间断开了',
|
|
361
|
+
} as const;
|
|
362
|
+
|
|
363
|
+
export type RoomTextKey = keyof typeof ROOM_TEXT;
|
|
364
|
+
export type RoomTexts = Partial<Record<RoomTextKey, string>>;
|
|
365
|
+
|
|
366
|
+
const CREATE_KEYS = new Set<string>(['login', 'forbidden', 'has_room', 'daily_limit', 'busy', 'bad_options', 'unavailable', 'offline']);
|
|
367
|
+
|
|
368
|
+
/** 开房失败给玩家看的话:错误码 → 文案,游戏的 overrides 优先 */
|
|
369
|
+
export function createText(error: string, overrides: RoomTexts = {}): string {
|
|
370
|
+
const key = (CREATE_KEYS.has(error) ? error : 'unavailable') as RoomTextKey;
|
|
371
|
+
return overrides[key] ?? ROOM_TEXT[key];
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** 和房间断开时给玩家看的话:关闭码(和 4000 带的原因)→ 文案,游戏的 overrides 优先。1013(稍后再试)客户端会自己重连,也给一句 other */
|
|
375
|
+
export function closeText(code: number, reason?: string, overrides: RoomTexts = {}): string {
|
|
376
|
+
let key: RoomTextKey = 'other';
|
|
377
|
+
if (code === CLOSE.roomClosed) key = reason === CLOSE_REASON.quota ? 'quota' : 'roomClosed';
|
|
378
|
+
else if (code === CLOSE.busy) key = 'busy';
|
|
379
|
+
else if (code === CLOSE.forbidden) key = 'forbidden';
|
|
380
|
+
else {
|
|
381
|
+
const name = (Object.keys(CLOSE) as (keyof typeof CLOSE)[]).find((k) => CLOSE[k] === code);
|
|
382
|
+
if (name && name in ROOM_TEXT) key = name as RoomTextKey;
|
|
383
|
+
}
|
|
384
|
+
return overrides[key] ?? ROOM_TEXT[key];
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ---------- 有没有平台(0.10.0,独立部署时页面降级用) ----------
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* 页面所在站点有没有平台:
|
|
391
|
+
* - `platform: true`:同域 `/api/me` 回 200 且是 `{ user }` 的 JSON。user 是平台的 `/api/me` 原样(没登录为 null);
|
|
392
|
+
* - `platform: false`:404、405、410,或者 2xx 却不是那个 JSON(独立部署时这个地址落到游戏自己的 Worker 或静态资源上)。
|
|
393
|
+
* 游戏应当藏起登录、云存档、排行榜、钱包,"回大厅"改成回游戏首页;
|
|
394
|
+
* - `platform: 'unknown'`:5xx、429、断网、超时(默认 2.5 秒)。平台可能只是抖了一下:先按有平台处理但别报错,过一会儿再试,
|
|
395
|
+
* 或者干脆当成没有——游戏自己定。
|
|
396
|
+
*/
|
|
397
|
+
export type PlatformInfo = { platform: true; user: unknown } | { platform: false } | { platform: 'unknown' };
|
|
398
|
+
|
|
399
|
+
export interface DetectPlatformOptions {
|
|
400
|
+
/** 默认 `/api/me`(站点根下,和游戏挂在哪无关) */
|
|
401
|
+
url?: string | URL;
|
|
402
|
+
timeoutMs?: number;
|
|
403
|
+
fetchImpl?: typeof fetch;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export async function detectPlatform(opts: DetectPlatformOptions = {}): Promise<PlatformInfo> {
|
|
407
|
+
const f = opts.fetchImpl ?? fetch;
|
|
408
|
+
let res: Response;
|
|
409
|
+
try {
|
|
410
|
+
res = await f(opts.url ?? '/api/me', { credentials: 'same-origin', headers: { accept: 'application/json' }, signal: AbortSignal.timeout(opts.timeoutMs ?? 2_500) });
|
|
411
|
+
} catch {
|
|
412
|
+
return { platform: 'unknown' };
|
|
413
|
+
}
|
|
414
|
+
if (res.status === 404 || res.status === 405 || res.status === 410) return { platform: false };
|
|
415
|
+
if (res.status < 200 || res.status >= 300) return { platform: 'unknown' };
|
|
416
|
+
const type = res.headers.get('content-type') ?? '';
|
|
417
|
+
if (!/^application\/(?:[\w.+-]+\+)?json\b/i.test(type)) return { platform: false };
|
|
418
|
+
try {
|
|
419
|
+
const body = (await res.json()) as unknown;
|
|
420
|
+
if (body && typeof body === 'object' && !Array.isArray(body) && 'user' in body) return { platform: true, user: (body as { user: unknown }).user };
|
|
421
|
+
} catch {
|
|
422
|
+
// 读到一半断了算不知道;读完了却不是 JSON 在上面已经按 content-type 挡掉
|
|
423
|
+
return { platform: 'unknown' };
|
|
424
|
+
}
|
|
425
|
+
return { platform: false };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* 独立模式下给开房、进房的地址带上昵称(`?name=`,0.10.0):`createRoom(withNickname('rooms', name), opts)`、
|
|
430
|
+
* `new RoomClient({ url: withNickname(wsUrl, name), … })`。服务端会严格清洗(guest.ts 的 cleanNickname),不合格就用默认的"游客1234";
|
|
431
|
+
* 有平台时服务端不看它,昵称以平台为准。name 空的原样返回
|
|
432
|
+
*/
|
|
433
|
+
export function withNickname(url: string, name?: string | null): string {
|
|
434
|
+
if (!name) return url;
|
|
435
|
+
const i = url.indexOf('#');
|
|
436
|
+
const [head, hash] = i < 0 ? [url, ''] : [url.slice(0, i), url.slice(i)];
|
|
437
|
+
return `${head}${head.includes('?') ? '&' : '?'}name=${encodeURIComponent(name)}${hash}`;
|
|
438
|
+
}
|
package/src/fun-art.ts
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 好玩素材的画(0.7.0):表情、道具、命中效果,全是手写的 SVG 字符串(viewBox 0 0 100 100),不用 emoji 字体、不引外部图片。
|
|
3
|
+
* 风格:圆脸、方脸的小动物,深棕描边、平涂、少细节,手机上缩到 40 像素也认得出。
|
|
4
|
+
* 这个文件只有字符串,不碰 DOM,浏览器、Node、workerd 里都能引用(测试在 workerd 里检查它们)。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const O = '#3b2a1f';
|
|
8
|
+
const SW = 'stroke="#3b2a1f" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"';
|
|
9
|
+
const LINE = `fill="none" ${SW}`;
|
|
10
|
+
|
|
11
|
+
const wrap = (body: string): string =>
|
|
12
|
+
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100%" height="100%">${body}</svg>`;
|
|
13
|
+
|
|
14
|
+
const blush = (fill = '#ff9a9a'): string =>
|
|
15
|
+
`<ellipse cx="27" cy="62" rx="7" ry="4" fill="${fill}" opacity=".55"/><ellipse cx="73" cy="62" rx="7" ry="4" fill="${fill}" opacity=".55"/>`;
|
|
16
|
+
const catEars = (fill: string): string =>
|
|
17
|
+
`<path d="M16 38L20 8L44 22Z" fill="${fill}" ${SW}/><path d="M84 38L80 8L56 22Z" fill="${fill}" ${SW}/>` +
|
|
18
|
+
`<path d="M22 28L23 16L33 22Z" fill="#ffb3a7"/><path d="M78 28L77 16L67 22Z" fill="#ffb3a7"/>`;
|
|
19
|
+
const bearEars = (fill: string): string =>
|
|
20
|
+
`<circle cx="22" cy="22" r="12" fill="${fill}" ${SW}/><circle cx="78" cy="22" r="12" fill="${fill}" ${SW}/>` +
|
|
21
|
+
`<circle cx="22" cy="22" r="5" fill="#e79a7a"/><circle cx="78" cy="22" r="5" fill="#e79a7a"/>`;
|
|
22
|
+
const roundFace = (fill: string): string => `<circle cx="50" cy="54" r="40" fill="${fill}" ${SW}/>`;
|
|
23
|
+
const squareFace = (fill: string): string => `<rect x="12" y="16" width="76" height="76" rx="22" fill="${fill}" ${SW}/>`;
|
|
24
|
+
const heart = (x: number, y: number, s: number, fill = '#ff5577'): string =>
|
|
25
|
+
`<path transform="translate(${x} ${y}) scale(${s})" d="M0 6C-6-2-16 2-12 10L0 22L12 10C16 2 6-2 0 6Z" fill="${fill}" ${SW.replace('4"', '2.5"')}/>`;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 表情包:顺序固定,只往后加(游戏按"自己的起始编号 + 下标"映射到 emote 的 id,改顺序会让新旧页面对不上)。
|
|
29
|
+
* 得意、狗头、大哭、流汗、震惊、坏笑、比心、生气、睡着、鼓掌、笑哭、墨镜
|
|
30
|
+
*/
|
|
31
|
+
export const STICKERS = ['smug', 'doge', 'cry', 'sweat', 'shock', 'sly', 'love', 'angry', 'sleep', 'clap', 'lol', 'cool'] as const;
|
|
32
|
+
export type StickerId = (typeof STICKERS)[number];
|
|
33
|
+
|
|
34
|
+
const STICKER_BODY: Record<StickerId, string> = {
|
|
35
|
+
// 得意(猫):眯眼、挑眉、歪嘴笑
|
|
36
|
+
smug:
|
|
37
|
+
catEars('#ffd36b') + roundFace('#ffd36b') + blush() +
|
|
38
|
+
`<path d="M26 40L42 36M74 40L58 36" ${LINE}/><path d="M27 52Q35 46 43 52M57 52Q65 46 73 52" ${LINE}/>` +
|
|
39
|
+
`<path d="M27 52H43M57 52H73" ${LINE}/><path d="M38 68Q52 76 66 64" ${LINE}/>`,
|
|
40
|
+
// 狗头(柴犬):斜眼看人
|
|
41
|
+
doge:
|
|
42
|
+
`<path d="M14 44L18 10L42 26Z" fill="#e8a14e" ${SW}/><path d="M86 44L82 10L58 26Z" fill="#e8a14e" ${SW}/>` +
|
|
43
|
+
roundFace('#f2b45a') + `<ellipse cx="50" cy="68" rx="26" ry="20" fill="#fff4e2" ${SW}/>` +
|
|
44
|
+
`<ellipse cx="34" cy="46" rx="8" ry="6" fill="#fff"/><ellipse cx="66" cy="46" rx="8" ry="6" fill="#fff"/>` +
|
|
45
|
+
`<circle cx="39" cy="46" r="3.5" fill="${O}"/><circle cx="71" cy="46" r="3.5" fill="${O}"/>` +
|
|
46
|
+
`<path d="M26 36Q34 32 42 36M58 36Q66 32 74 36" ${LINE}/>` +
|
|
47
|
+
`<ellipse cx="50" cy="60" rx="6" ry="4.5" fill="${O}"/><path d="M50 64V70M42 72Q50 78 58 72" ${LINE}/>`,
|
|
48
|
+
// 大哭(熊):闭眼张嘴,两道眼泪
|
|
49
|
+
cry:
|
|
50
|
+
bearEars('#e8c49a') + roundFace('#f6d9ae') +
|
|
51
|
+
`<path d="M26 50Q34 42 42 50M58 50Q66 42 74 50" ${LINE}/>` +
|
|
52
|
+
`<path d="M30 54Q24 72 30 92M70 54Q76 72 70 92" fill="none" stroke="#5ab4f0" stroke-width="7" stroke-linecap="round" opacity=".9"/>` +
|
|
53
|
+
`<path d="M36 70Q50 58 64 70Q50 84 36 70Z" fill="#8a2b1f" ${SW}/>`,
|
|
54
|
+
// 流汗:豆眼、一字嘴、额头一滴汗
|
|
55
|
+
sweat:
|
|
56
|
+
roundFace('#ffe08a') + `<path d="M28 40L40 44M72 40L60 44" ${LINE}/>` +
|
|
57
|
+
`<circle cx="36" cy="52" r="4" fill="${O}"/><circle cx="64" cy="52" r="4" fill="${O}"/><path d="M40 70H60" ${LINE}/>` +
|
|
58
|
+
`<path d="M80 18Q90 34 80 40Q70 34 80 18Z" fill="#7cc8f5" ${SW.replace('4"', '3"')}/>`,
|
|
59
|
+
// 震惊(方脸):大眼、O 嘴、额头发青
|
|
60
|
+
shock:
|
|
61
|
+
squareFace('#fff1b8') + `<path d="M30 22V34M42 20V32M58 20V32M70 22V34" stroke="#7aa6e8" stroke-width="4" stroke-linecap="round"/>` +
|
|
62
|
+
`<circle cx="34" cy="50" r="10" fill="#fff" ${SW}/><circle cx="66" cy="50" r="10" fill="#fff" ${SW}/>` +
|
|
63
|
+
`<circle cx="34" cy="50" r="3" fill="${O}"/><circle cx="66" cy="50" r="3" fill="${O}"/>` +
|
|
64
|
+
`<ellipse cx="50" cy="74" rx="8" ry="10" fill="#8a2b1f" ${SW}/>`,
|
|
65
|
+
// 坏笑(狐狸):一只眼眨、咧嘴
|
|
66
|
+
sly:
|
|
67
|
+
`<path d="M12 46L18 6L44 24Z" fill="#f08a3c" ${SW}/><path d="M88 46L82 6L56 24Z" fill="#f08a3c" ${SW}/>` +
|
|
68
|
+
roundFace('#f59a4c') + `<path d="M22 66Q50 100 78 66Q64 60 50 66Q36 60 22 66Z" fill="#fff4e2"/>` +
|
|
69
|
+
`<path d="M26 48Q34 42 42 48" ${LINE}/><circle cx="64" cy="48" r="5" fill="${O}"/><path d="M56 38L72 40" ${LINE}/>` +
|
|
70
|
+
`<path d="M34 64Q50 82 68 62Z" fill="#8a2b1f" ${SW}/><path d="M40 66H62" stroke="#fff" stroke-width="3"/>`,
|
|
71
|
+
// 比心:笑眼,旁边两颗心
|
|
72
|
+
love:
|
|
73
|
+
roundFace('#ffd36b') + blush() + `<path d="M26 50Q34 42 42 50M58 50Q66 42 74 50" ${LINE}/>` +
|
|
74
|
+
`<path d="M38 66Q50 78 62 66" ${LINE}/>` + heart(84, 12, 1.1) + heart(14, 18, 0.8),
|
|
75
|
+
// 生气:红脸、倒八眉、冒气
|
|
76
|
+
angry:
|
|
77
|
+
roundFace('#ff8a64') + `<path d="M24 38L42 46M76 38L58 46" ${LINE}/>` +
|
|
78
|
+
`<circle cx="36" cy="54" r="4" fill="${O}"/><circle cx="64" cy="54" r="4" fill="${O}"/><path d="M36 76Q50 64 64 76" ${LINE}/>` +
|
|
79
|
+
`<path d="M74 14Q78 20 74 24M84 16Q88 22 84 26M70 22H80" fill="none" stroke="#d62b2b" stroke-width="4" stroke-linecap="round"/>` +
|
|
80
|
+
`<circle cx="16" cy="20" r="6" fill="#fff" opacity=".85"/><circle cx="10" cy="12" r="4" fill="#fff" opacity=".7"/>`,
|
|
81
|
+
// 睡着(熊):闭眼、鼻涕泡、Z
|
|
82
|
+
sleep:
|
|
83
|
+
bearEars('#c9b0e8') + roundFace('#e2d2f6') +
|
|
84
|
+
`<path d="M28 52H42M58 52H72" ${LINE}/><circle cx="50" cy="70" r="4" fill="${O}"/>` +
|
|
85
|
+
`<circle cx="62" cy="66" r="7" fill="#bfe6ff" stroke="#5ab4f0" stroke-width="2.5" opacity=".9"/>` +
|
|
86
|
+
`<path d="M70 8H84L70 22H84M86 30H94L86 38H94" fill="none" stroke="#6a5acd" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,
|
|
87
|
+
// 鼓掌:张嘴笑,两只手
|
|
88
|
+
clap:
|
|
89
|
+
roundFace('#ffd36b') + blush() + `<path d="M28 48Q35 40 42 48M58 48Q65 40 72 48" ${LINE}/>` +
|
|
90
|
+
`<path d="M38 60Q50 74 62 60Z" fill="#8a2b1f" ${SW}/>` +
|
|
91
|
+
`<ellipse cx="24" cy="82" rx="10" ry="14" transform="rotate(-25 24 82)" fill="#ffd36b" ${SW}/>` +
|
|
92
|
+
`<ellipse cx="76" cy="82" rx="10" ry="14" transform="rotate(25 76 82)" fill="#ffd36b" ${SW}/>` +
|
|
93
|
+
`<path d="M8 66L14 70M10 56L16 62M92 66L86 70M90 56L84 62" stroke="#f5b400" stroke-width="4" stroke-linecap="round"/>`,
|
|
94
|
+
// 笑哭:笑眼带泪、大笑
|
|
95
|
+
lol:
|
|
96
|
+
roundFace('#ffd36b') + `<path d="M26 46Q34 38 42 46M58 46Q66 38 74 46" ${LINE}/>` +
|
|
97
|
+
`<path d="M20 50Q12 58 16 64Q22 62 22 54M80 50Q88 58 84 64Q78 62 78 54" fill="#7cc8f5" stroke="#3a8fd0" stroke-width="2.5"/>` +
|
|
98
|
+
`<path d="M30 58Q50 90 70 58Z" fill="#8a2b1f" ${SW}/><path d="M38 72Q50 80 62 72" fill="#ff8a8a"/>`,
|
|
99
|
+
// 墨镜(方脸):酷
|
|
100
|
+
cool:
|
|
101
|
+
squareFace('#ffc46b') + `<path d="M18 44H82" ${LINE}/>` +
|
|
102
|
+
`<rect x="22" y="40" width="24" height="16" rx="6" fill="${O}"/><rect x="54" y="40" width="24" height="16" rx="6" fill="${O}"/>` +
|
|
103
|
+
`<path d="M27 45L33 45" stroke="#fff" stroke-width="3" stroke-linecap="round" opacity=".8"/><path d="M59 45L65 45" stroke="#fff" stroke-width="3" stroke-linecap="round" opacity=".8"/>` +
|
|
104
|
+
`<path d="M40 72Q52 78 64 68" ${LINE}/>`,
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
export function stickerSvg(id: StickerId): string {
|
|
108
|
+
return wrap(STICKER_BODY[id]);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 道具:扔向别人头像的东西,顺序固定、只往后加(同 STICKERS)。臭鸡蛋和鸡蛋同一个画法换颜色,命中时多几缕臭气。 */
|
|
112
|
+
export const PROPS = ['tomato', 'egg', 'stinkyEgg', 'slipper', 'bomb', 'rose', 'like', 'cheers'] as const;
|
|
113
|
+
export type PropId = (typeof PROPS)[number];
|
|
114
|
+
|
|
115
|
+
const PROP_BODY: Record<PropId, string> = {
|
|
116
|
+
tomato:
|
|
117
|
+
`<circle cx="50" cy="56" r="34" fill="#e8402e" ${SW}/><ellipse cx="38" cy="44" rx="10" ry="6" fill="#fff" opacity=".35"/>` +
|
|
118
|
+
`<path d="M50 24L42 14L50 18L58 12L56 22L66 22L54 28Z" fill="#4caf50" ${SW.replace('4"', '3"')}/>`,
|
|
119
|
+
egg: `<ellipse cx="50" cy="54" rx="27" ry="35" fill="#fff8ec" ${SW}/><ellipse cx="40" cy="40" rx="7" ry="10" fill="#fff" opacity=".8"/>`,
|
|
120
|
+
stinkyEgg:
|
|
121
|
+
`<ellipse cx="50" cy="54" rx="27" ry="35" fill="#e2e6b8" ${SW}/><circle cx="40" cy="62" r="4" fill="#9a8a4a"/><circle cx="60" cy="46" r="3" fill="#9a8a4a"/>` +
|
|
122
|
+
`<path d="M72 18Q78 12 74 6M82 24Q88 18 84 12" fill="none" stroke="#6fbf3a" stroke-width="4" stroke-linecap="round"/>`,
|
|
123
|
+
slipper:
|
|
124
|
+
`<path d="M32 10Q52 2 68 14Q78 42 72 72Q64 96 46 94Q28 90 28 66Q24 36 32 10Z" fill="#4a8be0" ${SW}/>` +
|
|
125
|
+
`<path d="M36 70Q50 64 64 70" fill="none" stroke="#2f6bbf" stroke-width="3"/><path d="M38 28Q50 50 62 28" fill="none" stroke="#fff" stroke-width="7" stroke-linecap="round"/>`,
|
|
126
|
+
bomb:
|
|
127
|
+
`<circle cx="46" cy="60" r="30" fill="#2b2b2b" ${SW}/><ellipse cx="36" cy="50" rx="8" ry="5" fill="#fff" opacity=".35"/>` +
|
|
128
|
+
`<rect x="56" y="24" width="16" height="14" rx="3" transform="rotate(35 64 31)" fill="#6b6b6b" ${SW}/>` +
|
|
129
|
+
`<path d="M70 24Q78 14 88 16" fill="none" stroke="#a0763a" stroke-width="4" stroke-linecap="round"/>` +
|
|
130
|
+
`<path d="M88 8L90 14L96 16L90 18L88 24L86 18L80 16L86 14Z" fill="#ffd23f" stroke="#ff8a00" stroke-width="1.5"/>`,
|
|
131
|
+
rose:
|
|
132
|
+
`<path d="M50 52V94" stroke="#3f9b4a" stroke-width="6" stroke-linecap="round"/><path d="M50 74Q34 66 30 76Q40 84 50 78" fill="#4caf50" ${SW.replace('4"', '3"')}/>` +
|
|
133
|
+
`<path d="M50 14Q30 16 28 36Q30 56 50 58Q70 56 72 36Q70 16 50 14Z" fill="#e8344e" ${SW}/>` +
|
|
134
|
+
`<path d="M40 30Q50 22 60 30Q56 42 50 44Q42 40 40 30Z" fill="#c21d3a"/><path d="M44 34Q50 30 56 34" fill="none" stroke="#ff8aa0" stroke-width="3" stroke-linecap="round"/>`,
|
|
135
|
+
like:
|
|
136
|
+
`<circle cx="50" cy="50" r="42" fill="#3d8bfd" ${SW}/><path d="M28 48H38V78H28Z" fill="#fff" ${SW.replace('4"', '3"')}/>` +
|
|
137
|
+
`<path d="M40 48L52 24Q58 20 61 28L57 44H72Q79 46 77 54L73 74Q71 78 65 78H40Z" fill="#fff" ${SW.replace('4"', '3"')}/>`,
|
|
138
|
+
cheers:
|
|
139
|
+
`<rect x="24" y="30" width="40" height="58" rx="6" fill="#f5b52e" ${SW}/><path d="M64 44Q80 44 80 58Q80 72 64 72" fill="none" ${SW}/>` +
|
|
140
|
+
`<path d="M22 32Q24 16 36 20Q42 10 52 18Q64 14 66 30Z" fill="#fff" ${SW}/><path d="M34 46V78M46 46V78M56 46V78" stroke="#ffd77a" stroke-width="3"/>`,
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
export function propSvg(id: PropId): string {
|
|
144
|
+
return wrap(PROP_BODY[id]);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---------- 命中效果用的小件(fun.ts 组合、上动画) ----------
|
|
148
|
+
|
|
149
|
+
/** 番茄爆开的汁水:一大摊不规则红色加几块果肉(带籽) */
|
|
150
|
+
export const SPLAT_TOMATO = wrap(
|
|
151
|
+
`<path d="M50 18Q58 30 70 22Q70 36 86 38Q76 48 88 60Q72 60 74 76Q62 68 54 84Q48 70 34 80Q34 66 16 64Q28 54 14 42Q30 40 28 24Q42 32 50 18Z" fill="#d93a28" opacity=".92"/>` +
|
|
152
|
+
`<circle cx="18" cy="24" r="5" fill="#d93a28"/><circle cx="86" cy="22" r="4" fill="#d93a28"/><circle cx="90" cy="76" r="5" fill="#d93a28"/><circle cx="12" cy="80" r="4" fill="#d93a28"/>` +
|
|
153
|
+
`<path d="M40 44Q48 38 54 46Q50 54 42 52Z" fill="#f0705a"/><circle cx="46" cy="47" r="1.8" fill="#ffe08a"/>` +
|
|
154
|
+
`<path d="M58 58Q66 54 68 62Q62 68 58 64Z" fill="#f0705a"/><circle cx="62" cy="61" r="1.6" fill="#ffe08a"/>`,
|
|
155
|
+
);
|
|
156
|
+
/** 鸡蛋糊上去:蛋清加蛋黄;臭鸡蛋的蛋清发绿 */
|
|
157
|
+
export const splatEgg = (stinky: boolean): string =>
|
|
158
|
+
wrap(
|
|
159
|
+
`<path d="M50 20Q66 18 74 30Q90 36 82 52Q88 68 70 72Q60 86 46 76Q28 82 24 66Q10 56 22 44Q18 26 36 28Q40 20 50 20Z" fill="${stinky ? '#eef2c8' : '#fffaf0'}" opacity=".93" stroke="${stinky ? '#c8d08a' : '#efe2c8'}" stroke-width="2"/>` +
|
|
160
|
+
`<circle cx="50" cy="48" r="15" fill="${stinky ? '#c9b23a' : '#ffb81f'}"/><ellipse cx="45" cy="43" rx="5" ry="3" fill="#fff" opacity=".6"/>`,
|
|
161
|
+
);
|
|
162
|
+
/** 往下流的一道(蛋清带一点黄) */
|
|
163
|
+
export const DRIP = wrap(`<path d="M40 0Q34 44 44 74Q50 90 58 74Q66 44 60 0Z" fill="#fff6d8" opacity=".95" stroke="#e8c870" stroke-width="4"/><ellipse cx="50" cy="74" rx="6" ry="8" fill="#ffd25a"/>`);
|
|
164
|
+
/** 蛋壳碎片 */
|
|
165
|
+
export const SHELL = wrap(`<path d="M10 60L30 30L44 48L58 26L74 50L90 40L80 76L20 80Z" fill="#fff8ec" ${SW}/>`);
|
|
166
|
+
/** 臭气:一缕绿色的波浪线 */
|
|
167
|
+
export const STINK = wrap(`<path d="M50 96Q26 80 50 64Q74 48 50 32Q26 16 50 2" fill="none" stroke="#3f8f1f" stroke-width="13" stroke-linecap="round" opacity=".55"/><path d="M50 96Q26 80 50 64Q74 48 50 32Q26 16 50 2" fill="none" stroke="#8ee05a" stroke-width="6" stroke-linecap="round"/>`);
|
|
168
|
+
/** 汁水、泡沫的小圆点 */
|
|
169
|
+
export const dot = (fill: string, stroke = 'none'): string => wrap(`<circle cx="50" cy="50" r="40" fill="${fill}" stroke="${stroke}" stroke-width="8"/>`);
|
|
170
|
+
/** 小星星(拖鞋打中、点赞、碰杯) */
|
|
171
|
+
export const STAR = wrap(`<path d="M50 4L62 36L96 38L70 60L80 94L50 74L20 94L30 60L4 38L38 36Z" fill="#ffd23f" stroke="#e08a00" stroke-width="4" stroke-linejoin="round"/>`);
|
|
172
|
+
/** 爆炸:两层锯齿 */
|
|
173
|
+
export const BURST = wrap(
|
|
174
|
+
`<path d="M50 0L58 30L84 12L70 40L100 46L72 58L90 84L60 70L50 100L40 70L10 84L28 58L0 46L30 40L16 12L42 30Z" fill="#ff8a1f"/>` +
|
|
175
|
+
`<path d="M50 20L56 40L74 30L64 48L84 52L64 58L74 74L56 64L50 82L44 64L26 74L36 58L16 52L36 48L26 30L44 40Z" fill="#ffe14d"/>`,
|
|
176
|
+
);
|
|
177
|
+
/** 烟 */
|
|
178
|
+
export const SMOKE = wrap(`<circle cx="36" cy="58" r="26" fill="#8a8a8a"/><circle cx="62" cy="48" r="28" fill="#9a9a9a"/><circle cx="54" cy="70" r="22" fill="#7a7a7a"/>`);
|
|
179
|
+
/** 熏黑:盖在头像上的一团黑,炸完淡掉 */
|
|
180
|
+
export const SOOT = wrap(`<circle cx="50" cy="50" r="48" fill="#1a1a1a"/>`);
|
|
181
|
+
/** 花瓣 */
|
|
182
|
+
export const PETAL = wrap(`<path d="M50 6Q86 30 50 94Q14 30 50 6Z" fill="#e8344e" stroke="#c21d3a" stroke-width="4"/>`);
|
|
183
|
+
/** 引信上的火花 */
|
|
184
|
+
export const SPARK = wrap(`<path d="M50 0L58 38L100 50L58 62L50 100L42 62L0 50L42 38Z" fill="#ffd23f" stroke="#ff6a00" stroke-width="4"/>`);
|