koishi-plugin-vsinger-gacha 1.1.4 → 1.2.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/README.md +1 -0
- package/lib/handbook.js +139 -1
- package/lib/index.js +46 -9
- package/package.json +1 -1
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,142 @@ async function render({ game, store, uid, displayName, outDir, tagsDir, thumbsDi
|
|
|
330
330
|
return files;
|
|
331
331
|
}
|
|
332
332
|
|
|
333
|
-
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* 抽卡结果图(不依赖合并转发,任何平台都能发)
|
|
336
|
+
* @param {object} opts
|
|
337
|
+
* @param {Array} opts.results [{hash|h, card, tier, laser}]
|
|
338
|
+
* @param {string} opts.title
|
|
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 TIER_NAME = { 40: '大隐藏', 30: '小隐藏', 20: '稀有', 10: '普通' };
|
|
346
|
+
const TIER_COLOR = { 40: '#ff6d84', 30: '#ffb44d', 20: '#8fc0ff', 10: '#c3cad8' };
|
|
347
|
+
const tierOf = (card) => {
|
|
348
|
+
const sc = Number((card && card.card_scarcity) || 10);
|
|
349
|
+
return sc === 40 ? 40 : sc === 30 ? 30 : sc === 20 ? 20 : 10;
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
const n = Math.max(1, results.length);
|
|
353
|
+
const PER_ROW = n <= 3 ? n : Math.min(5, n);
|
|
354
|
+
const rows = Math.ceil(n / PER_ROW);
|
|
355
|
+
const CW = n === 1 ? 300 : 170; // 卡宽(1 张时放大)
|
|
356
|
+
const CH = Math.round(CW * 1.43);
|
|
357
|
+
const LABEL = 58;
|
|
358
|
+
const GAP = 16;
|
|
359
|
+
const PAD = 32;
|
|
360
|
+
const HEAD = 108;
|
|
361
|
+
const W = Math.max(520, PAD * 2 + PER_ROW * CW + (PER_ROW - 1) * GAP);
|
|
362
|
+
const H = HEAD + rows * (CH + LABEL + GAP) + PAD;
|
|
363
|
+
|
|
364
|
+
const canvas = Canvas.createCanvas(W, H);
|
|
365
|
+
const g = canvas.getContext('2d');
|
|
366
|
+
g.textBaseline = 'top';
|
|
367
|
+
|
|
368
|
+
// 背景:深蓝渐变 + 顶部柔光
|
|
369
|
+
const bg = g.createLinearGradient(0, 0, W * 0.6, H);
|
|
370
|
+
bg.addColorStop(0, '#111c33');
|
|
371
|
+
bg.addColorStop(0.55, '#16263f');
|
|
372
|
+
bg.addColorStop(1, '#0d1524');
|
|
373
|
+
g.fillStyle = bg;
|
|
374
|
+
g.fillRect(0, 0, W, H);
|
|
375
|
+
const glow = g.createRadialGradient(W * 0.25, 0, 10, W * 0.25, 0, Math.max(W, H) * 0.75);
|
|
376
|
+
glow.addColorStop(0, 'rgba(120,180,255,0.28)');
|
|
377
|
+
glow.addColorStop(1, 'rgba(120,180,255,0)');
|
|
378
|
+
g.fillStyle = glow;
|
|
379
|
+
g.fillRect(0, 0, W, H);
|
|
380
|
+
|
|
381
|
+
// 标题
|
|
382
|
+
g.fillStyle = '#ffffff';
|
|
383
|
+
g.font = 'bold 32px VSingerSans, sans-serif';
|
|
384
|
+
const t = String(title || '').replace(/\s+/g, ' ');
|
|
385
|
+
const maxTitle = W - PAD * 2;
|
|
386
|
+
let tt = t;
|
|
387
|
+
while (tt.length > 2 && g.measureText(tt).width > maxTitle) tt = tt.slice(0, -1);
|
|
388
|
+
if (tt !== t) tt = tt.slice(0, -1) + '…';
|
|
389
|
+
g.fillText(tt, PAD, 30);
|
|
390
|
+
g.fillStyle = 'rgba(200,215,240,0.65)';
|
|
391
|
+
g.font = '18px VSingerSans, sans-serif';
|
|
392
|
+
g.fillText(`共 ${n} 张 | 抽卡结果`, PAD, 72);
|
|
393
|
+
|
|
394
|
+
const rowW = PER_ROW * CW + (PER_ROW - 1) * GAP;
|
|
395
|
+
const x0 = Math.round((W - rowW) / 2);
|
|
396
|
+
|
|
397
|
+
for (let i = 0; i < results.length; i++) {
|
|
398
|
+
const r = results[i];
|
|
399
|
+
const hash = r.hash || r.h;
|
|
400
|
+
const col = i % PER_ROW;
|
|
401
|
+
const row = Math.floor(i / PER_ROW);
|
|
402
|
+
const x = x0 + col * (CW + GAP);
|
|
403
|
+
const y = HEAD + row * (CH + LABEL + GAP);
|
|
404
|
+
const tier = tierOf(r.card);
|
|
405
|
+
|
|
406
|
+
// 卡片底
|
|
407
|
+
g.save();
|
|
408
|
+
g.beginPath();
|
|
409
|
+
g.roundRect(x, y, CW, CH, 14);
|
|
410
|
+
g.clip();
|
|
411
|
+
g.fillStyle = '#1b2436';
|
|
412
|
+
g.fillRect(x, y, CW, CH);
|
|
413
|
+
const thumb = hash ? path.join(thumbsDir, hash + '.jpg') : '';
|
|
414
|
+
if (thumb && fs.existsSync(thumb)) {
|
|
415
|
+
try {
|
|
416
|
+
const img = await Canvas.loadImage(thumb);
|
|
417
|
+
g.drawImage(img, x, y, CW, CH);
|
|
418
|
+
} catch { /* 忽略 */ }
|
|
419
|
+
}
|
|
420
|
+
g.restore();
|
|
421
|
+
|
|
422
|
+
// 边框(按档位上色)
|
|
423
|
+
g.save();
|
|
424
|
+
g.beginPath();
|
|
425
|
+
g.roundRect(x + 1, y + 1, CW - 2, CH - 2, 13);
|
|
426
|
+
g.strokeStyle = TIER_COLOR[tier];
|
|
427
|
+
g.lineWidth = 3;
|
|
428
|
+
g.stroke();
|
|
429
|
+
g.restore();
|
|
430
|
+
|
|
431
|
+
// 档位角标
|
|
432
|
+
const tag = tier === 40 ? path.join(tagsDir, 'tag_40.webp') : tier === 30 ? path.join(tagsDir, 'tag_30.webp') : null;
|
|
433
|
+
if (tag && fs.existsSync(tag)) {
|
|
434
|
+
try {
|
|
435
|
+
const ti = await Canvas.loadImage(tag);
|
|
436
|
+
const tw = CW * 0.46;
|
|
437
|
+
g.drawImage(ti, x + 4, y + 4, tw, (ti.height / ti.width) * tw);
|
|
438
|
+
} catch { /* 忽略 */ }
|
|
439
|
+
}
|
|
440
|
+
if (r.laser) {
|
|
441
|
+
const holo = path.join(tagsDir, 'tag_holo.png');
|
|
442
|
+
if (fs.existsSync(holo)) {
|
|
443
|
+
try {
|
|
444
|
+
const hi = await Canvas.loadImage(holo);
|
|
445
|
+
const hw = CW * 0.36;
|
|
446
|
+
const hh = (hi.height / hi.width) * hw;
|
|
447
|
+
g.drawImage(hi, x + (CW - hw) / 2, y + CH - hh - 6, hw, hh);
|
|
448
|
+
} catch { /* 忽略 */ }
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// 名称 + 档位/概率
|
|
453
|
+
g.fillStyle = '#ffffff';
|
|
454
|
+
g.font = 'bold 19px VSingerSans, sans-serif';
|
|
455
|
+
const name = String((r.card && r.card.card_name) || '');
|
|
456
|
+
let nn = name;
|
|
457
|
+
while (nn.length > 1 && g.measureText(nn).width > CW) nn = nn.slice(0, -1);
|
|
458
|
+
g.fillText(nn === name ? nn : nn.slice(0, -1) + '…', x, y + CH + 8);
|
|
459
|
+
g.fillStyle = TIER_COLOR[tier];
|
|
460
|
+
g.font = '15px VSingerSans, sans-serif';
|
|
461
|
+
const rate = r.card && r.card.rate_pct != null ? ` ${r.card.rate_pct}%` : '';
|
|
462
|
+
g.fillText(`${TIER_NAME[tier]}${rate}`, x, y + CH + 34);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
466
|
+
const out = path.join(outDir, `draw_${Date.now()}.jpg`);
|
|
467
|
+
fs.writeFileSync(out, await canvas.encode('jpeg', 90));
|
|
468
|
+
return out;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
module.exports = { render, renderResult };
|
package/lib/index.js
CHANGED
|
@@ -29,8 +29,12 @@ const Config = Schema.intersect([
|
|
|
29
29
|
.description('普卡出镭射版的概率'),
|
|
30
30
|
idMapFile: Schema.string().default('')
|
|
31
31
|
.description('可选:openid ↔ QQ 映射表(如 /root/qqbot/data/id-map.json),用于统一用户标识'),
|
|
32
|
+
ignoreAtOthers: Schema.boolean().default(true)
|
|
33
|
+
.description('消息里 @ 的是别的机器人/别人时,本插件不响应(避免同群两个机器人重复回复)'),
|
|
32
34
|
debug: Schema.boolean().default(false)
|
|
33
35
|
.description('调试模式:把收到的每条消息打到日志里'),
|
|
36
|
+
resultImage: Schema.union(['auto', 'always', 'off']).default('auto')
|
|
37
|
+
.description('抽卡结果图:auto=不能合并转发时渲染成一张图;always=总是;off=纯文本'),
|
|
34
38
|
forwardMode: Schema.union(['auto', 'off']).default('auto')
|
|
35
39
|
.description('合并转发:auto=支持就转发、不支持自动退回普通消息;off=永远普通消息'),
|
|
36
40
|
imageMode: Schema.union(['auto', 'buffer', 'file']).default('auto')
|
|
@@ -220,6 +224,20 @@ function apply(ctx, config) {
|
|
|
220
224
|
logger.info('抽卡数据就绪:%d 张卡 / %d 个奖池(卡图齐全)', game.cards.length, game.pools.size);
|
|
221
225
|
}
|
|
222
226
|
|
|
227
|
+
// ★ 同群多机器人时:消息 @ 的不是自己 → 不处理(避免重复回复)
|
|
228
|
+
if (config.ignoreAtOthers !== false) {
|
|
229
|
+
ctx.middleware((session, next) => {
|
|
230
|
+
try {
|
|
231
|
+
const els = (session.event && session.event.message && session.event.message.elements) || [];
|
|
232
|
+
const ats = els.filter((e) => e && e.type === 'at').map((e) => String(e.attrs && e.attrs.id || ''));
|
|
233
|
+
if (ats.length && !ats.includes('all') && !ats.includes(String(session.selfId || ''))) {
|
|
234
|
+
return; // 拦截:这条是给别的机器人的
|
|
235
|
+
}
|
|
236
|
+
} catch { /* 忽略 */ }
|
|
237
|
+
return next();
|
|
238
|
+
}, true);
|
|
239
|
+
}
|
|
240
|
+
|
|
223
241
|
if (config.debug) {
|
|
224
242
|
ctx.middleware((session, next) => {
|
|
225
243
|
try {
|
|
@@ -325,7 +343,7 @@ function apply(ctx, config) {
|
|
|
325
343
|
nodes.push({ type: 'node', data: { name: botName(session), uin: botUin(session), content } });
|
|
326
344
|
});
|
|
327
345
|
}
|
|
328
|
-
const ok = config.forwardMode !== 'off' && nodes.length && await sendForward(session, nodes);
|
|
346
|
+
const ok = config.resultImage !== 'always' && config.forwardMode !== 'off' && nodes.length && await sendForward(session, nodes);
|
|
329
347
|
if (!ok && nodes.length) logger.warn('合并转发不可用(%s),退回普通发送', sendForward.lastError || 'unknown');
|
|
330
348
|
|
|
331
349
|
// ★ 动态卡:单独发 mp4(OneBot 要求视频独占一条消息)
|
|
@@ -341,14 +359,33 @@ function apply(ctx, config) {
|
|
|
341
359
|
}
|
|
342
360
|
}
|
|
343
361
|
if (!ok) {
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
362
|
+
// ★ 优先渲染成一张结果图(官方群机器人等不支持合并转发的平台)
|
|
363
|
+
let sentImage = false;
|
|
364
|
+
if (config.resultImage !== 'off' && handbook) {
|
|
365
|
+
try {
|
|
366
|
+
const out = await handbook.renderResult({
|
|
367
|
+
results,
|
|
368
|
+
title,
|
|
369
|
+
outDir: path.join(dataDir, 'out'),
|
|
370
|
+
tagsDir,
|
|
371
|
+
thumbsDir: path.join(assetsDir, 'thumbs'),
|
|
372
|
+
});
|
|
373
|
+
await session.send(fileEl(out, 'image/jpeg', config.imageMode));
|
|
374
|
+
sentImage = true;
|
|
375
|
+
} catch (e) {
|
|
376
|
+
logger.warn('结果图渲染失败:%s', e.message);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
if (!sentImage) {
|
|
380
|
+
const lines = results.map((r, i) => {
|
|
381
|
+
const rate = r.card.rate_pct == null ? '' : ` ${r.card.rate_pct}%`;
|
|
382
|
+
return `${i + 1}. ${r.tier}|${r.card.card_name}${rate}${r.laser ? '|✦镭射版' : ''}${r.card.video ? '|动态' : ''}`;
|
|
383
|
+
});
|
|
384
|
+
await session.send(title + '\n' + lines.join('\n'));
|
|
385
|
+
for (const f of pics) {
|
|
386
|
+
if (!f) continue;
|
|
387
|
+
try { await session.send(fileEl(f, null, config.imageMode)); } catch (e) { logger.warn('发卡图失败:%s', e.message); }
|
|
388
|
+
}
|
|
352
389
|
}
|
|
353
390
|
}
|
|
354
391
|
return;
|