koishi-plugin-vsinger-gacha 1.3.0 → 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 +4 -1
- 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 +36 -31
- package/lib/index.js +5 -0
- package/lib/render/kit.js +93 -47
- package/lib/render/result.js +39 -21
- package/lib/render/theme.js +21 -22
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -62,7 +62,10 @@ 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
|
-
##
|
|
65
|
+
## 统一渲染系统(浅色玻璃手账风)
|
|
66
|
+
|
|
67
|
+
所有图片统一走 `lib/render/`:插画背景(`assets/wallpapers/`)+ 白色玻璃面板 + 蓝色强调 + 档位描边卡牌。
|
|
68
|
+
把喜欢的插画丢进 `assets/wallpapers/` 即可随机当背景;留空则用纯浅色底。
|
|
66
69
|
|
|
67
70
|
所有图片(抽卡结果、图鉴、以后新增的图)都走同一套设计令牌与组件:
|
|
68
71
|
|
|
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
|
@@ -10,6 +10,9 @@ const path = require('node:path');
|
|
|
10
10
|
|
|
11
11
|
const FONT_FILE = require('node:path').join(__dirname, '..', 'assets', 'font.ttf');
|
|
12
12
|
const kit = require('./render/kit');
|
|
13
|
+
const theme = require('./render/theme');
|
|
14
|
+
const COLOR = theme.COLOR;
|
|
15
|
+
const ctx_fs = require('node:fs');
|
|
13
16
|
|
|
14
17
|
let Canvas = null;
|
|
15
18
|
/** 按需加载画布(没装也不影响抽卡/积分) */
|
|
@@ -37,7 +40,7 @@ const TH = 200;
|
|
|
37
40
|
const LABEL_H = 62;
|
|
38
41
|
const GAP = 12;
|
|
39
42
|
const PAD = 24;
|
|
40
|
-
const HEAD =
|
|
43
|
+
const HEAD = 128;
|
|
41
44
|
const POOL_TITLE_H = 34;
|
|
42
45
|
const SERIES_H = 44;
|
|
43
46
|
const COLS_PER_COL = 8;
|
|
@@ -106,7 +109,7 @@ async function ensureThumbs(game) {
|
|
|
106
109
|
return dir;
|
|
107
110
|
}
|
|
108
111
|
|
|
109
|
-
async function render({ game, store, uid, displayName, outDir, tagsDir, thumbsDir }) {
|
|
112
|
+
async function render({ game, store, uid, displayName, outDir, tagsDir, thumbsDir, wallpaperDir }) {
|
|
110
113
|
const Canvas = getCanvas();
|
|
111
114
|
|
|
112
115
|
// ★ 优先用包内自带缩略图(assets/thumbs,423 张约 4.7MB),缺失才现场生成
|
|
@@ -200,28 +203,26 @@ async function render({ game, store, uid, displayName, outDir, tagsDir, thumbsDi
|
|
|
200
203
|
const canvas = Canvas.createCanvas(W, H);
|
|
201
204
|
const g = canvas.getContext('2d');
|
|
202
205
|
|
|
203
|
-
//
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
glow.addColorStop(0, 'rgba(120,180,255,0.35)');
|
|
212
|
-
glow.addColorStop(1, 'rgba(120,180,255,0)');
|
|
213
|
-
g.fillStyle = glow;
|
|
214
|
-
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);
|
|
215
214
|
|
|
216
|
-
//
|
|
217
|
-
g.fillStyle = '#ffffff';
|
|
218
|
-
g.font = 'bold 44px VSingerSans, sans-serif';
|
|
219
|
-
g.fillText('VSinger 收藏集 · 我的图鉴', PAD, 56);
|
|
215
|
+
// 标题(统一组件)
|
|
220
216
|
const gotN = Object.keys(owned).length;
|
|
221
217
|
const dupN = Object.values(owned).reduce((a, b) => a + Math.max(0, b - 1), 0);
|
|
222
|
-
g
|
|
223
|
-
|
|
224
|
-
|
|
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
|
+
});
|
|
225
226
|
|
|
226
227
|
for (let ci = 0; ci < part.length; ci++) {
|
|
227
228
|
const grp = part[ci];
|
|
@@ -230,20 +231,24 @@ async function render({ game, store, uid, displayName, outDir, tagsDir, thumbsDi
|
|
|
230
231
|
let prevAct = null;
|
|
231
232
|
for (const blk of grp) {
|
|
232
233
|
if (blk.act !== prevAct) {
|
|
233
|
-
g
|
|
234
|
-
g.
|
|
235
|
-
g
|
|
236
|
-
g.
|
|
237
|
-
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);
|
|
238
241
|
y += SERIES_H;
|
|
239
242
|
prevAct = blk.act;
|
|
240
243
|
}
|
|
241
244
|
// 池标题(玻璃条)
|
|
242
|
-
g
|
|
243
|
-
g.
|
|
244
|
-
g.
|
|
245
|
-
g.
|
|
246
|
-
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);
|
|
247
252
|
|
|
248
253
|
for (let i = 0; i < blk.arr.length; i++) {
|
|
249
254
|
const c = blk.arr[i];
|
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));
|
package/lib/render/kit.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
/**
|
|
2
|
+
/** 渲染基础组件:浅色玻璃拟态 */
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const fs = require('node:fs');
|
|
3
5
|
const { FONT, COLOR, TIER, RADIUS, SPACE, FONT_SIZE, tierOf } = require('./theme');
|
|
4
6
|
|
|
5
7
|
function roundRect(g, x, y, w, h, r) {
|
|
6
8
|
g.beginPath();
|
|
7
|
-
if (g.roundRect)
|
|
8
|
-
|
|
9
|
-
} else {
|
|
9
|
+
if (g.roundRect) g.roundRect(x, y, w, h, r);
|
|
10
|
+
else {
|
|
10
11
|
g.moveTo(x + r, y);
|
|
11
12
|
g.arcTo(x + w, y, x + w, y + h, r);
|
|
12
13
|
g.arcTo(x + w, y + h, x, y + h, r);
|
|
@@ -17,53 +18,109 @@ function roundRect(g, x, y, w, h, r) {
|
|
|
17
18
|
return g;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
/**
|
|
21
|
-
function background(g, W, H) {
|
|
22
|
-
|
|
23
|
-
bg.addColorStop(0, COLOR.bg2);
|
|
24
|
-
bg.addColorStop(0.55, COLOR.bg1);
|
|
25
|
-
bg.addColorStop(1, COLOR.bg0);
|
|
26
|
-
g.fillStyle = bg;
|
|
21
|
+
/** 背景:插画铺满 + 模糊 + 白色蒙层 */
|
|
22
|
+
async function background(g, Canvas, W, H, wallpaper) {
|
|
23
|
+
g.fillStyle = '#eef3fa';
|
|
27
24
|
g.fillRect(0, 0, W, H);
|
|
28
25
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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;
|
|
33
48
|
g.fillRect(0, 0, W, H);
|
|
34
49
|
}
|
|
35
50
|
|
|
36
|
-
/**
|
|
51
|
+
/** 白玻璃面板 */
|
|
37
52
|
function panel(g, x, y, w, h, opts = {}) {
|
|
38
53
|
const r = opts.radius == null ? RADIUS.lg : opts.radius;
|
|
39
54
|
g.save();
|
|
55
|
+
g.shadowColor = COLOR.shadow;
|
|
56
|
+
g.shadowBlur = 18;
|
|
57
|
+
g.shadowOffsetY = 4;
|
|
40
58
|
roundRect(g, x, y, w, h, r);
|
|
41
59
|
g.fillStyle = opts.fill || COLOR.panel;
|
|
42
60
|
g.fill();
|
|
61
|
+
g.restore();
|
|
62
|
+
g.save();
|
|
63
|
+
roundRect(g, x, y, w, h, r);
|
|
43
64
|
g.strokeStyle = opts.stroke || COLOR.panelBorder;
|
|
44
|
-
g.lineWidth =
|
|
65
|
+
g.lineWidth = 1.5;
|
|
45
66
|
g.stroke();
|
|
46
67
|
g.restore();
|
|
47
68
|
}
|
|
48
69
|
|
|
49
|
-
/**
|
|
50
|
-
|
|
51
|
-
|
|
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);
|
|
52
83
|
g.fillStyle = COLOR.accent;
|
|
53
|
-
roundRect(g, x, y + 4, 6, 34, 3);
|
|
54
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();
|
|
55
90
|
|
|
91
|
+
g.textBaseline = 'top';
|
|
56
92
|
g.fillStyle = COLOR.text;
|
|
57
93
|
g.font = `bold ${FONT_SIZE.h1}px ${FONT}`;
|
|
58
|
-
|
|
59
|
-
let t = String(title || '');
|
|
60
|
-
while (t.length > 2 && g.measureText(t).width > maxW) t = t.slice(0, -1);
|
|
61
|
-
g.fillText(t.length < String(title).length ? t.slice(0, -1) + '…' : t, x + 16, y);
|
|
94
|
+
g.fillText(fit(g, o.title, `bold ${FONT_SIZE.h1}px ${FONT}`, W - 260), x + R * 2 + 14, y);
|
|
62
95
|
|
|
63
|
-
if (subtitle) {
|
|
96
|
+
if (o.subtitle) {
|
|
64
97
|
g.fillStyle = COLOR.textSub;
|
|
65
98
|
g.font = `${FONT_SIZE.body}px ${FONT}`;
|
|
66
|
-
g.fillText(
|
|
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
|
+
}
|
|
67
124
|
}
|
|
68
125
|
}
|
|
69
126
|
|
|
@@ -77,10 +134,7 @@ function fit(g, text, font, maxW) {
|
|
|
77
134
|
return t + '…';
|
|
78
135
|
}
|
|
79
136
|
|
|
80
|
-
/**
|
|
81
|
-
* 卡牌组件(图鉴 / 结果图共用)
|
|
82
|
-
* @param {object} o {img, name, rate, tier, laser, count, x, y, w, h, owned}
|
|
83
|
-
*/
|
|
137
|
+
/** 卡牌组件 */
|
|
84
138
|
async function card(g, Canvas, o) {
|
|
85
139
|
const w = o.w || 170;
|
|
86
140
|
const h = o.h || Math.round(w * 1.43);
|
|
@@ -88,44 +142,38 @@ async function card(g, Canvas, o) {
|
|
|
88
142
|
const tier = o.tier == null ? tierOf({ card_scarcity: o.scarcity }) : o.tier;
|
|
89
143
|
const tc = (TIER[tier] || TIER[10]).color;
|
|
90
144
|
|
|
91
|
-
// 底 + 图
|
|
92
145
|
g.save();
|
|
93
146
|
roundRect(g, o.x, o.y, w, h, r);
|
|
94
147
|
g.clip();
|
|
95
148
|
g.fillStyle = COLOR.cardBg;
|
|
96
149
|
g.fillRect(o.x, o.y, w, h);
|
|
97
150
|
if (o.img) {
|
|
98
|
-
try {
|
|
99
|
-
g.drawImage(o.img, o.x, o.y, w, h);
|
|
100
|
-
} catch { /* 忽略 */ }
|
|
151
|
+
try { g.drawImage(o.img, o.x, o.y, w, h); } catch { /* 忽略 */ }
|
|
101
152
|
}
|
|
102
153
|
if (o.owned === false) {
|
|
103
|
-
g.fillStyle = 'rgba(
|
|
154
|
+
g.fillStyle = 'rgba(255,255,255,0.38)';
|
|
104
155
|
g.fillRect(o.x, o.y, w, h);
|
|
105
156
|
}
|
|
106
157
|
g.restore();
|
|
107
158
|
|
|
108
|
-
// 边框(档位色 + 拥有加粗)
|
|
109
159
|
g.save();
|
|
110
160
|
roundRect(g, o.x + 1, o.y + 1, w - 2, h - 2, r - 1);
|
|
111
|
-
g.strokeStyle = o.owned === false ? 'rgba(
|
|
161
|
+
g.strokeStyle = o.owned === false ? 'rgba(150,165,185,0.45)' : tc;
|
|
112
162
|
g.lineWidth = o.owned === false ? 1.5 : 2.5;
|
|
113
163
|
g.stroke();
|
|
114
164
|
g.restore();
|
|
115
165
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
if ((TIER[tier] || {}).tag && o.tagsDir && require('node:fs').existsSync(tagFile)) {
|
|
166
|
+
const tagFile = o.tagsDir ? path.join(o.tagsDir, (TIER[tier] || {}).tag || '') : '';
|
|
167
|
+
if ((TIER[tier] || {}).tag && o.tagsDir && fs.existsSync(tagFile)) {
|
|
119
168
|
try {
|
|
120
169
|
const ti = await Canvas.loadImage(tagFile);
|
|
121
170
|
const tw = w * 0.46;
|
|
122
171
|
g.drawImage(ti, o.x + 4, o.y + 4, tw, (ti.height / ti.width) * tw);
|
|
123
172
|
} catch { /* 忽略 */ }
|
|
124
173
|
}
|
|
125
|
-
// 镭射标
|
|
126
174
|
if (o.laser && o.tagsDir) {
|
|
127
|
-
const holo =
|
|
128
|
-
if (
|
|
175
|
+
const holo = path.join(o.tagsDir, 'tag_holo.png');
|
|
176
|
+
if (fs.existsSync(holo)) {
|
|
129
177
|
try {
|
|
130
178
|
const hi = await Canvas.loadImage(holo);
|
|
131
179
|
const hw = w * 0.36;
|
|
@@ -134,9 +182,8 @@ async function card(g, Canvas, o) {
|
|
|
134
182
|
} catch { /* 忽略 */ }
|
|
135
183
|
}
|
|
136
184
|
}
|
|
137
|
-
// 重复次数
|
|
138
185
|
if (o.count > 1) {
|
|
139
|
-
g.fillStyle = 'rgba(
|
|
186
|
+
g.fillStyle = 'rgba(31,42,58,0.82)';
|
|
140
187
|
roundRect(g, o.x + w - 46, o.y + 6, 40, 22, 8);
|
|
141
188
|
g.fill();
|
|
142
189
|
g.fillStyle = '#8cffaa';
|
|
@@ -144,7 +191,6 @@ async function card(g, Canvas, o) {
|
|
|
144
191
|
g.fillText(`×${o.count}`, o.x + w - 40, o.y + 9);
|
|
145
192
|
}
|
|
146
193
|
|
|
147
|
-
// 名称 / 档位概率
|
|
148
194
|
g.fillStyle = o.owned === false ? COLOR.textDim : COLOR.text;
|
|
149
195
|
g.font = `bold ${o.nameSize || FONT_SIZE.h3}px ${FONT}`;
|
|
150
196
|
g.fillText(fit(g, o.name, `bold ${o.nameSize || FONT_SIZE.h3}px ${FONT}`, w), o.x, o.y + h + 8);
|
package/lib/render/result.js
CHANGED
|
@@ -1,20 +1,18 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
/**
|
|
2
|
+
/** 抽卡结果布局:浅色玻璃手账风 */
|
|
3
3
|
const path = require('node:path');
|
|
4
4
|
const fs = require('node:fs');
|
|
5
5
|
const theme = require('./theme');
|
|
6
6
|
const kit = require('./kit');
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
* @param {number} [o.thumbW]
|
|
17
|
-
*/
|
|
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
|
+
|
|
18
16
|
async function render(o) {
|
|
19
17
|
const { Canvas, tagsDir, thumbsDir, outDir } = o;
|
|
20
18
|
const results = o.results || [];
|
|
@@ -26,18 +24,38 @@ async function render(o) {
|
|
|
26
24
|
const label = theme.CARD.label;
|
|
27
25
|
const gap = theme.CARD.gap;
|
|
28
26
|
const pad = theme.SPACE.xl;
|
|
29
|
-
const head =
|
|
27
|
+
const head = 132;
|
|
28
|
+
const panelPad = 20;
|
|
30
29
|
|
|
31
|
-
const W = Math.max(
|
|
32
|
-
const H = head + rows * (ch + label + gap) + pad;
|
|
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;
|
|
33
32
|
|
|
34
33
|
const canvas = Canvas.createCanvas(W, H);
|
|
35
34
|
const g = canvas.getContext('2d');
|
|
36
|
-
|
|
37
|
-
|
|
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)' });
|
|
38
56
|
|
|
39
57
|
const rowW = perRow * cw + (perRow - 1) * gap;
|
|
40
|
-
const x0 = Math.round((
|
|
58
|
+
const x0 = px + Math.round((pw - rowW) / 2);
|
|
41
59
|
|
|
42
60
|
for (let i = 0; i < results.length; i++) {
|
|
43
61
|
const r = results[i];
|
|
@@ -45,7 +63,7 @@ async function render(o) {
|
|
|
45
63
|
const col = i % perRow;
|
|
46
64
|
const row = Math.floor(i / perRow);
|
|
47
65
|
const x = x0 + col * (cw + gap);
|
|
48
|
-
const y =
|
|
66
|
+
const y = py + panelPad + row * (ch + label + gap);
|
|
49
67
|
|
|
50
68
|
let img = null;
|
|
51
69
|
const f = hash ? path.join(thumbsDir, hash + '.jpg') : '';
|
|
@@ -61,12 +79,12 @@ async function render(o) {
|
|
|
61
79
|
laser: Boolean(r.laser),
|
|
62
80
|
tagsDir,
|
|
63
81
|
owned: true,
|
|
64
|
-
nameSize: cw >= 260 ? 22 :
|
|
65
|
-
metaSize: cw >= 260 ? 17 :
|
|
82
|
+
nameSize: cw >= 260 ? 22 : 17,
|
|
83
|
+
metaSize: cw >= 260 ? 17 : 13,
|
|
66
84
|
});
|
|
67
85
|
}
|
|
68
86
|
|
|
69
|
-
kit.footer(g, W, H, 'VSinger 收藏集 ·
|
|
87
|
+
kit.footer(g, W, H, 'VSinger 收藏集 · 天依抽卡手账');
|
|
70
88
|
|
|
71
89
|
fs.mkdirSync(outDir, { recursive: true });
|
|
72
90
|
const out = path.join(outDir, `draw_${Date.now()}.jpg`);
|
package/lib/render/theme.js
CHANGED
|
@@ -1,35 +1,34 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
/**
|
|
2
|
+
/** 设计令牌:浅色玻璃拟态(仿星轨手账) */
|
|
3
3
|
const FONT = 'VSingerSans, sans-serif';
|
|
4
4
|
|
|
5
5
|
const COLOR = {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
panel: 'rgba(
|
|
14
|
-
panelBorder: 'rgba(
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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)',
|
|
19
20
|
};
|
|
20
21
|
|
|
21
22
|
const TIER = {
|
|
22
|
-
40: { name: '大隐藏', color: '#
|
|
23
|
-
30: { name: '小隐藏', color: '#
|
|
24
|
-
20: { name: '稀有', color: '#
|
|
25
|
-
10: { name: '普通', color: '#
|
|
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 },
|
|
26
27
|
};
|
|
27
28
|
|
|
28
|
-
const RADIUS = { sm:
|
|
29
|
+
const RADIUS = { sm: 10, md: 14, lg: 20, xl: 28 };
|
|
29
30
|
const SPACE = { xs: 8, sm: 12, md: 16, lg: 24, xl: 32 };
|
|
30
|
-
const FONT_SIZE = { h1:
|
|
31
|
-
|
|
32
|
-
/** 卡片几何(图鉴与结果图共用) */
|
|
31
|
+
const FONT_SIZE = { h1: 30, h2: 24, h3: 18, body: 16, meta: 14, tiny: 12 };
|
|
33
32
|
const CARD = { w: 170, h: 243, label: 58, gap: 16 };
|
|
34
33
|
|
|
35
34
|
function tierOf(card) {
|