@yyyu-star/arcade-kit 0.11.2 → 0.12.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/src/share.ts ADDED
@@ -0,0 +1,435 @@
1
+ /**
2
+ * 分享卡(0.12.0,仓库根 docs/social.md 1.1):结算页的"分享"按钮生成一张 1080×1350 的战绩图(Canvas 画,不经服务器,0 行写入),
3
+ * 手机上支持带文件的 navigator.share 就直接调系统分享,不支持的出预览,"保存图片""复制链接"两个按钮。
4
+ *
5
+ * - 游戏只交数据(`ShareCard`),画法统一在这里,几个游戏风格一致;kit 不认识任何游戏,名字、图标、主题色都由游戏给。
6
+ * - 这个文件只在调用时碰 DOM,import 时不碰(房间、测试里 import 没事);排版的算法是纯函数,测试在 test/share.test.ts。
7
+ * - 图上只有游戏给的数据:别人的隐藏信息(没摊的底牌、手牌)游戏自己别放进来。
8
+ */
9
+
10
+ /** 卡的尺寸:竖版 4:5,社交软件里不被裁 */
11
+ export const CARD_W = 1080;
12
+ export const CARD_H = 1350;
13
+ /** 小数据最多三条,多的不画 */
14
+ export const MAX_STATS = 3;
15
+
16
+ /** 图标、插画:一个能画的图(img / canvas / ImageBitmap),或者从图集里切一块(crop 是源图的像素) */
17
+ export type ShareArt =
18
+ | CanvasImageSource
19
+ | { image: CanvasImageSource; crop?: { x: number; y: number; w: number; h: number }; fit?: 'contain' | 'cover' };
20
+
21
+ export interface ShareCard {
22
+ /** 游戏名(显示用,比如游戏自己的中文名) */
23
+ game: string;
24
+ /** 这一张是什么:比如"实盘小档 · 8 副"、"每日挑战" */
25
+ title: string;
26
+ /** 一个大数字和它的说明:守了多久、赚了多少、名次 */
27
+ big: { value: string; label: string };
28
+ /** 两三条小数据,最多 3 条 */
29
+ stats?: readonly { label: string; value: string }[];
30
+ /** 一句名场面(模板拼的,不收玩家的自由文字) */
31
+ line?: string;
32
+ /** 主题色 #rrggbb,底色按它渐变 */
33
+ accent: string;
34
+ /** 游戏图标或插画(可选);是 img 时先 decode,失败就不画,照常出卡 */
35
+ art?: ShareArt;
36
+ /** 玩家昵称和称号(可选) */
37
+ player?: { name: string; badge?: string };
38
+ /** 日期,默认现在(按北京时间写成 2026.10.16) */
39
+ date?: Date;
40
+ }
41
+
42
+ /** 截短:超过 max 个字符的截掉、补省略号(按码点算,不切坏表情) */
43
+ export function clip(s: string, max: number): string {
44
+ const cs = [...s.trim()];
45
+ return cs.length <= max ? cs.join('') : `${cs.slice(0, Math.max(1, max - 1)).join('')}…`;
46
+ }
47
+
48
+ /** 游戏给的主题色只认 #rrggbb,不认的用一个中性的深红 */
49
+ export function accentOf(x: string): string {
50
+ return /^#[0-9a-f]{6}$/i.test(x) ? x.toLowerCase() : '#9b2f24';
51
+ }
52
+
53
+ /** 颜色变深 / 变浅:k < 0 往黑走,k > 0 往白走(-1..1) */
54
+ export function shade(hex: string, k: number): string {
55
+ const c = accentOf(hex);
56
+ const ch = [1, 3, 5].map((i) => parseInt(c.slice(i, i + 2), 16));
57
+ const out = ch.map((v) => Math.round(k < 0 ? v * (1 + k) : v + (255 - v) * k));
58
+ return `#${out.map((v) => Math.max(0, Math.min(255, v)).toString(16).padStart(2, '0')).join('')}`;
59
+ }
60
+
61
+ /** 日期按北京时间写:2026.10.16 */
62
+ export function cardDate(d: Date): string {
63
+ const p = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(d);
64
+ const get = (t: string): string => p.find((x) => x.type === t)?.value ?? '';
65
+ return `${get('year')}.${get('month')}.${get('day')}`;
66
+ }
67
+
68
+ /** 卡上真正要画的字:截短、最多三条小数据、颜色认好 */
69
+ export function normalizeCard(card: ShareCard): Required<Omit<ShareCard, 'art' | 'player'>> & Pick<ShareCard, 'art' | 'player'> {
70
+ return {
71
+ game: clip(card.game, 12),
72
+ title: clip(card.title, 20),
73
+ big: { value: clip(card.big.value, 10), label: clip(card.big.label, 16) },
74
+ stats: (card.stats ?? []).slice(0, MAX_STATS).map((s) => ({ label: clip(s.label, 8), value: clip(s.value, 10) })),
75
+ line: clip(card.line ?? '', 60),
76
+ accent: accentOf(card.accent),
77
+ date: card.date ?? new Date(),
78
+ art: card.art,
79
+ player: card.player ? { name: clip(card.player.name, 12), ...(card.player.badge ? { badge: clip(card.player.badge, 10) } : {}) } : undefined,
80
+ };
81
+ }
82
+
83
+ /**
84
+ * 按宽度折行(中文逐字、英文按词),最多 maxLines 行,放不下的最后一行补省略号。measure 由调用方给(Canvas 的 measureText),测试给假的
85
+ */
86
+ export function wrapLines(text: string, maxW: number, measure: (s: string) => number, maxLines: number): string[] {
87
+ const tokens = text.match(/[A-Za-z0-9.,'%+\-]+|\s+|./gu) ?? [];
88
+ const lines: string[] = [];
89
+ let cur = '';
90
+ let cut = false;
91
+ for (const t of tokens) {
92
+ const space = /^\s+$/.test(t);
93
+ if (!cur && space) continue;
94
+ // 行满了还有字没放:截断
95
+ if (lines.length === maxLines) {
96
+ cut = true;
97
+ break;
98
+ }
99
+ const next = cur + t;
100
+ if (measure(next) <= maxW || !cur) {
101
+ cur = next;
102
+ continue;
103
+ }
104
+ lines.push(cur.trimEnd());
105
+ cur = space ? '' : t;
106
+ if (cur && lines.length === maxLines) {
107
+ cut = true;
108
+ break;
109
+ }
110
+ }
111
+ if (!cut && cur) lines.push(cur.trimEnd());
112
+ if (cut) {
113
+ let last = lines[maxLines - 1]!;
114
+ while (last && measure(`${last}…`) > maxW) last = [...last].slice(0, -1).join('');
115
+ lines[maxLines - 1] = `${last}…`;
116
+ }
117
+ return lines;
118
+ }
119
+
120
+ /** 图怎么放进框:contain 整张放进去、居中;cover 铺满框、裁掉多的(中间那块)。返回 drawImage 的九个参数里后八个 */
121
+ export function artRect(
122
+ src: { w: number; h: number },
123
+ box: { x: number; y: number; w: number; h: number },
124
+ fit: 'contain' | 'cover' = 'contain',
125
+ crop?: { x: number; y: number; w: number; h: number },
126
+ ): [number, number, number, number, number, number, number, number] {
127
+ let sx = crop?.x ?? 0;
128
+ let sy = crop?.y ?? 0;
129
+ let sw = crop?.w ?? src.w;
130
+ let sh = crop?.h ?? src.h;
131
+ if (fit === 'cover') {
132
+ const k = Math.max(box.w / sw, box.h / sh);
133
+ const vw = box.w / k;
134
+ const vh = box.h / k;
135
+ sx += (sw - vw) / 2;
136
+ sy += (sh - vh) / 2;
137
+ sw = vw;
138
+ sh = vh;
139
+ return [sx, sy, sw, sh, box.x, box.y, box.w, box.h];
140
+ }
141
+ const k = Math.min(box.w / sw, box.h / sh);
142
+ const dw = sw * k;
143
+ const dh = sh * k;
144
+ return [sx, sy, sw, sh, box.x + (box.w - dw) / 2, box.y + (box.h - dh) / 2, dw, dh];
145
+ }
146
+
147
+ // ───────────── 画(只在调用时碰 DOM) ─────────────
148
+
149
+ const FONT = '"PingFang SC","Hiragino Sans GB","Microsoft YaHei","Noto Sans CJK SC","Source Han Sans SC",system-ui,sans-serif';
150
+
151
+ function sizeOf(img: CanvasImageSource): { w: number; h: number } {
152
+ const o = img as { naturalWidth?: number; naturalHeight?: number; videoWidth?: number; videoHeight?: number; width?: unknown; height?: unknown; displayWidth?: number; displayHeight?: number };
153
+ const num = (v: unknown): number => (typeof v === 'number' ? v : typeof v === 'object' && v && 'baseVal' in v ? Number((v as { baseVal: { value: number } }).baseVal.value) : 0);
154
+ const w = o.naturalWidth || o.videoWidth || o.displayWidth || num(o.width);
155
+ const h = o.naturalHeight || o.videoHeight || o.displayHeight || num(o.height);
156
+ return { w, h };
157
+ }
158
+
159
+ /** art 的两种写法拆开;是 img 就先 decode(没加载完、坏图都在这里失败),失败返回 null,卡照常出 */
160
+ async function readyArt(art: ShareArt | undefined): Promise<{ image: CanvasImageSource; crop?: { x: number; y: number; w: number; h: number }; fit: 'contain' | 'cover' } | null> {
161
+ if (!art) return null;
162
+ const spec = typeof art === 'object' && art !== null && 'image' in art && !('naturalWidth' in art) ? art : { image: art as CanvasImageSource };
163
+ const img = spec.image as { decode?: () => Promise<void>; naturalWidth?: number };
164
+ if (typeof img.decode === 'function' && 'naturalWidth' in img) {
165
+ try {
166
+ await img.decode();
167
+ } catch {
168
+ return null;
169
+ }
170
+ }
171
+ const { w, h } = sizeOf(spec.image);
172
+ if (!w || !h) return null;
173
+ return { image: spec.image, crop: spec.crop, fit: spec.fit ?? 'contain' };
174
+ }
175
+
176
+ function round(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void {
177
+ ctx.beginPath();
178
+ ctx.moveTo(x + r, y);
179
+ ctx.arcTo(x + w, y, x + w, y + h, r);
180
+ ctx.arcTo(x + w, y + h, x, y + h, r);
181
+ ctx.arcTo(x, y + h, x, y, r);
182
+ ctx.arcTo(x, y, x + w, y, r);
183
+ ctx.closePath();
184
+ }
185
+
186
+ /** 字太宽就缩小字号,最小到 min */
187
+ function fitFont(ctx: CanvasRenderingContext2D, text: string, weight: string, size: number, maxW: number, min: number): number {
188
+ let s = size;
189
+ for (; s > min; s -= 4) {
190
+ ctx.font = `${weight} ${s}px ${FONT}`;
191
+ if (ctx.measureText(text).width <= maxW) break;
192
+ }
193
+ ctx.font = `${weight} ${s}px ${FONT}`;
194
+ return s;
195
+ }
196
+
197
+ /** 画一张卡。canvas 不给就新建一个 1080×1350 的 */
198
+ export async function drawShareCard(input: ShareCard, canvas?: HTMLCanvasElement): Promise<HTMLCanvasElement> {
199
+ const c = normalizeCard(input);
200
+ const cv = canvas ?? document.createElement('canvas');
201
+ cv.width = CARD_W;
202
+ cv.height = CARD_H;
203
+ const ctx = cv.getContext('2d')!;
204
+ const art = await readyArt(c.art);
205
+ const W = CARD_W;
206
+ const M = 72;
207
+
208
+ // 底:主题色从上往下变深,再压一层很淡的斜纹
209
+ const bg = ctx.createLinearGradient(0, 0, W * 0.4, CARD_H);
210
+ bg.addColorStop(0, shade(c.accent, 0.12));
211
+ bg.addColorStop(1, shade(c.accent, -0.55));
212
+ ctx.fillStyle = bg;
213
+ ctx.fillRect(0, 0, W, CARD_H);
214
+ ctx.save();
215
+ ctx.globalAlpha = 0.06;
216
+ ctx.strokeStyle = '#ffffff';
217
+ ctx.lineWidth = 28;
218
+ for (let x = -CARD_H; x < W; x += 110) {
219
+ ctx.beginPath();
220
+ ctx.moveTo(x, CARD_H);
221
+ ctx.lineTo(x + CARD_H, 0);
222
+ ctx.stroke();
223
+ }
224
+ ctx.restore();
225
+
226
+ // 顶:图标 + 游戏名 + 这一张是什么
227
+ let tx = M;
228
+ if (art) {
229
+ const box = { x: M, y: 70, w: 180, h: 180 };
230
+ ctx.save();
231
+ round(ctx, box.x, box.y, box.w, box.h, 36);
232
+ ctx.fillStyle = 'rgba(255,255,255,0.16)';
233
+ ctx.fill();
234
+ ctx.clip();
235
+ try {
236
+ ctx.drawImage(art.image, ...artRect(sizeOf(art.image), box, art.fit, art.crop));
237
+ } catch {
238
+ // 跨域的图、坏图:不画,卡照常出
239
+ }
240
+ ctx.restore();
241
+ tx = M + 180 + 36;
242
+ }
243
+ ctx.fillStyle = '#ffffff';
244
+ ctx.textBaseline = 'alphabetic';
245
+ ctx.textAlign = 'left';
246
+ fitFont(ctx, c.game, '800', 84, W - tx - M, 48);
247
+ ctx.fillText(c.game, tx, 160);
248
+ ctx.fillStyle = 'rgba(255,255,255,0.82)';
249
+ fitFont(ctx, c.title, '600', 44, W - tx - M, 30);
250
+ ctx.fillText(c.title, tx, 228);
251
+
252
+ // 中:大数字
253
+ ctx.textAlign = 'center';
254
+ ctx.fillStyle = '#ffffff';
255
+ ctx.shadowColor = 'rgba(0,0,0,0.25)';
256
+ ctx.shadowBlur = 24;
257
+ ctx.shadowOffsetY = 8;
258
+ fitFont(ctx, c.big.value, '900', 230, W - 2 * M, 96);
259
+ ctx.fillText(c.big.value, W / 2, 600);
260
+ ctx.shadowColor = 'transparent';
261
+ ctx.fillStyle = 'rgba(255,255,255,0.88)';
262
+ fitFont(ctx, c.big.label, '700', 54, W - 2 * M, 34);
263
+ ctx.fillText(c.big.label, W / 2, 690);
264
+
265
+ // 小数据:一排最多三格
266
+ const n = c.stats.length;
267
+ if (n) {
268
+ const gap = 28;
269
+ const cw = (W - 2 * M - gap * (n - 1)) / n;
270
+ c.stats.forEach((s, i) => {
271
+ const x = M + i * (cw + gap);
272
+ round(ctx, x, 770, cw, 190, 28);
273
+ ctx.fillStyle = 'rgba(255,255,255,0.14)';
274
+ ctx.fill();
275
+ ctx.fillStyle = '#ffffff';
276
+ fitFont(ctx, s.value, '800', 76, cw - 32, 40);
277
+ ctx.fillText(s.value, x + cw / 2, 870);
278
+ ctx.fillStyle = 'rgba(255,255,255,0.78)';
279
+ fitFont(ctx, s.label, '600', 36, cw - 32, 26);
280
+ ctx.fillText(s.label, x + cw / 2, 925);
281
+ });
282
+ }
283
+
284
+ // 一句名场面:最多两行
285
+ if (c.line) {
286
+ ctx.fillStyle = '#ffffff';
287
+ ctx.font = `700 50px ${FONT}`;
288
+ const lines = wrapLines(`“${c.line}”`, W - 2 * M - 40, (s) => ctx.measureText(s).width, 2);
289
+ lines.forEach((l, i) => ctx.fillText(l, W / 2, 1065 + i * 66));
290
+ }
291
+
292
+ // 底:昵称、称号、日期
293
+ ctx.fillStyle = 'rgba(0,0,0,0.22)';
294
+ ctx.fillRect(0, CARD_H - 150, W, 150);
295
+ ctx.textAlign = 'left';
296
+ ctx.fillStyle = '#ffffff';
297
+ if (c.player) {
298
+ fitFont(ctx, c.player.name, '800', 50, W * 0.5, 32);
299
+ ctx.fillText(c.player.name, M, CARD_H - 62);
300
+ if (c.player.badge) {
301
+ const nw = ctx.measureText(c.player.name).width;
302
+ ctx.font = `700 34px ${FONT}`;
303
+ const bw = ctx.measureText(c.player.badge).width + 36;
304
+ const bx = M + nw + 24;
305
+ if (bx + bw < W * 0.72) {
306
+ round(ctx, bx, CARD_H - 104, bw, 54, 27);
307
+ ctx.fillStyle = shade(c.accent, 0.75);
308
+ ctx.fill();
309
+ ctx.fillStyle = shade(c.accent, -0.5);
310
+ ctx.fillText(c.player.badge, bx + 18, CARD_H - 66);
311
+ }
312
+ }
313
+ }
314
+ ctx.textAlign = 'right';
315
+ ctx.fillStyle = 'rgba(255,255,255,0.8)';
316
+ ctx.font = `600 38px ${FONT}`;
317
+ ctx.fillText(cardDate(c.date), W - M, CARD_H - 62);
318
+ return cv;
319
+ }
320
+
321
+ /** 画成 PNG */
322
+ export async function shareCardBlob(card: ShareCard): Promise<Blob> {
323
+ const cv = await drawShareCard(card);
324
+ return new Promise((resolve, reject) => cv.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob failed'))), 'image/png'));
325
+ }
326
+
327
+ export interface ShareOptions {
328
+ /** 分享出去的链接:游戏地址,好友房结算带房间码 */
329
+ url: string;
330
+ /** 系统分享里的一句话,默认用卡的游戏名和大数字拼 */
331
+ text?: string;
332
+ /** 存图的文件名,默认 share.png */
333
+ fileName?: string;
334
+ /** 预览层的 z-index,默认 2000(要比游戏的弹窗高) */
335
+ zIndex?: number;
336
+ /** 冒烟、调试看的流水:`分享 系统`、`分享 预览`、`分享 保存`、`分享 复制` */
337
+ log?(line: string): void;
338
+ }
339
+
340
+ /** 结果:调了系统分享 / 出了预览 / 用户在系统分享里取消了 */
341
+ export type ShareResult = 'shared' | 'preview' | 'cancelled';
342
+
343
+ const PREVIEW_ID = 'arcade-share-preview';
344
+
345
+ /**
346
+ * 点"分享":画卡,能带文件调系统分享就调,不能就出预览(保存图片、复制链接)。
347
+ * 系统分享出错(不是用户取消)也退回预览,总能拿到图。
348
+ */
349
+ export async function shareCard(card: ShareCard, opts: ShareOptions): Promise<ShareResult> {
350
+ const blob = await shareCardBlob(card);
351
+ const name = opts.fileName ?? 'share.png';
352
+ const text = opts.text ?? `${card.game} · ${card.big.label} ${card.big.value}`;
353
+ const nav = navigator as Navigator & { canShare?: (d: ShareData) => boolean };
354
+ if (typeof File === 'function' && typeof nav.share === 'function') {
355
+ const file = new File([blob], name, { type: 'image/png' });
356
+ const data: ShareData = { files: [file], title: card.game, text, url: opts.url };
357
+ if (nav.canShare?.(data)) {
358
+ try {
359
+ await nav.share(data);
360
+ opts.log?.('分享 系统');
361
+ return 'shared';
362
+ } catch (e) {
363
+ if ((e as { name?: string }).name === 'AbortError') return 'cancelled';
364
+ // 别的错(权限、不支持的类型):退回预览
365
+ }
366
+ }
367
+ }
368
+ showPreview(blob, name, opts);
369
+ return 'preview';
370
+ }
371
+
372
+ /** 不支持系统分享时:预览图 + "保存图片""复制链接";点空白处或"关闭"收起 */
373
+ function showPreview(blob: Blob, name: string, opts: ShareOptions): void {
374
+ document.getElementById(PREVIEW_ID)?.remove();
375
+ const url = URL.createObjectURL(blob);
376
+ const root = document.createElement('div');
377
+ root.id = PREVIEW_ID;
378
+ root.setAttribute('role', 'dialog');
379
+ root.style.cssText = `position:fixed;inset:0;z-index:${opts.zIndex ?? 2000};display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16px;padding:16px;box-sizing:border-box;background:rgba(0,0,0,.72);font:600 16px ${FONT};color:#fff`;
380
+ const close = (): void => {
381
+ root.remove();
382
+ URL.revokeObjectURL(url);
383
+ };
384
+ root.addEventListener('click', (e) => {
385
+ if (e.target === root) close();
386
+ });
387
+ const img = document.createElement('img');
388
+ img.src = url;
389
+ img.alt = '战绩图';
390
+ img.style.cssText = 'max-width:min(92vw,540px);max-height:calc(100% - 140px);border-radius:16px;box-shadow:0 8px 32px rgba(0,0,0,.5)';
391
+ const tip = document.createElement('div');
392
+ tip.textContent = '手机上也可以长按图片保存';
393
+ tip.style.cssText = 'font-size:13px;opacity:.75';
394
+ const row = document.createElement('div');
395
+ row.style.cssText = 'display:flex;gap:12px;flex-wrap:wrap;justify-content:center';
396
+ const btn = (label: string, key: string, fn: () => void, primary = false): HTMLButtonElement => {
397
+ const b = document.createElement('button');
398
+ b.type = 'button';
399
+ b.textContent = label;
400
+ b.dataset['key'] = key;
401
+ b.style.cssText = `min-width:120px;min-height:48px;padding:0 20px;border-radius:24px;border:2px solid #fff;font:inherit;cursor:pointer;${primary ? 'background:#fff;color:#222' : 'background:transparent;color:#fff'}`;
402
+ b.addEventListener('click', (e) => {
403
+ e.stopPropagation();
404
+ fn();
405
+ });
406
+ return b;
407
+ };
408
+ const save = btn('保存图片', 'share:save', () => {
409
+ const a = document.createElement('a');
410
+ a.href = url;
411
+ a.download = name;
412
+ document.body.appendChild(a);
413
+ a.click();
414
+ a.remove();
415
+ opts.log?.('分享 保存');
416
+ }, true);
417
+ const copy = btn('复制链接', 'share:copy', () => {
418
+ const done = (ok: boolean): void => {
419
+ copy.textContent = ok ? '已复制' : '复制不了,链接:';
420
+ if (!ok) tip.textContent = opts.url;
421
+ opts.log?.(ok ? '分享 复制' : '分享 复制失败');
422
+ };
423
+ if (navigator.clipboard?.writeText) navigator.clipboard.writeText(opts.url).then(() => done(true), () => done(false));
424
+ else done(false);
425
+ });
426
+ const shut = btn('关闭', 'share:close', close);
427
+ row.appendChild(save);
428
+ row.appendChild(copy);
429
+ row.appendChild(shut);
430
+ root.appendChild(img);
431
+ root.appendChild(tip);
432
+ root.appendChild(row);
433
+ document.body.appendChild(root);
434
+ opts.log?.('分享 预览');
435
+ }