koishi-plugin-vsinger-gacha 1.1.4 → 1.2.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/README.md CHANGED
@@ -74,6 +74,7 @@ npm i @napi-rs/canvas
74
74
  | **图鉴** | 需要可选依赖 `@napi-rs/canvas`;没装时其它功能不受影响 |
75
75
  | **字体** | 包内自带中文子集,不依赖系统字体 |
76
76
  | **合并转发** | 配置 `forwardMode: auto / off` |
77
+ | **结果图** | 不支持合并转发的平台(如 QQ 官方群机器人)自动把抽卡结果渲染成**一张图**;`resultImage: auto / always / off` |
77
78
 
78
79
  ## 指令
79
80
 
package/lib/handbook.js CHANGED
@@ -330,4 +330,100 @@ async function render({ game, store, uid, displayName, outDir, tagsDir, thumbsDi
330
330
  return files;
331
331
  }
332
332
 
333
- module.exports = { render };
333
+
334
+ /**
335
+ * 抽卡结果图(不依赖合并转发,任何平台都能发)
336
+ * @param {object} opts
337
+ * @param {Array} opts.results [{h, card, tier, laser}]
338
+ * @param {string} opts.title 标题(如「zb 抽了 10 张(-100 分,剩 233)」)
339
+ */
340
+ async function renderResult({ results, title, outDir, tagsDir, thumbsDir }) {
341
+ const Canvas = getCanvas();
342
+ const fs = require('node:fs');
343
+ const path = require('node:path');
344
+
345
+ const W_CARD = TW;
346
+ const H_CARD = TH;
347
+ const LABEL = 44;
348
+ const GAP = 12;
349
+ const PAD = 24;
350
+ const PER_ROW = Math.min(5, Math.max(1, results.length));
351
+ const rows = Math.max(1, Math.ceil(results.length / PER_ROW));
352
+ const W = PAD * 2 + PER_ROW * W_CARD + (PER_ROW - 1) * GAP;
353
+ const HEAD = 92;
354
+ const H = HEAD + rows * (H_CARD + LABEL + GAP) + PAD;
355
+
356
+ const canvas = Canvas.createCanvas(W, H);
357
+ const g = canvas.getContext('2d');
358
+ const bg = g.createLinearGradient(0, 0, W, H);
359
+ bg.addColorStop(0, '#101a2e');
360
+ bg.addColorStop(1, '#16304f');
361
+ g.fillStyle = bg;
362
+ g.fillRect(0, 0, W, H);
363
+
364
+ g.fillStyle = '#ffffff';
365
+ g.font = 'bold 30px VSingerSans, sans-serif';
366
+ g.textBaseline = 'top';
367
+ g.fillText(String(title).slice(0, 40), PAD, 26);
368
+
369
+ const TIER_NAME = { 40: '大隐藏', 30: '小隐藏', 20: '稀有', 10: '普通' };
370
+ const TIER_COLOR = { 40: '#ff6078', 30: '#ffb050', 20: '#96beff', 10: '#bec4d2' };
371
+
372
+ for (let i = 0; i < results.length; i++) {
373
+ const r = results[i];
374
+ const col = i % PER_ROW;
375
+ const row = Math.floor(i / PER_ROW);
376
+ const x = PAD + col * (W_CARD + GAP);
377
+ const y = HEAD + row * (H_CARD + LABEL + GAP);
378
+
379
+ g.fillStyle = 'rgba(0,0,0,0.45)';
380
+ g.fillRect(x, y, W_CARD, H_CARD);
381
+
382
+ const thumb = path.join(thumbsDir, r.h + '.jpg');
383
+ if (fs.existsSync(thumb)) {
384
+ try {
385
+ const img = await Canvas.loadImage(thumb);
386
+ g.drawImage(img, x, y, W_CARD, H_CARD);
387
+ } catch { /* 忽略 */ }
388
+ }
389
+ const tier = Number(r.card && r.card.card_scarcity) === 40 ? 40
390
+ : Number(r.card && r.card.card_scarcity) === 30 ? 30
391
+ : Number(r.card && r.card.card_scarcity) === 20 ? 20 : 10;
392
+
393
+ // 角标
394
+ const tag = tier === 40 ? path.join(tagsDir, 'tag_40.webp') : tier === 30 ? path.join(tagsDir, 'tag_30.webp') : null;
395
+ if (tag && fs.existsSync(tag)) {
396
+ try {
397
+ const ti = await Canvas.loadImage(tag);
398
+ const tw = W_CARD * 0.46;
399
+ g.drawImage(ti, x + 3, y + 3, tw, (ti.height / ti.width) * tw);
400
+ } catch { /* 忽略 */ }
401
+ }
402
+ if (r.laser) {
403
+ const holo = path.join(tagsDir, 'tag_holo.png');
404
+ if (fs.existsSync(holo)) {
405
+ try {
406
+ const hi = await Canvas.loadImage(holo);
407
+ const hw = W_CARD * 0.36;
408
+ const hh = (hi.height / hi.width) * hw;
409
+ g.drawImage(hi, x + (W_CARD - hw) / 2, y + H_CARD - hh - 4, hw, hh);
410
+ } catch { /* 忽略 */ }
411
+ }
412
+ }
413
+
414
+ g.fillStyle = '#ffffff';
415
+ g.font = '16px VSingerSans, sans-serif';
416
+ const name = String((r.card && r.card.card_name) || '').slice(0, 12);
417
+ g.fillText(name, x, y + H_CARD + 6);
418
+ g.fillStyle = TIER_COLOR[tier];
419
+ g.font = '13px VSingerSans, sans-serif';
420
+ g.fillText(`${TIER_NAME[tier]} ${r.card && r.card.rate_pct != null ? r.card.rate_pct + '%' : ''}`, x, y + H_CARD + 26);
421
+ }
422
+
423
+ fs.mkdirSync(outDir, { recursive: true });
424
+ const out = path.join(outDir, `draw_${Date.now()}.jpg`);
425
+ fs.writeFileSync(out, await canvas.encode('jpeg', 88));
426
+ return out;
427
+ }
428
+
429
+ module.exports = { render, renderResult };
package/lib/index.js CHANGED
@@ -31,6 +31,8 @@ const Config = Schema.intersect([
31
31
  .description('可选:openid ↔ QQ 映射表(如 /root/qqbot/data/id-map.json),用于统一用户标识'),
32
32
  debug: Schema.boolean().default(false)
33
33
  .description('调试模式:把收到的每条消息打到日志里'),
34
+ resultImage: Schema.union(['auto', 'always', 'off']).default('auto')
35
+ .description('抽卡结果图:auto=不能合并转发时渲染成一张图;always=总是;off=纯文本'),
34
36
  forwardMode: Schema.union(['auto', 'off']).default('auto')
35
37
  .description('合并转发:auto=支持就转发、不支持自动退回普通消息;off=永远普通消息'),
36
38
  imageMode: Schema.union(['auto', 'buffer', 'file']).default('auto')
@@ -325,7 +327,7 @@ function apply(ctx, config) {
325
327
  nodes.push({ type: 'node', data: { name: botName(session), uin: botUin(session), content } });
326
328
  });
327
329
  }
328
- const ok = config.forwardMode !== 'off' && nodes.length && await sendForward(session, nodes);
330
+ const ok = config.resultImage !== 'always' && config.forwardMode !== 'off' && nodes.length && await sendForward(session, nodes);
329
331
  if (!ok && nodes.length) logger.warn('合并转发不可用(%s),退回普通发送', sendForward.lastError || 'unknown');
330
332
 
331
333
  // ★ 动态卡:单独发 mp4(OneBot 要求视频独占一条消息)
@@ -341,14 +343,33 @@ function apply(ctx, config) {
341
343
  }
342
344
  }
343
345
  if (!ok) {
344
- const lines = results.map((r, i) => {
345
- const rate = r.card.rate_pct == null ? '' : ` ${r.card.rate_pct}%`;
346
- return `${i + 1}. ${r.tier}|${r.card.card_name}${rate}${r.laser ? '|✦镭射版' : ''}${r.card.video ? '|动态' : ''}`;
347
- });
348
- await session.send(title + '\n' + lines.join('\n'));
349
- for (const f of pics) {
350
- if (!f) continue;
351
- try { await session.send(fileEl(f, null, config.imageMode)); } catch (e) { logger.warn('发卡图失败:%s', e.message); }
346
+ // ★ 优先渲染成一张结果图(官方群机器人等不支持合并转发的平台)
347
+ let sentImage = false;
348
+ if (config.resultImage !== 'off' && handbook) {
349
+ try {
350
+ const out = await handbook.renderResult({
351
+ results,
352
+ title,
353
+ outDir: path.join(dataDir, 'out'),
354
+ tagsDir,
355
+ thumbsDir: path.join(assetsDir, 'thumbs'),
356
+ });
357
+ await session.send(fileEl(out, 'image/jpeg', config.imageMode));
358
+ sentImage = true;
359
+ } catch (e) {
360
+ logger.warn('结果图渲染失败:%s', e.message);
361
+ }
362
+ }
363
+ if (!sentImage) {
364
+ const lines = results.map((r, i) => {
365
+ const rate = r.card.rate_pct == null ? '' : ` ${r.card.rate_pct}%`;
366
+ return `${i + 1}. ${r.tier}|${r.card.card_name}${rate}${r.laser ? '|✦镭射版' : ''}${r.card.video ? '|动态' : ''}`;
367
+ });
368
+ await session.send(title + '\n' + lines.join('\n'));
369
+ for (const f of pics) {
370
+ if (!f) continue;
371
+ try { await session.send(fileEl(f, null, config.imageMode)); } catch (e) { logger.warn('发卡图失败:%s', e.message); }
372
+ }
352
373
  }
353
374
  }
354
375
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koishi-plugin-vsinger-gacha",
3
- "version": "1.1.4",
3
+ "version": "1.2.0",
4
4
  "description": "VSinger 收藏集抽卡 / 签到积分 / 图鉴(Koishi 插件,一次性安装)",
5
5
  "main": "lib/index.js",
6
6
  "files": [