koishi-plugin-vsinger-gacha 1.3.0 → 1.4.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 +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 +42 -32
- package/lib/index.js +5 -0
- package/lib/render/kit.js +97 -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),缺失才现场生成
|
|
@@ -131,12 +134,17 @@ async function render({ game, store, uid, displayName, outDir, tagsDir, thumbsDi
|
|
|
131
134
|
|
|
132
135
|
// 每池一块(顺序填充)
|
|
133
136
|
const blocks = [];
|
|
137
|
+
let _lastAct = null;
|
|
134
138
|
for (const [act, pm] of seriesMap) {
|
|
135
139
|
for (const [pool, arr] of pm) {
|
|
136
140
|
arr.sort((a, b) => Number(b.v.rate_pct || 0) - Number(a.v.rate_pct || 0));
|
|
137
141
|
const rows = Math.max(1, Math.ceil(arr.length / COLS_PER_COL));
|
|
138
142
|
const got = arr.filter((c) => (owned[c.h] || 0) > 0).length;
|
|
139
|
-
|
|
143
|
+
const isNewSeries = act !== _lastAct;
|
|
144
|
+
// ★ 2026-11-05:系列头也要占高度,否则下一块会压到上一块的卡片上
|
|
145
|
+
const h = POOL_TITLE_H + rows * ROWH + 12 + (isNewSeries ? SERIES_H : 0);
|
|
146
|
+
blocks.push({ act, pool, arr, got, series: isNewSeries, h });
|
|
147
|
+
_lastAct = act;
|
|
140
148
|
}
|
|
141
149
|
}
|
|
142
150
|
const totalH = blocks.reduce((a, b) => a + b.h, 0);
|
|
@@ -200,28 +208,26 @@ async function render({ game, store, uid, displayName, outDir, tagsDir, thumbsDi
|
|
|
200
208
|
const canvas = Canvas.createCanvas(W, H);
|
|
201
209
|
const g = canvas.getContext('2d');
|
|
202
210
|
|
|
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);
|
|
211
|
+
// 背景:插画 + 白蒙层(统一渲染系统)
|
|
212
|
+
const wall = wallpaperDir ? (() => {
|
|
213
|
+
try {
|
|
214
|
+
const list = ctx_fs.readdirSync(wallpaperDir).filter((f) => /\.(jpe?g|png|webp)$/i.test(f));
|
|
215
|
+
return list.length ? require('node:path').join(wallpaperDir, list[Math.floor(Math.random() * list.length)]) : null;
|
|
216
|
+
} catch { return null; }
|
|
217
|
+
})() : null;
|
|
218
|
+
await kit.background(g, Canvas, W, H, wall);
|
|
215
219
|
|
|
216
|
-
//
|
|
217
|
-
g.fillStyle = '#ffffff';
|
|
218
|
-
g.font = 'bold 44px VSingerSans, sans-serif';
|
|
219
|
-
g.fillText('VSinger 收藏集 · 我的图鉴', PAD, 56);
|
|
220
|
+
// 标题(统一组件)
|
|
220
221
|
const gotN = Object.keys(owned).length;
|
|
221
222
|
const dupN = Object.values(owned).reduce((a, b) => a + Math.max(0, b - 1), 0);
|
|
222
|
-
g
|
|
223
|
-
|
|
224
|
-
|
|
223
|
+
kit.header(g, {
|
|
224
|
+
x: PAD, y: 34, W: W - PAD * 2,
|
|
225
|
+
title: 'VSinger 收藏集 · 我的图鉴',
|
|
226
|
+
subtitle: `${displayName} 已收集 ${gotN}/${game.cards.length} 重复 ${dupN} 张`,
|
|
227
|
+
badge: `${gotN}/${game.cards.length}`,
|
|
228
|
+
badgeSub: '收集进度',
|
|
229
|
+
progress: game.cards.length ? gotN / game.cards.length : 0,
|
|
230
|
+
});
|
|
225
231
|
|
|
226
232
|
for (let ci = 0; ci < part.length; ci++) {
|
|
227
233
|
const grp = part[ci];
|
|
@@ -230,20 +236,24 @@ async function render({ game, store, uid, displayName, outDir, tagsDir, thumbsDi
|
|
|
230
236
|
let prevAct = null;
|
|
231
237
|
for (const blk of grp) {
|
|
232
238
|
if (blk.act !== prevAct) {
|
|
233
|
-
g
|
|
234
|
-
g.
|
|
235
|
-
g
|
|
236
|
-
g.
|
|
237
|
-
g.
|
|
239
|
+
kit.panel(g, x0, y, COLW, SERIES_H - 8, { radius: 14, fill: 'rgba(255,255,255,0.88)' });
|
|
240
|
+
g.fillStyle = COLOR.accent;
|
|
241
|
+
kit.roundRect(g, x0 + 12, y + 12, 5, SERIES_H - 32, 3);
|
|
242
|
+
g.fill();
|
|
243
|
+
g.fillStyle = COLOR.text;
|
|
244
|
+
g.font = 'bold 21px VSingerSans, sans-serif';
|
|
245
|
+
g.fillText(blk.act, x0 + 26, y + 22);
|
|
238
246
|
y += SERIES_H;
|
|
239
247
|
prevAct = blk.act;
|
|
240
248
|
}
|
|
241
249
|
// 池标题(玻璃条)
|
|
242
|
-
g
|
|
243
|
-
g.
|
|
244
|
-
g.
|
|
245
|
-
g.
|
|
246
|
-
g.
|
|
250
|
+
kit.panel(g, x0, y, COLW, POOL_TITLE_H - 10, { radius: 12, fill: 'rgba(255,255,255,0.78)' });
|
|
251
|
+
g.fillStyle = COLOR.accent;
|
|
252
|
+
g.font = 'bold 21px VSingerSans, sans-serif';
|
|
253
|
+
g.fillText(`${blk.pool}`, x0 + 12, y + 12);
|
|
254
|
+
g.fillStyle = COLOR.textSub;
|
|
255
|
+
g.font = '18px VSingerSans, sans-serif';
|
|
256
|
+
g.fillText(`${blk.got}/${blk.arr.length}`, x0 + COLW - 82, y + 14);
|
|
247
257
|
|
|
248
258
|
for (let i = 0; i < blk.arr.length; i++) {
|
|
249
259
|
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,113 @@ 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 = 24;
|
|
80
|
+
const cx = x + R;
|
|
81
|
+
const cy = y + R;
|
|
82
|
+
g.save();
|
|
83
|
+
g.beginPath();
|
|
84
|
+
g.arc(cx, cy, R, 0, Math.PI * 2);
|
|
52
85
|
g.fillStyle = COLOR.accent;
|
|
53
|
-
roundRect(g, x, y + 4, 6, 34, 3);
|
|
54
86
|
g.fill();
|
|
87
|
+
g.fillStyle = '#fff';
|
|
88
|
+
g.font = `bold 26px ${FONT}`;
|
|
89
|
+
g.textAlign = 'center';
|
|
90
|
+
g.textBaseline = 'middle';
|
|
91
|
+
g.fillText('♪', cx, cy + 1);
|
|
92
|
+
g.textAlign = 'left';
|
|
93
|
+
g.restore();
|
|
55
94
|
|
|
95
|
+
g.textBaseline = 'top';
|
|
56
96
|
g.fillStyle = COLOR.text;
|
|
57
97
|
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);
|
|
98
|
+
g.fillText(fit(g, o.title, `bold ${FONT_SIZE.h1}px ${FONT}`, W - 260), x + R * 2 + 14, y);
|
|
62
99
|
|
|
63
|
-
if (subtitle) {
|
|
100
|
+
if (o.subtitle) {
|
|
64
101
|
g.fillStyle = COLOR.textSub;
|
|
65
102
|
g.font = `${FONT_SIZE.body}px ${FONT}`;
|
|
66
|
-
g.fillText(
|
|
103
|
+
g.fillText(o.subtitle, x + R * 2 + 14, y + 40);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// 右侧徽章
|
|
107
|
+
if (o.badge != null) {
|
|
108
|
+
const bw = 118;
|
|
109
|
+
const bh = 58;
|
|
110
|
+
const bx = x + W - bw;
|
|
111
|
+
panel(g, bx, y - 4, bw, bh, { radius: RADIUS.md, fill: 'rgba(255,255,255,0.95)' });
|
|
112
|
+
g.fillStyle = COLOR.accent;
|
|
113
|
+
g.font = `bold 26px ${FONT}`;
|
|
114
|
+
g.fillText(String(o.badge), bx + 14, y + 6);
|
|
115
|
+
g.fillStyle = COLOR.textSub;
|
|
116
|
+
g.font = `12px ${FONT}`;
|
|
117
|
+
g.fillText(String(o.badgeSub || ''), bx + 14, y + 36);
|
|
118
|
+
// 进度条
|
|
119
|
+
const pct = Math.max(0, Math.min(1, Number(o.progress || 0)));
|
|
120
|
+
g.fillStyle = 'rgba(74,144,217,0.15)';
|
|
121
|
+
roundRect(g, bx + 10, y + bh - 6, bw - 20, 5, 3);
|
|
122
|
+
g.fill();
|
|
123
|
+
if (pct > 0) {
|
|
124
|
+
g.fillStyle = COLOR.accent;
|
|
125
|
+
roundRect(g, bx + 10, y + bh - 6, Math.max(4, (bw - 20) * pct), 5, 3);
|
|
126
|
+
g.fill();
|
|
127
|
+
}
|
|
67
128
|
}
|
|
68
129
|
}
|
|
69
130
|
|
|
@@ -77,10 +138,7 @@ function fit(g, text, font, maxW) {
|
|
|
77
138
|
return t + '…';
|
|
78
139
|
}
|
|
79
140
|
|
|
80
|
-
/**
|
|
81
|
-
* 卡牌组件(图鉴 / 结果图共用)
|
|
82
|
-
* @param {object} o {img, name, rate, tier, laser, count, x, y, w, h, owned}
|
|
83
|
-
*/
|
|
141
|
+
/** 卡牌组件 */
|
|
84
142
|
async function card(g, Canvas, o) {
|
|
85
143
|
const w = o.w || 170;
|
|
86
144
|
const h = o.h || Math.round(w * 1.43);
|
|
@@ -88,44 +146,38 @@ async function card(g, Canvas, o) {
|
|
|
88
146
|
const tier = o.tier == null ? tierOf({ card_scarcity: o.scarcity }) : o.tier;
|
|
89
147
|
const tc = (TIER[tier] || TIER[10]).color;
|
|
90
148
|
|
|
91
|
-
// 底 + 图
|
|
92
149
|
g.save();
|
|
93
150
|
roundRect(g, o.x, o.y, w, h, r);
|
|
94
151
|
g.clip();
|
|
95
152
|
g.fillStyle = COLOR.cardBg;
|
|
96
153
|
g.fillRect(o.x, o.y, w, h);
|
|
97
154
|
if (o.img) {
|
|
98
|
-
try {
|
|
99
|
-
g.drawImage(o.img, o.x, o.y, w, h);
|
|
100
|
-
} catch { /* 忽略 */ }
|
|
155
|
+
try { g.drawImage(o.img, o.x, o.y, w, h); } catch { /* 忽略 */ }
|
|
101
156
|
}
|
|
102
157
|
if (o.owned === false) {
|
|
103
|
-
g.fillStyle = 'rgba(
|
|
158
|
+
g.fillStyle = 'rgba(255,255,255,0.38)';
|
|
104
159
|
g.fillRect(o.x, o.y, w, h);
|
|
105
160
|
}
|
|
106
161
|
g.restore();
|
|
107
162
|
|
|
108
|
-
// 边框(档位色 + 拥有加粗)
|
|
109
163
|
g.save();
|
|
110
164
|
roundRect(g, o.x + 1, o.y + 1, w - 2, h - 2, r - 1);
|
|
111
|
-
g.strokeStyle = o.owned === false ? 'rgba(
|
|
165
|
+
g.strokeStyle = o.owned === false ? 'rgba(150,165,185,0.45)' : tc;
|
|
112
166
|
g.lineWidth = o.owned === false ? 1.5 : 2.5;
|
|
113
167
|
g.stroke();
|
|
114
168
|
g.restore();
|
|
115
169
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
if ((TIER[tier] || {}).tag && o.tagsDir && require('node:fs').existsSync(tagFile)) {
|
|
170
|
+
const tagFile = o.tagsDir ? path.join(o.tagsDir, (TIER[tier] || {}).tag || '') : '';
|
|
171
|
+
if ((TIER[tier] || {}).tag && o.tagsDir && fs.existsSync(tagFile)) {
|
|
119
172
|
try {
|
|
120
173
|
const ti = await Canvas.loadImage(tagFile);
|
|
121
174
|
const tw = w * 0.46;
|
|
122
175
|
g.drawImage(ti, o.x + 4, o.y + 4, tw, (ti.height / ti.width) * tw);
|
|
123
176
|
} catch { /* 忽略 */ }
|
|
124
177
|
}
|
|
125
|
-
// 镭射标
|
|
126
178
|
if (o.laser && o.tagsDir) {
|
|
127
|
-
const holo =
|
|
128
|
-
if (
|
|
179
|
+
const holo = path.join(o.tagsDir, 'tag_holo.png');
|
|
180
|
+
if (fs.existsSync(holo)) {
|
|
129
181
|
try {
|
|
130
182
|
const hi = await Canvas.loadImage(holo);
|
|
131
183
|
const hw = w * 0.36;
|
|
@@ -134,9 +186,8 @@ async function card(g, Canvas, o) {
|
|
|
134
186
|
} catch { /* 忽略 */ }
|
|
135
187
|
}
|
|
136
188
|
}
|
|
137
|
-
// 重复次数
|
|
138
189
|
if (o.count > 1) {
|
|
139
|
-
g.fillStyle = 'rgba(
|
|
190
|
+
g.fillStyle = 'rgba(31,42,58,0.82)';
|
|
140
191
|
roundRect(g, o.x + w - 46, o.y + 6, 40, 22, 8);
|
|
141
192
|
g.fill();
|
|
142
193
|
g.fillStyle = '#8cffaa';
|
|
@@ -144,7 +195,6 @@ async function card(g, Canvas, o) {
|
|
|
144
195
|
g.fillText(`×${o.count}`, o.x + w - 40, o.y + 9);
|
|
145
196
|
}
|
|
146
197
|
|
|
147
|
-
// 名称 / 档位概率
|
|
148
198
|
g.fillStyle = o.owned === false ? COLOR.textDim : COLOR.text;
|
|
149
199
|
g.font = `bold ${o.nameSize || FONT_SIZE.h3}px ${FONT}`;
|
|
150
200
|
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) {
|