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 +17 -0
- package/assets/wallpapers/w01.jpg +0 -0
- package/assets/wallpapers/w02.jpg +0 -0
- package/assets/wallpapers/w03.jpg +0 -0
- package/assets/wallpapers/w04.jpg +0 -0
- package/assets/wallpapers/w05.jpg +0 -0
- package/assets/wallpapers/w06.jpg +0 -0
- package/assets/wallpapers/w07.jpg +0 -0
- package/assets/wallpapers/w08.jpg +0 -0
- package/assets/wallpapers/w09.jpg +0 -0
- package/assets/wallpapers/w10.jpg +0 -0
- package/lib/handbook.js +52 -225
- package/lib/index.js +5 -0
- package/lib/render/kit.js +212 -0
- package/lib/render/result.js +95 -0
- package/lib/render/theme.js +39 -0
- package/package.json +1 -1
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 =
|
|
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
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
|
222
|
-
|
|
223
|
-
|
|
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
|
|
233
|
-
g.
|
|
234
|
-
g
|
|
235
|
-
g.
|
|
236
|
-
g.
|
|
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
|
|
242
|
-
g.
|
|
243
|
-
g.
|
|
244
|
-
g.
|
|
245
|
-
g.
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
|
343
|
-
|
|
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 };
|