@yyyu-star/arcade-kit 0.14.0 → 0.15.0

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 CHANGED
@@ -5,7 +5,11 @@
5
5
  * - 游戏只交数据(`ShareCard`),画法统一在这里,几个游戏风格一致;kit 不认识任何游戏,名字、图标、主题色都由游戏给。
6
6
  * - 这个文件只在调用时碰 DOM,import 时不碰(房间、测试里 import 没事);排版的算法是纯函数,测试在 test/share.test.ts。
7
7
  * - 图上只有游戏给的数据:别人的隐藏信息(没摊的底牌、手牌)游戏自己别放进来。
8
+ * - 右下角的二维码(0.15.0):朋友圈里的图不能点,扫码是唯一入口。内容是运行时的 location.origin 加上 opts.url 的路径,
9
+ * kit 和游戏都不写域名;编码器是 qr.ts(自己写的最小子集,不引依赖)。
10
+ * - 微信内置浏览器(0.15.0):没有 navigator.share,下载链接也常常不生效,预览图改用 data: URL(blob: 的图长按经常存不下来),提示改成长按。
8
11
  */
12
+ import { qrEncode } from './qr.js';
9
13
 
10
14
  /** 卡的尺寸:竖版 4:5,社交软件里不被裁 */
11
15
  export const CARD_W = 1080;
@@ -161,6 +165,39 @@ export function artRect(
161
165
  return [sx, sy, sw, sh, box.x + (box.w - dw) / 2, box.y + (box.h - dh) / 2, dw, dh];
162
166
  }
163
167
 
168
+ /**
169
+ * 码里和分享出去的链接(0.15.0):只认运行时的 origin,游戏给的 url 只取路径、查询和 # 后面的部分。
170
+ * 这样 kit 和游戏里都不写域名,单独部署的游戏(workers.dev)扫出来也是它自己
171
+ */
172
+ export function shareLink(url: string, origin: string): string {
173
+ const u = new URL(url, `${origin}/`);
174
+ return `${origin}${u.pathname}${u.search}${u.hash}`;
175
+ }
176
+
177
+ /** 系统分享的文字末尾带上链接(0.15.0):有的 App 只取 text、不取 url */
178
+ export function shareText(text: string, link: string): string {
179
+ return text.includes(link) ? text : `${text} ${link}`;
180
+ }
181
+
182
+ /** 微信内置浏览器(UA 带 MicroMessenger,含企业微信) */
183
+ export function isWeChat(ua: string): boolean {
184
+ return /MicroMessenger/i.test(ua);
185
+ }
186
+
187
+ /** 预览层怎么出(0.15.0):微信里用 data: URL、提示长按、不给"保存图片";别的浏览器照旧 */
188
+ export function previewMode(ua: string): { dataUrl: boolean; tip: string; save: boolean } {
189
+ return isWeChat(ua)
190
+ ? { dataUrl: true, tip: '长按图片 → 发送给朋友,或保存后发朋友圈', save: false }
191
+ : { dataUrl: false, tip: '手机上也可以长按图片保存', save: true };
192
+ }
193
+
194
+ /** 二维码在卡上的框:白底留白 ≥ 4 格(扫码的规范要求),每格整数像素、对齐像素,截图后也清楚 */
195
+ export const QR_BOX = 248;
196
+ export function qrLayout(size: number, box = QR_BOX): { cell: number; pad: number } {
197
+ const cell = Math.max(1, Math.floor(box / (size + 8)));
198
+ return { cell, pad: Math.floor((box - cell * size) / 2) };
199
+ }
200
+
164
201
  // ───────────── 画(只在调用时碰 DOM) ─────────────
165
202
 
166
203
  const FONT = '"PingFang SC","Hiragino Sans GB","Microsoft YaHei","Noto Sans CJK SC","Source Han Sans SC",system-ui,sans-serif';
@@ -211,9 +248,16 @@ function fitFont(ctx: CanvasRenderingContext2D, text: string, weight: string, si
211
248
  return s;
212
249
  }
213
250
 
214
- /** 画一张卡。canvas 不给就新建一个 1080×1350 的 */
215
- export async function drawShareCard(input: ShareCard, canvas?: HTMLCanvasElement): Promise<HTMLCanvasElement> {
251
+ /**
252
+ * 画一张卡。canvas 不给就新建一个 1080×1350 的。link 给了(0.15.0,prepareShare 用 shareLink 算好的)就在右下角画二维码,
253
+ * 太长编不了码就不画
254
+ */
255
+ export async function drawShareCard(input: ShareCard, canvas?: HTMLCanvasElement, link?: string): Promise<HTMLCanvasElement> {
216
256
  const c = normalizeCard(input);
257
+ const qr = link ? qrEncode(link) : null;
258
+ // 二维码的白框:右下角,压在底栏上;名场面和日期往左让
259
+ const qx = CARD_W - 72 - QR_BOX;
260
+ const qy = CARD_H - 40 - QR_BOX - 44;
217
261
  const cv = canvas ?? document.createElement('canvas');
218
262
  cv.width = CARD_W;
219
263
  cv.height = CARD_H;
@@ -302,24 +346,28 @@ export async function drawShareCard(input: ShareCard, canvas?: HTMLCanvasElement
302
346
  if (c.line) {
303
347
  ctx.fillStyle = '#ffffff';
304
348
  ctx.font = `700 50px ${FONT}`;
305
- const lines = wrapLines(`“${c.line}”`, W - 2 * M - 40, (s) => ctx.measureText(s).width, 2);
306
- lines.forEach((l, i) => ctx.fillText(l, W / 2, 1065 + i * 66));
349
+ const lw = qr ? qx - 32 - M : W - 2 * M - 40;
350
+ const lines = wrapLines(`“${c.line}”`, lw, (s) => ctx.measureText(s).width, 2);
351
+ lines.forEach((l, i) => ctx.fillText(l, qr ? M + lw / 2 : W / 2, 1065 + i * 66));
307
352
  }
308
353
 
309
354
  // 底:昵称、称号、日期
310
355
  ctx.fillStyle = 'rgba(0,0,0,0.22)';
311
356
  ctx.fillRect(0, CARD_H - 150, W, 150);
357
+ ctx.font = `600 38px ${FONT}`;
358
+ const dateRight = qr ? qx - 28 : W - M;
359
+ const dateLeft = dateRight - ctx.measureText(cardDate(c.date)).width;
312
360
  ctx.textAlign = 'left';
313
361
  ctx.fillStyle = '#ffffff';
314
362
  if (c.player) {
315
- fitFont(ctx, c.player.name, '800', 50, W * 0.5, 32);
363
+ fitFont(ctx, c.player.name, '800', 50, Math.min(W * 0.5, dateLeft - 24 - M), 32);
316
364
  ctx.fillText(c.player.name, M, CARD_H - 62);
317
365
  if (c.player.badge) {
318
366
  const nw = ctx.measureText(c.player.name).width;
319
367
  ctx.font = `700 34px ${FONT}`;
320
368
  const bw = ctx.measureText(c.player.badge).width + 36;
321
369
  const bx = M + nw + 24;
322
- if (bx + bw < W * 0.72) {
370
+ if (bx + bw < Math.min(W * 0.72, dateLeft - 16)) {
323
371
  round(ctx, bx, CARD_H - 104, bw, 54, 27);
324
372
  ctx.fillStyle = shade(c.accent, 0.75);
325
373
  ctx.fill();
@@ -331,20 +379,39 @@ export async function drawShareCard(input: ShareCard, canvas?: HTMLCanvasElement
331
379
  ctx.textAlign = 'right';
332
380
  ctx.fillStyle = 'rgba(255,255,255,0.8)';
333
381
  ctx.font = `600 38px ${FONT}`;
334
- ctx.fillText(cardDate(c.date), W - M, CARD_H - 62);
382
+ ctx.fillText(cardDate(c.date), dateRight, CARD_H - 62);
383
+
384
+ if (qr) {
385
+ // 纯白底、近黑的格,不叠阴影和渐变:对比度够,手机截图压缩后照样扫得出
386
+ ctx.fillStyle = '#ffffff';
387
+ round(ctx, qx, qy, QR_BOX, QR_BOX + 44, 24);
388
+ ctx.fill();
389
+ const { cell, pad } = qrLayout(qr.size);
390
+ ctx.fillStyle = '#111111';
391
+ for (let y = 0; y < qr.size; y++) {
392
+ for (let x = 0; x < qr.size; x++) if (qr.dark[y]![x]) ctx.fillRect(qx + pad + x * cell, qy + pad + y * cell, cell, cell);
393
+ }
394
+ ctx.fillStyle = '#444444';
395
+ ctx.textAlign = 'center';
396
+ ctx.font = `700 28px ${FONT}`;
397
+ ctx.fillText('扫码来玩', qx + QR_BOX / 2, qy + QR_BOX + 22);
398
+ }
335
399
  return cv;
336
400
  }
337
401
 
338
- /** 画成 PNG */
339
- export async function shareCardBlob(card: ShareCard): Promise<Blob> {
340
- const cv = await drawShareCard(card);
402
+ /** 画成 PNG;link 见 drawShareCard */
403
+ export async function shareCardBlob(card: ShareCard, link?: string): Promise<Blob> {
404
+ const cv = await drawShareCard(card, undefined, link);
341
405
  return new Promise((resolve, reject) => cv.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob failed'))), 'image/png'));
342
406
  }
343
407
 
344
408
  export interface ShareOptions {
345
- /** 分享出去的链接:游戏地址,好友房结算带房间码 */
409
+ /**
410
+ * 分享出去的链接:游戏地址,好友房结算带房间码。只用它的路径、查询和 # 后面的部分,前面一律换成运行时的 location.origin(0.15.0),
411
+ * 写相对路径(`./?room=123456`)或绝对地址都行;卡上的二维码、系统分享、"复制链接"用的都是换过的这一个
412
+ */
346
413
  url: string;
347
- /** 系统分享里的一句话,默认用卡的游戏名和大数字拼 */
414
+ /** 系统分享里的一句话,默认用卡的游戏名和大数字拼;末尾自动带上链接(0.15.0) */
348
415
  text?: string;
349
416
  /** 存图的文件名,默认 share.png */
350
417
  fileName?: string;
@@ -375,10 +442,13 @@ export interface PreparedShare {
375
442
  */
376
443
  export function prepareShare(card: ShareCard, opts: ShareOptions): PreparedShare {
377
444
  const name = opts.fileName ?? 'share.png';
378
- const text = opts.text ?? `${card.game} · ${card.big.label} ${card.big.value}`;
445
+ // 没有 DOM(房间、测试里)时 location 也没有:链接照原样,画卡那一步本来就会失败
446
+ const link = typeof location === 'undefined' ? opts.url : shareLink(opts.url, location.origin);
447
+ const text = shareText(opts.text ?? `${card.game} · ${card.big.label} ${card.big.value}`, link);
448
+ const o: ShareOptions = { ...opts, url: link };
379
449
  let blob: Blob | null = null;
380
450
  let failed: unknown = null;
381
- const ready = shareCardBlob(card).then(
451
+ const ready = shareCardBlob(card, link).then(
382
452
  (b) => {
383
453
  blob = b;
384
454
  },
@@ -388,8 +458,9 @@ export function prepareShare(card: ShareCard, opts: ShareOptions): PreparedShare
388
458
  );
389
459
  const attempt = (b: Blob): Promise<ShareResult> => {
390
460
  const nav = navigator as Navigator & { canShare?: (d: ShareData) => boolean };
391
- if (typeof File === 'function' && typeof nav.share === 'function') {
392
- const data: ShareData = { files: [new File([b], name, { type: 'image/png' })], title: card.game, text, url: opts.url };
461
+ // 微信里不试系统分享(没有,或者有也发不出图),直接出长按的预览
462
+ if (!isWeChat(nav.userAgent) && typeof File === 'function' && typeof nav.share === 'function') {
463
+ const data: ShareData = { files: [new File([b], name, { type: 'image/png' })], title: card.game, text, url: link };
393
464
  if (nav.canShare?.(data)) {
394
465
  // 同步调(这一行之前没有 await):点击的激活还在
395
466
  return nav.share(data).then(
@@ -400,13 +471,13 @@ export function prepareShare(card: ShareCard, opts: ShareOptions): PreparedShare
400
471
  (e: { name?: string }) => {
401
472
  if (e?.name === 'AbortError') return 'cancelled' as const;
402
473
  // 别的错(激活过期、权限、不支持的类型):退回预览
403
- showPreview(b, name, opts);
474
+ showPreview(b, name, o);
404
475
  return 'preview' as const;
405
476
  },
406
477
  );
407
478
  }
408
479
  }
409
- showPreview(b, name, opts);
480
+ showPreview(b, name, o);
410
481
  return Promise.resolve('preview');
411
482
  };
412
483
  return {
@@ -431,27 +502,36 @@ export async function shareCard(card: ShareCard, opts: ShareOptions): Promise<Sh
431
502
  return p.share();
432
503
  }
433
504
 
434
- /** 不支持系统分享时:预览图 + "保存图片""复制链接";点空白处或"关闭"收起 */
505
+ /** 不支持系统分享时:预览图 + "保存图片""复制链接";点空白处或"关闭"收起。微信里见 previewMode */
435
506
  function showPreview(blob: Blob, name: string, opts: ShareOptions): void {
436
507
  document.getElementById(PREVIEW_ID)?.remove();
437
- const url = URL.createObjectURL(blob);
508
+ const mode = previewMode(navigator.userAgent);
509
+ // 微信里不用 blob:(长按存不下来),也就不建对象 URL
510
+ const url = mode.dataUrl ? '' : URL.createObjectURL(blob);
438
511
  const root = document.createElement('div');
439
512
  root.id = PREVIEW_ID;
440
513
  root.setAttribute('role', 'dialog');
441
514
  root.style.cssText = `position:fixed;inset:0;z-index:${opts.zIndex ?? 11000};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`;
442
515
  const close = (): void => {
443
516
  root.remove();
444
- URL.revokeObjectURL(url);
517
+ if (url) URL.revokeObjectURL(url);
445
518
  };
446
519
  root.addEventListener('click', (e) => {
447
520
  if (e.target === root) close();
448
521
  });
449
522
  const img = document.createElement('img');
450
- img.src = url;
523
+ if (url) img.src = url;
524
+ else {
525
+ const reader = new FileReader();
526
+ reader.onload = () => {
527
+ if (typeof reader.result === 'string') img.src = reader.result;
528
+ };
529
+ reader.readAsDataURL(blob);
530
+ }
451
531
  img.alt = '战绩图';
452
532
  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)';
453
533
  const tip = document.createElement('div');
454
- tip.textContent = '手机上也可以长按图片保存';
534
+ tip.textContent = mode.tip;
455
535
  tip.style.cssText = 'font-size:13px;opacity:.75';
456
536
  const row = document.createElement('div');
457
537
  row.style.cssText = 'display:flex;gap:12px;flex-wrap:wrap;justify-content:center';
@@ -486,7 +566,7 @@ function showPreview(blob: Blob, name: string, opts: ShareOptions): void {
486
566
  else done(false);
487
567
  });
488
568
  const shut = btn('关闭', 'share:close', close);
489
- row.appendChild(save);
569
+ if (mode.save) row.appendChild(save);
490
570
  row.appendChild(copy);
491
571
  row.appendChild(shut);
492
572
  root.appendChild(img);