koishi-plugin-vsinger-gacha 1.2.1 → 1.4.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
@@ -62,6 +62,23 @@ npm i @napi-rs/canvas
62
62
  - 想一次性缓存全部:`npm run fetch-assets -- --dir data/vsinger-gacha/cards`
63
63
  - 要动态卡 mp4(1.1GB):下载 Release 的 `assets-videos.tar` 解压到 `cards/`
64
64
 
65
+ ## 统一渲染系统(浅色玻璃手账风)
66
+
67
+ 所有图片统一走 `lib/render/`:插画背景(`assets/wallpapers/`)+ 白色玻璃面板 + 蓝色强调 + 档位描边卡牌。
68
+ 把喜欢的插画丢进 `assets/wallpapers/` 即可随机当背景;留空则用纯浅色底。
69
+
70
+ 所有图片(抽卡结果、图鉴、以后新增的图)都走同一套设计令牌与组件:
71
+
72
+ ```
73
+ lib/render/
74
+ ├── theme.js 设计令牌:颜色/字号/间距/圆角/档位配色
75
+ ├── kit.js 组件:背景、毛玻璃面板、卡牌、角标、标题、页脚、文本截断
76
+ ├── result.js 抽卡结果布局
77
+ └── handbook.js 图鉴布局(复用 kit)
78
+ ```
79
+
80
+ 新增图片时只写布局,样式自动统一。
81
+
65
82
  ## 兼容性
66
83
 
67
84
  | 项 | 说明 |
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
package/lib/handbook.js CHANGED
@@ -9,6 +9,10 @@ const fs = require('node:fs');
9
9
  const path = require('node:path');
10
10
 
11
11
  const FONT_FILE = require('node:path').join(__dirname, '..', 'assets', 'font.ttf');
12
+ const kit = require('./render/kit');
13
+ const theme = require('./render/theme');
14
+ const COLOR = theme.COLOR;
15
+ const ctx_fs = require('node:fs');
12
16
 
13
17
  let Canvas = null;
14
18
  /** 按需加载画布(没装也不影响抽卡/积分) */
@@ -36,7 +40,7 @@ const TH = 200;
36
40
  const LABEL_H = 62;
37
41
  const GAP = 12;
38
42
  const PAD = 24;
39
- const HEAD = 100;
43
+ const HEAD = 128;
40
44
  const POOL_TITLE_H = 34;
41
45
  const SERIES_H = 44;
42
46
  const COLS_PER_COL = 8;
@@ -105,7 +109,7 @@ async function ensureThumbs(game) {
105
109
  return dir;
106
110
  }
107
111
 
108
- async function render({ game, store, uid, displayName, outDir, tagsDir, thumbsDir }) {
112
+ async function render({ game, store, uid, displayName, outDir, tagsDir, thumbsDir, wallpaperDir }) {
109
113
  const Canvas = getCanvas();
110
114
 
111
115
  // ★ 优先用包内自带缩略图(assets/thumbs,423 张约 4.7MB),缺失才现场生成
@@ -199,28 +203,26 @@ async function render({ game, store, uid, displayName, outDir, tagsDir, thumbsDi
199
203
  const canvas = Canvas.createCanvas(W, H);
200
204
  const g = canvas.getContext('2d');
201
205
 
202
- // 背景:液态玻璃(渐变 + 光晕 + 噪点)
203
- const bg = g.createLinearGradient(0, 0, W, H);
204
- bg.addColorStop(0, '#101a2e');
205
- bg.addColorStop(0.45, '#16304f');
206
- bg.addColorStop(1, '#0e1626');
207
- g.fillStyle = bg;
208
- g.fillRect(0, 0, W, H);
209
- const glow = g.createRadialGradient(W * 0.2, H * 0.1, 10, W * 0.2, H * 0.1, Math.max(W, H) * 0.8);
210
- glow.addColorStop(0, 'rgba(120,180,255,0.35)');
211
- glow.addColorStop(1, 'rgba(120,180,255,0)');
212
- g.fillStyle = glow;
213
- g.fillRect(0, 0, W, H);
206
+ // 背景:插画 + 白蒙层(统一渲染系统)
207
+ const wall = wallpaperDir ? (() => {
208
+ try {
209
+ const list = ctx_fs.readdirSync(wallpaperDir).filter((f) => /\.(jpe?g|png|webp)$/i.test(f));
210
+ return list.length ? require('node:path').join(wallpaperDir, list[Math.floor(Math.random() * list.length)]) : null;
211
+ } catch { return null; }
212
+ })() : null;
213
+ await kit.background(g, Canvas, W, H, wall);
214
214
 
215
- // 标题
216
- g.fillStyle = '#ffffff';
217
- g.font = 'bold 44px VSingerSans, sans-serif';
218
- g.fillText('VSinger 收藏集 · 我的图鉴', PAD, 56);
215
+ // 标题(统一组件)
219
216
  const gotN = Object.keys(owned).length;
220
217
  const dupN = Object.values(owned).reduce((a, b) => a + Math.max(0, b - 1), 0);
221
- g.font = '22px VSingerSans, sans-serif';
222
- g.fillStyle = 'rgba(235,240,250,0.85)';
223
- g.fillText(`${displayName} 已收集 ${gotN}/${game.cards.length} 重复 ${dupN} 张`, PAD, 88);
218
+ kit.header(g, {
219
+ x: PAD, y: 34, W: W - PAD * 2,
220
+ title: 'VSinger 收藏集 · 我的图鉴',
221
+ subtitle: `${displayName} 已收集 ${gotN}/${game.cards.length} 重复 ${dupN} 张`,
222
+ badge: `${gotN}/${game.cards.length}`,
223
+ badgeSub: '收集进度',
224
+ progress: game.cards.length ? gotN / game.cards.length : 0,
225
+ });
224
226
 
225
227
  for (let ci = 0; ci < part.length; ci++) {
226
228
  const grp = part[ci];
@@ -229,87 +231,43 @@ async function render({ game, store, uid, displayName, outDir, tagsDir, thumbsDi
229
231
  let prevAct = null;
230
232
  for (const blk of grp) {
231
233
  if (blk.act !== prevAct) {
232
- g.fillStyle = SERIES_COLOR[blk.act] || '#3c5a96';
233
- g.fillRect(x0, y, COLW, SERIES_H - 8);
234
- g.fillStyle = '#fff';
235
- g.font = 'bold 22px VSingerSans, sans-serif';
236
- g.fillText(blk.act, x0 + 12, y + 32);
234
+ kit.panel(g, x0, y, COLW, SERIES_H - 8, { radius: 14, fill: 'rgba(255,255,255,0.88)' });
235
+ g.fillStyle = COLOR.accent;
236
+ kit.roundRect(g, x0 + 12, y + 12, 5, SERIES_H - 32, 3);
237
+ g.fill();
238
+ g.fillStyle = COLOR.text;
239
+ g.font = 'bold 21px VSingerSans, sans-serif';
240
+ g.fillText(blk.act, x0 + 26, y + 22);
237
241
  y += SERIES_H;
238
242
  prevAct = blk.act;
239
243
  }
240
244
  // 池标题(玻璃条)
241
- g.fillStyle = 'rgba(8,14,30,0.85)';
242
- g.fillRect(x0, y, COLW, POOL_TITLE_H - 10);
243
- g.fillStyle = '#ffd682';
244
- g.font = 'bold 22px VSingerSans, sans-serif';
245
- g.fillText(`${blk.pool} ${blk.got}/${blk.arr.length}`, x0 + 8, y + 24);
245
+ kit.panel(g, x0, y, COLW, POOL_TITLE_H - 10, { radius: 12, fill: 'rgba(255,255,255,0.78)' });
246
+ g.fillStyle = COLOR.accent;
247
+ g.font = 'bold 21px VSingerSans, sans-serif';
248
+ g.fillText(`${blk.pool}`, x0 + 12, y + 12);
249
+ g.fillStyle = COLOR.textSub;
250
+ g.font = '18px VSingerSans, sans-serif';
251
+ g.fillText(`${blk.got}/${blk.arr.length}`, x0 + COLW - 82, y + 14);
246
252
 
247
253
  for (let i = 0; i < blk.arr.length; i++) {
248
254
  const c = blk.arr[i];
249
255
  const cx = x0 + (i % COLS_PER_COL) * (TW + GAP);
250
256
  const cy = y + POOL_TITLE_H + Math.floor(i / COLS_PER_COL) * ROWH;
251
257
  const n = owned[c.h] || 0;
252
- const tier = tierOf(c.v);
253
- const tcol = TIER_COLOR[tier];
254
-
255
- // 辉光(拥有)
256
- if (n) {
257
- g.save();
258
- g.shadowColor = tcol;
259
- g.shadowBlur = 22;
260
- g.fillStyle = 'rgba(0,0,0,0.6)';
261
- g.fillRect(cx, cy, TW, TH);
262
- g.restore();
263
- }
264
258
  const img = await load(path.join(thumbs, c.h + '.jpg'));
265
- if (img) {
266
- g.save();
267
- if (!n) g.globalAlpha = 0.62;
268
- g.drawImage(img, cx, cy, TW, TH);
269
- g.restore();
270
- } else {
271
- g.fillStyle = '#1b2233';
272
- g.fillRect(cx, cy, TW, TH);
273
- g.fillStyle = '#7d879c';
274
- g.font = '18px VSingerSans, sans-serif';
275
- g.fillText('缺图', cx + TW / 2 - 18, cy + TH / 2);
276
- }
277
- g.strokeStyle = n ? '#ffffff' : 'rgba(205,210,222,0.8)';
278
- g.lineWidth = 2;
279
- g.strokeRect(cx + 1, cy + 1, TW - 2, TH - 2);
280
-
281
- // 角标
282
- const tagFile = tier === 40 ? tagBig : (tier === 30 ? tagSmall : null);
283
- if (tagFile && fs.existsSync(tagFile)) {
284
- const ti = await load(tagFile);
285
- if (ti) {
286
- const w = TW * 0.46;
287
- g.drawImage(ti, cx + 3, cy + 3, w, (ti.height / ti.width) * w);
288
- }
289
- }
290
- if (laser[c.h] && fs.existsSync(tagHolo)) {
291
- const hi = await load(tagHolo);
292
- if (hi) {
293
- const w = TW * 0.36;
294
- const hh = (hi.height / hi.width) * w;
295
- g.drawImage(hi, cx + (TW - w) / 2, cy + TH - hh - 4, w, hh);
296
- }
297
- }
298
-
299
- // 文字
300
- g.fillStyle = n ? '#ffffff' : 'rgba(200,205,218,0.9)';
301
- g.font = '16px VSingerSans, sans-serif';
302
- g.fillText(fitText(g, c.v.card_name, TW), cx, cy + TH + 20);
303
- g.fillStyle = n ? tcol : 'rgba(225,230,240,0.85)';
304
- g.font = '13px VSingerSans, sans-serif';
305
- g.fillText(fitText(g, `${TIER_NAME[tier]} ${c.v.rate_pct}%`, TW), cx, cy + TH + 40);
306
- if (n > 1) {
307
- g.fillStyle = 'rgba(0,0,0,0.75)';
308
- g.fillRect(cx + TW - 38, cy + 4, 34, 20);
309
- g.fillStyle = '#8cffaa';
310
- g.font = 'bold 15px VSingerSans, sans-serif';
311
- g.fillText(`×${n}`, cx + TW - 35, cy + 19);
312
- }
259
+ await kit.card(g, Canvas, {
260
+ img, x: cx, y: cy, w: TW, h: TH,
261
+ name: c.v.card_name,
262
+ rate: c.v.rate_pct,
263
+ tier: tierOf(c.v),
264
+ laser: Boolean(laser[c.h]),
265
+ count: n,
266
+ owned: n > 0,
267
+ tagsDir,
268
+ nameSize: 15,
269
+ metaSize: 12,
270
+ });
313
271
  }
314
272
  y += blk.h;
315
273
  }
@@ -331,141 +289,10 @@ async function render({ game, store, uid, displayName, outDir, tagsDir, thumbsDi
331
289
  }
332
290
 
333
291
 
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 }) {
292
+ async function renderResult(opts) {
341
293
  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;
294
+ const { render } = require('./render/result');
295
+ return render({ Canvas, ...opts });
469
296
  }
470
297
 
471
298
  module.exports = { render, renderResult };
package/lib/index.js CHANGED
@@ -368,6 +368,11 @@ function apply(ctx, config) {
368
368
  title,
369
369
  outDir: path.join(dataDir, 'out'),
370
370
  tagsDir,
371
+ wallpaperDir: config.wallpaperDir
372
+ ? config.wallpaperDir
373
+ : path.join(__dirname, '..', 'assets', 'wallpapers'),
374
+ badge: String(Math.round(Number(store.pu(uid).points || 0))),
375
+ badgeSub: '当前积分',
371
376
  thumbsDir: path.join(assetsDir, 'thumbs'),
372
377
  });
373
378
  await session.send(fileEl(out, 'image/jpeg', config.imageMode));
@@ -0,0 +1,212 @@
1
+ 'use strict';
2
+ /** 渲染基础组件:浅色玻璃拟态 */
3
+ const path = require('node:path');
4
+ const fs = require('node:fs');
5
+ const { FONT, COLOR, TIER, RADIUS, SPACE, FONT_SIZE, tierOf } = require('./theme');
6
+
7
+ function roundRect(g, x, y, w, h, r) {
8
+ g.beginPath();
9
+ if (g.roundRect) g.roundRect(x, y, w, h, r);
10
+ else {
11
+ g.moveTo(x + r, y);
12
+ g.arcTo(x + w, y, x + w, y + h, r);
13
+ g.arcTo(x + w, y + h, x, y + h, r);
14
+ g.arcTo(x, y + h, x, y, r);
15
+ g.arcTo(x, y, x + w, y, r);
16
+ g.closePath();
17
+ }
18
+ return g;
19
+ }
20
+
21
+ /** 背景:插画铺满 + 模糊 + 白色蒙层 */
22
+ async function background(g, Canvas, W, H, wallpaper) {
23
+ g.fillStyle = '#eef3fa';
24
+ g.fillRect(0, 0, W, H);
25
+
26
+ if (wallpaper && fs.existsSync(wallpaper)) {
27
+ try {
28
+ const img = await Canvas.loadImage(wallpaper);
29
+ // 先用小尺寸绘制再放大 → 廉价模糊
30
+ const SW = Math.max(24, Math.round(W / 18));
31
+ const SH = Math.round(SW * (H / W));
32
+ const small = Canvas.createCanvas(SW, SH);
33
+ const sg = small.getContext('2d');
34
+ sg.drawImage(img, 0, 0, SW, SH);
35
+ g.save();
36
+ g.globalAlpha = 0.72;
37
+ g.drawImage(small, 0, 0, W, H);
38
+ g.restore();
39
+ } catch { /* 忽略 */ }
40
+ }
41
+
42
+ // 白色蒙层(左上更亮,保证文字清晰)
43
+ const grad = g.createLinearGradient(0, 0, W, H);
44
+ grad.addColorStop(0, 'rgba(255,255,255,0.82)');
45
+ grad.addColorStop(0.55, 'rgba(255,255,255,0.68)');
46
+ grad.addColorStop(1, 'rgba(235,244,255,0.78)');
47
+ g.fillStyle = grad;
48
+ g.fillRect(0, 0, W, H);
49
+ }
50
+
51
+ /** 白玻璃面板 */
52
+ function panel(g, x, y, w, h, opts = {}) {
53
+ const r = opts.radius == null ? RADIUS.lg : opts.radius;
54
+ g.save();
55
+ g.shadowColor = COLOR.shadow;
56
+ g.shadowBlur = 18;
57
+ g.shadowOffsetY = 4;
58
+ roundRect(g, x, y, w, h, r);
59
+ g.fillStyle = opts.fill || COLOR.panel;
60
+ g.fill();
61
+ g.restore();
62
+ g.save();
63
+ roundRect(g, x, y, w, h, r);
64
+ g.strokeStyle = opts.stroke || COLOR.panelBorder;
65
+ g.lineWidth = 1.5;
66
+ g.stroke();
67
+ g.restore();
68
+ }
69
+
70
+ /**
71
+ * 头部(仿手账卡):图标 + 标题 + 副标题 + 右侧数字徽章 + 进度条
72
+ */
73
+ function header(g, o) {
74
+ const x = o.x;
75
+ const y = o.y;
76
+ const W = o.W;
77
+
78
+ // 圆形图标
79
+ const R = 22;
80
+ g.save();
81
+ g.beginPath();
82
+ g.arc(x + R, y + R + 4, R, 0, Math.PI * 2);
83
+ g.fillStyle = COLOR.accent;
84
+ g.fill();
85
+ g.fillStyle = '#fff';
86
+ g.font = `bold 22px ${FONT}`;
87
+ g.textBaseline = 'middle';
88
+ g.fillText('♪', x + R - 8, y + R + 4);
89
+ g.restore();
90
+
91
+ g.textBaseline = 'top';
92
+ g.fillStyle = COLOR.text;
93
+ g.font = `bold ${FONT_SIZE.h1}px ${FONT}`;
94
+ g.fillText(fit(g, o.title, `bold ${FONT_SIZE.h1}px ${FONT}`, W - 260), x + R * 2 + 14, y);
95
+
96
+ if (o.subtitle) {
97
+ g.fillStyle = COLOR.textSub;
98
+ g.font = `${FONT_SIZE.body}px ${FONT}`;
99
+ g.fillText(o.subtitle, x + R * 2 + 14, y + 40);
100
+ }
101
+
102
+ // 右侧徽章
103
+ if (o.badge != null) {
104
+ const bw = 118;
105
+ const bh = 58;
106
+ const bx = x + W - bw;
107
+ panel(g, bx, y - 4, bw, bh, { radius: RADIUS.md, fill: 'rgba(255,255,255,0.95)' });
108
+ g.fillStyle = COLOR.accent;
109
+ g.font = `bold 26px ${FONT}`;
110
+ g.fillText(String(o.badge), bx + 14, y + 6);
111
+ g.fillStyle = COLOR.textSub;
112
+ g.font = `12px ${FONT}`;
113
+ g.fillText(String(o.badgeSub || ''), bx + 14, y + 36);
114
+ // 进度条
115
+ const pct = Math.max(0, Math.min(1, Number(o.progress || 0)));
116
+ g.fillStyle = 'rgba(74,144,217,0.15)';
117
+ roundRect(g, bx + 10, y + bh - 6, bw - 20, 5, 3);
118
+ g.fill();
119
+ if (pct > 0) {
120
+ g.fillStyle = COLOR.accent;
121
+ roundRect(g, bx + 10, y + bh - 6, Math.max(4, (bw - 20) * pct), 5, 3);
122
+ g.fill();
123
+ }
124
+ }
125
+ }
126
+
127
+ /** 文本截断 */
128
+ function fit(g, text, font, maxW) {
129
+ const s = String(text == null ? '' : text);
130
+ g.font = font;
131
+ if (g.measureText(s).width <= maxW) return s;
132
+ let t = s;
133
+ while (t.length > 1 && g.measureText(t + '…').width > maxW) t = t.slice(0, -1);
134
+ return t + '…';
135
+ }
136
+
137
+ /** 卡牌组件 */
138
+ async function card(g, Canvas, o) {
139
+ const w = o.w || 170;
140
+ const h = o.h || Math.round(w * 1.43);
141
+ const r = RADIUS.md;
142
+ const tier = o.tier == null ? tierOf({ card_scarcity: o.scarcity }) : o.tier;
143
+ const tc = (TIER[tier] || TIER[10]).color;
144
+
145
+ g.save();
146
+ roundRect(g, o.x, o.y, w, h, r);
147
+ g.clip();
148
+ g.fillStyle = COLOR.cardBg;
149
+ g.fillRect(o.x, o.y, w, h);
150
+ if (o.img) {
151
+ try { g.drawImage(o.img, o.x, o.y, w, h); } catch { /* 忽略 */ }
152
+ }
153
+ if (o.owned === false) {
154
+ g.fillStyle = 'rgba(255,255,255,0.38)';
155
+ g.fillRect(o.x, o.y, w, h);
156
+ }
157
+ g.restore();
158
+
159
+ g.save();
160
+ roundRect(g, o.x + 1, o.y + 1, w - 2, h - 2, r - 1);
161
+ g.strokeStyle = o.owned === false ? 'rgba(150,165,185,0.45)' : tc;
162
+ g.lineWidth = o.owned === false ? 1.5 : 2.5;
163
+ g.stroke();
164
+ g.restore();
165
+
166
+ const tagFile = o.tagsDir ? path.join(o.tagsDir, (TIER[tier] || {}).tag || '') : '';
167
+ if ((TIER[tier] || {}).tag && o.tagsDir && fs.existsSync(tagFile)) {
168
+ try {
169
+ const ti = await Canvas.loadImage(tagFile);
170
+ const tw = w * 0.46;
171
+ g.drawImage(ti, o.x + 4, o.y + 4, tw, (ti.height / ti.width) * tw);
172
+ } catch { /* 忽略 */ }
173
+ }
174
+ if (o.laser && o.tagsDir) {
175
+ const holo = path.join(o.tagsDir, 'tag_holo.png');
176
+ if (fs.existsSync(holo)) {
177
+ try {
178
+ const hi = await Canvas.loadImage(holo);
179
+ const hw = w * 0.36;
180
+ const hh = (hi.height / hi.width) * hw;
181
+ g.drawImage(hi, o.x + (w - hw) / 2, o.y + h - hh - 6, hw, hh);
182
+ } catch { /* 忽略 */ }
183
+ }
184
+ }
185
+ if (o.count > 1) {
186
+ g.fillStyle = 'rgba(31,42,58,0.82)';
187
+ roundRect(g, o.x + w - 46, o.y + 6, 40, 22, 8);
188
+ g.fill();
189
+ g.fillStyle = '#8cffaa';
190
+ g.font = `bold 14px ${FONT}`;
191
+ g.fillText(`×${o.count}`, o.x + w - 40, o.y + 9);
192
+ }
193
+
194
+ g.fillStyle = o.owned === false ? COLOR.textDim : COLOR.text;
195
+ g.font = `bold ${o.nameSize || FONT_SIZE.h3}px ${FONT}`;
196
+ g.fillText(fit(g, o.name, `bold ${o.nameSize || FONT_SIZE.h3}px ${FONT}`, w), o.x, o.y + h + 8);
197
+ if (o.rate != null || o.tierName) {
198
+ g.fillStyle = tc;
199
+ g.font = `${o.metaSize || FONT_SIZE.meta}px ${FONT}`;
200
+ const meta = `${o.tierName || (TIER[tier] || TIER[10]).name}${o.rate != null ? ' ' + o.rate + '%' : ''}`;
201
+ g.fillText(fit(g, meta, `${o.metaSize || FONT_SIZE.meta}px ${FONT}`, w), o.x, o.y + h + 32);
202
+ }
203
+ }
204
+
205
+ /** 页脚 */
206
+ function footer(g, W, H, text) {
207
+ g.fillStyle = COLOR.textDim;
208
+ g.font = `${FONT_SIZE.tiny}px ${FONT}`;
209
+ g.fillText(String(text), SPACE.lg, H - 22);
210
+ }
211
+
212
+ module.exports = { roundRect, background, panel, header, card, footer, fit };
@@ -0,0 +1,95 @@
1
+ 'use strict';
2
+ /** 抽卡结果布局:浅色玻璃手账风 */
3
+ const path = require('node:path');
4
+ const fs = require('node:fs');
5
+ const theme = require('./theme');
6
+ const kit = require('./kit');
7
+
8
+ function pickWallpaper(dir) {
9
+ try {
10
+ const files = fs.readdirSync(dir).filter((f) => /\.(jpe?g|png|webp)$/i.test(f));
11
+ if (!files.length) return null;
12
+ return path.join(dir, files[Math.floor(Math.random() * files.length)]);
13
+ } catch { return null; }
14
+ }
15
+
16
+ async function render(o) {
17
+ const { Canvas, tagsDir, thumbsDir, outDir } = o;
18
+ const results = o.results || [];
19
+ const n = Math.max(1, results.length);
20
+ const perRow = n <= 3 ? n : Math.min(5, n);
21
+ const rows = Math.ceil(n / perRow);
22
+ const cw = n === 1 ? 300 : 170;
23
+ const ch = Math.round(cw * 1.43);
24
+ const label = theme.CARD.label;
25
+ const gap = theme.CARD.gap;
26
+ const pad = theme.SPACE.xl;
27
+ const head = 132;
28
+ const panelPad = 20;
29
+
30
+ const W = Math.max(640, pad * 2 + panelPad * 2 + perRow * cw + (perRow - 1) * gap);
31
+ const H = head + rows * (ch + label + gap) + panelPad * 2 + pad;
32
+
33
+ const canvas = Canvas.createCanvas(W, H);
34
+ const g = canvas.getContext('2d');
35
+
36
+ const wall = o.wallpaperDir ? pickWallpaper(o.wallpaperDir) : null;
37
+ await kit.background(g, Canvas, W, H, wall);
38
+
39
+ kit.header(g, {
40
+ x: pad,
41
+ y: 28,
42
+ W: W - pad * 2,
43
+ title: o.title,
44
+ subtitle: `共 ${n} 张 | 抽卡结果`,
45
+ badge: o.badge != null ? o.badge : '',
46
+ badgeSub: o.badgeSub || '',
47
+ progress: o.progress || 0,
48
+ });
49
+
50
+ // 白色玻璃大面板
51
+ const px = pad;
52
+ const py = head - 12;
53
+ const pw = W - pad * 2;
54
+ const ph = rows * (ch + label + gap) + panelPad * 2 - gap;
55
+ kit.panel(g, px, py, pw, ph, { radius: theme.RADIUS.xl, fill: 'rgba(255,255,255,0.72)' });
56
+
57
+ const rowW = perRow * cw + (perRow - 1) * gap;
58
+ const x0 = px + Math.round((pw - rowW) / 2);
59
+
60
+ for (let i = 0; i < results.length; i++) {
61
+ const r = results[i];
62
+ const hash = r.hash || r.h;
63
+ const col = i % perRow;
64
+ const row = Math.floor(i / perRow);
65
+ const x = x0 + col * (cw + gap);
66
+ const y = py + panelPad + row * (ch + label + gap);
67
+
68
+ let img = null;
69
+ const f = hash ? path.join(thumbsDir, hash + '.jpg') : '';
70
+ if (f && fs.existsSync(f)) {
71
+ try { img = await Canvas.loadImage(f); } catch { img = null; }
72
+ }
73
+
74
+ await kit.card(g, Canvas, {
75
+ img, x, y, w: cw, h: ch,
76
+ name: (r.card && r.card.card_name) || '',
77
+ rate: r.card && r.card.rate_pct,
78
+ tier: theme.tierOf(r.card),
79
+ laser: Boolean(r.laser),
80
+ tagsDir,
81
+ owned: true,
82
+ nameSize: cw >= 260 ? 22 : 17,
83
+ metaSize: cw >= 260 ? 17 : 13,
84
+ });
85
+ }
86
+
87
+ kit.footer(g, W, H, 'VSinger 收藏集 · 天依抽卡手账');
88
+
89
+ fs.mkdirSync(outDir, { recursive: true });
90
+ const out = path.join(outDir, `draw_${Date.now()}.jpg`);
91
+ fs.writeFileSync(out, await canvas.encode('jpeg', 90));
92
+ return out;
93
+ }
94
+
95
+ module.exports = { render };
@@ -0,0 +1,39 @@
1
+ 'use strict';
2
+ /** 设计令牌:浅色玻璃拟态(仿星轨手账) */
3
+ const FONT = 'VSingerSans, sans-serif';
4
+
5
+ const COLOR = {
6
+ // 浅色系
7
+ paper: '#ffffff',
8
+ text: '#1f2a3a',
9
+ textSub: '#5d6b80',
10
+ textDim: '#8b98ab',
11
+ accent: '#4a90d9',
12
+ accentSoft: 'rgba(74,144,217,0.12)',
13
+ panel: 'rgba(255,255,255,0.82)',
14
+ panelBorder: 'rgba(120,160,210,0.28)',
15
+ cardBg: '#f2f5fa',
16
+ cardBorder: 'rgba(120,150,190,0.25)',
17
+ shadow: 'rgba(60,90,130,0.18)',
18
+ // 深色兜底(保留给旧主题)
19
+ bg0: '#0d1524', bg1: '#16263f', bg2: '#111c33', glow: 'rgba(120,180,255,0.28)',
20
+ };
21
+
22
+ const TIER = {
23
+ 40: { name: '大隐藏', color: '#e0607a', tag: 'tag_40.webp' },
24
+ 30: { name: '小隐藏', color: '#e09a3c', tag: 'tag_30.webp' },
25
+ 20: { name: '稀有', color: '#4a90d9', tag: null },
26
+ 10: { name: '普通', color: '#8b98ab', tag: null },
27
+ };
28
+
29
+ const RADIUS = { sm: 10, md: 14, lg: 20, xl: 28 };
30
+ const SPACE = { xs: 8, sm: 12, md: 16, lg: 24, xl: 32 };
31
+ const FONT_SIZE = { h1: 30, h2: 24, h3: 18, body: 16, meta: 14, tiny: 12 };
32
+ const CARD = { w: 170, h: 243, label: 58, gap: 16 };
33
+
34
+ function tierOf(card) {
35
+ const sc = Number((card && card.card_scarcity) || 10);
36
+ return sc === 40 ? 40 : sc === 30 ? 30 : sc === 20 ? 20 : 10;
37
+ }
38
+
39
+ module.exports = { FONT, COLOR, TIER, RADIUS, SPACE, FONT_SIZE, CARD, tierOf };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koishi-plugin-vsinger-gacha",
3
- "version": "1.2.1",
3
+ "version": "1.4.0",
4
4
  "description": "VSinger 收藏集抽卡 / 签到积分 / 图鉴(Koishi 插件,一次性安装)",
5
5
  "main": "lib/index.js",
6
6
  "files": [