koishi-plugin-ll-bns-fortune 0.2.3 → 0.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.
Binary file
package/lib/data.js CHANGED
@@ -90,26 +90,38 @@ export const DEFAULT_MAPS = [
90
90
  /**
91
91
  * 排版模板:{xxx} 会被替换成实际内容。
92
92
  *
93
- * 行首可以加的标记:
93
+ * 行首可以加的标记(可以叠加,顺序随意):
94
94
  * [left] / [center] 指定这一行的对齐方式(默认跟随 panel.align)
95
95
  * [size=0.85] 指定这一行的字号倍率(默认 1)
96
+ * [card] 这一行当「卡头」渲染:
97
+ * 左边画一个圆角徽章(字与颜色见「卡片样式」设置),
98
+ * 标题排在徽章右侧,下面画一条分隔线
96
99
  *
97
100
  * 字号层级(size 标记 = 相对整体字号的倍率):
98
101
  * 五角星 —— 由 panel.starSize 单独控制
99
- * 标题行 0.86 —— 比正文大一点,作为抬头
100
- * 正文 0.74 —— 比标题小,避免抢戏
101
- * 所以整体字号(panel.charsPerLine 决定的那个)应当按「最大的一行」去调。
102
+ * 抬头 0.82 —— 「今日运势指数为」这类说明
103
+ * 卡头 1.0 —— 宜 / 忌 / 地图 的标题,最大
104
+ * 说明 0.72 —— 卡头下面的小字
102
105
  */
103
106
  export const DEFAULT_FORMAT = [
104
- '[center][size=0.86]您今日的运势指数为:',
107
+ '[center][size=0.82]您今日的运势指数为:',
105
108
  '[center]{stars}',
106
- '[size=0.74]今日幸运频道:{channel}',
107
- '[size=0.74]宜{good}:{goodText}',
108
- '[size=0.74]忌{bad}:{badText}',
109
- '[size=0.74]今日最佳玄学地图是:{map}',
110
- '[size=0.74]{mapText}',
109
+ '[card]{good}',
110
+ '[size=0.72]{goodText}',
111
+ '[card]{bad}',
112
+ '[size=0.72]{badText}',
113
+ '[card]地图 {map}',
114
+ '[size=0.72]{mapText}',
111
115
  ].join('\n')
112
116
 
117
+ /** 卡片徽章的默认字与颜色(设置页里「卡片样式」可覆盖) */
118
+ export const DEFAULT_CARDS = {
119
+ good: { badge: '宜', color: '#2E7D6B' },
120
+ bad: { badge: '忌', color: '#3E4A5A' },
121
+ map: { badge: '图', color: '#8C5A2B' },
122
+ summary: { badge: '', color: '#C8434F' },
123
+ }
124
+
113
125
  /** 地图下面的那句补充说明。留空则模板里的 {mapText} 一行自动省略 */
114
126
  export const DEFAULT_MAP_TEXT = ''
115
127
 
package/lib/index.js CHANGED
@@ -62,7 +62,7 @@ function assetPath (name) {
62
62
  return candidates[0]
63
63
  }
64
64
 
65
- const DEFAULT_BG = assetPath('bg.png')
65
+ const DEFAULT_BG = assetPath('bg-panel.png')
66
66
 
67
67
  /* ------------------------------------------------------------------ *
68
68
  * 配置
@@ -73,25 +73,29 @@ const panelSchema = Schema.object({
73
73
  Schema.const('left').description('左对齐(和参考排版一致)'),
74
74
  Schema.const('center').description('居中'),
75
75
  ]).default('left').description('整体对齐方式。想单独控制某一行,可在模板行首写 [left]/[center]'),
76
- charsPerLine: Schema.number().role('slider').min(6).max(40).step(1).default(14)
76
+ charsPerLine: Schema.number().role('slider').min(6).max(40).step(1).default(18)
77
77
  .description('字号大小:每行大约放几个字。<b>这是最常调的一项</b>,调小字更大'),
78
78
  blockFill: Schema.number().role('slider').min(1).max(1.5).step(0.01).default(1.08)
79
79
  .description('松紧度:1 = 撑满面板,调大四周留白更多(嫌挤就往大调)'),
80
80
  lineHeight: Schema.number().role('slider').min(1).max(2.5).step(0.05).default(1.5)
81
81
  .description('行高倍数(调大行与行更松)'),
82
- starSize: Schema.number().role('slider').min(0.5).max(2.5).step(0.05).default(1.15)
82
+ starSize: Schema.number().role('slider').min(0.5).max(2.5).step(0.05).default(1.3)
83
83
  .description('五角星大小(相对字号倍数)。星数多的时候别调太大,会顶出面板'),
84
84
  starSpacing: Schema.number().role('slider').min(1).max(2).step(0.02).default(1.32)
85
85
  .description('五角星间距'),
86
+ starStyle: Schema.union([
87
+ Schema.const('gem').description('宝石质感(金色渐变 + 深色描边 + 内高光,默认)'),
88
+ Schema.const('flat').description('单色平涂(旧样式,可逐颗换色)'),
89
+ ]).default('gem').description('星星样式'),
86
90
 
87
91
  // ↓ 换底图才需要动,折叠起来
88
92
  geometry: Schema.object({
89
- x: Schema.number().role('ratio').default(0.607).description('面板左边缘:占整图宽度的比例'),
90
- y: Schema.number().role('ratio').default(0.180).description('面板上边缘:占整图高度的比例'),
91
- w: Schema.number().role('ratio').default(0.312).description('面板宽度:占整图宽度的比例'),
92
- h: Schema.number().role('ratio').default(0.668).description('面板高度:占整图高度的比例'),
93
- paddingX: Schema.number().role('ratio').default(0.035).description('面板内左右留白:占面板宽度的比例'),
94
- paddingY: Schema.number().role('ratio').default(0.035).description('面板内上下留白:占面板高度的比例'),
93
+ x: Schema.number().role('ratio').default(0.556).description('面板左边缘:占整图宽度的比例'),
94
+ y: Schema.number().role('ratio').default(0.163).description('面板上边缘:占整图高度的比例'),
95
+ w: Schema.number().role('ratio').default(0.375).description('面板宽度:占整图宽度的比例'),
96
+ h: Schema.number().role('ratio').default(0.697).description('面板高度:占整图高度的比例'),
97
+ paddingX: Schema.number().role('ratio').default(0.045).description('面板内左右留白:占面板宽度的比例'),
98
+ paddingY: Schema.number().role('ratio').default(0.05).description('面板内上下留白:占面板高度的比例'),
95
99
  measureRatio: Schema.number().min(0.5).max(1).step(0.01).default(0.98)
96
100
  .description('文字块最大宽度占面板宽度的比例'),
97
101
  }).collapse().description('面板位置(默认已按内置底图量好,换底图时才需要改)'),
@@ -106,6 +110,18 @@ const starColorsSchema = Schema.object({
106
110
  .description('未点亮星星的描边颜色,按顺序循环取用'),
107
111
  }).description('五角星配色')
108
112
 
113
+ const cardThemeSchema = Schema.object({
114
+ badge: Schema.string().default('').description('徽章里的字(留空 = 只画一个空轮廓)'),
115
+ color: Schema.string().role('color').default('#3E4A5A').description('徽章颜色(分隔线也用这个色)'),
116
+ }).description('单张卡片的样式')
117
+
118
+ const cardSchema = Schema.object({
119
+ good: cardThemeSchema.default({ badge: '宜', color: '#2E7D6B' }).description('「宜」那一张'),
120
+ bad: cardThemeSchema.default({ badge: '忌', color: '#3E4A5A' }).description('「忌」那一张'),
121
+ map: cardThemeSchema.default({ badge: '图', color: '#8C5A2B' }).description('「地图」那一张'),
122
+ summary: cardThemeSchema.default({ badge: '', color: '#C8434F' }).description('「小结」那一张(模板里用 [card] 触发)'),
123
+ }).description('卡片徽章与颜色。模板里给某一行加 [card] 就会按这里的样式渲染')
124
+
109
125
  const starWeightSchema = Schema.object({
110
126
  maxStars: Schema.number().role('slider').min(3).max(10).step(1).default(data.DEFAULT_MAX_STARS)
111
127
  .description('一共有几颗星(满星 = 这个数),改这里等于改满分'),
@@ -207,11 +223,12 @@ exports.Config = Schema.intersect([
207
223
  panel: panelSchema,
208
224
  colors: colorsSchema,
209
225
  starColors: starColorsSchema,
226
+ cards: cardSchema,
210
227
 
211
228
  renderAdvanced: Schema.object({
212
229
  background: Schema.string().role('textarea').default(DEFAULT_BG)
213
230
  .description('底图:支持 http(s) 直链、绝对路径、插件相对路径。留空则用内置底图'),
214
- outputWidth: Schema.number().role('slider').min(0).max(2560).step(10).default(1280)
231
+ outputWidth: Schema.number().role('slider').min(0).max(2560).step(10).default(1520)
215
232
  .description('输出图片宽度(像素)。默认 1280 出图最快;想要更锐利的文字可调到 1920'),
216
233
  imageFormat: Schema.union([
217
234
  Schema.const('jpeg').description('JPEG(编码最快,推荐)'),
@@ -310,12 +327,16 @@ function adv (config) {
310
327
 
311
328
  function renderCfg (config) {
312
329
  return Object.assign({
313
- background: DEFAULT_BG, outputWidth: 1280, imageFormat: 'jpeg', imageQuality: 92,
330
+ background: DEFAULT_BG, outputWidth: 1520, imageFormat: 'jpeg', imageQuality: 92,
314
331
  renderScale: 2, fallbackText: true, showPanelBackground: false, panelColor: '#F6E7DA',
315
332
  panelOpacity: 0, decorations: [], fontFamilies: renderer.defaultFamilies(),
316
333
  }, config && config.renderAdvanced)
317
334
  }
318
335
 
336
+ function cardCfg (config) {
337
+ return Object.assign({}, data.DEFAULT_CARDS, config && config.cards)
338
+ }
339
+
319
340
  function storageCfg (config) {
320
341
  return Object.assign({ databaseCache: true }, config && config.storage)
321
342
  }
@@ -330,8 +351,8 @@ function kwMore (config) {
330
351
  /** 面板几何(折叠组,缺省用内置底图量好的值) */
331
352
  function panelGeom (config) {
332
353
  return Object.assign({
333
- x: 0.607, y: 0.180, w: 0.312, h: 0.668,
334
- paddingX: 0.035, paddingY: 0.035, measureRatio: 0.98,
354
+ x: 0.556, y: 0.163, w: 0.375, h: 0.697,
355
+ paddingX: 0.06, paddingY: 0.05, measureRatio: 0.98,
335
356
  }, config && config.panel && config.panel.geometry)
336
357
  }
337
358
 
@@ -533,6 +554,7 @@ exports.apply = function apply (ctx, config) {
533
554
  // ⚠️ 必须定义在所有使用者之前:const 有暂时性死区,
534
555
  // 放到后面会在启动时直接抛 "Cannot access 'xxx' before initialization"。
535
556
  const geom = panelGeom(config)
557
+ const cardTheme = cardCfg(config)
536
558
  const render = renderCfg(config)
537
559
  const storage = storageCfg(config)
538
560
  const keywordMore = kwMore(config)
@@ -670,8 +692,10 @@ exports.apply = function apply (ctx, config) {
670
692
  blockFill: Number(config.panel.blockFill),
671
693
  starSize: Number(config.panel.starSize),
672
694
  starSpacing: Number(config.panel.starSpacing),
695
+ starStyle: config.panel.starStyle,
673
696
  starColors: (config.starColors && config.starColors.fill) || null,
674
697
  starEmptyColors: (config.starColors && config.starColors.empty) || null,
698
+ cards: cardTheme,
675
699
  outputWidth: Number(render.outputWidth) || 0,
676
700
  color: render.panelColor,
677
701
  opacity: render.showPanelBackground ? Number(render.panelOpacity) : 0,
package/lib/render.js CHANGED
@@ -174,16 +174,46 @@ async function loadImageBuffer (ctx, target, relativeBase) {
174
174
  /** 这些占位符即使和上一行重复也不要删(否则整行都会消失) */
175
175
  const SINGLE_TOKEN_BLOCKLIST = ['stars', 'map', 'mapText', 'date', 'userId']
176
176
 
177
+ /** 徽章尺寸 = 字号 × 这个比例(1.55 约等于参考图里的比例) */
178
+ const CARD_BADGE_RATIO = 1.55
179
+
180
+ /** 卡片主题默认值:徽章上的字 + 颜色。可在设置页覆盖 */
181
+ const DEFAULT_CARDS = {
182
+ good: { badge: '宜', color: '#2E7D6B', titleColor: null },
183
+ bad: { badge: '忌', color: '#3E4A5A', titleColor: null },
184
+ map: { badge: '', color: '#B0306A', titleColor: null },
185
+ summary: { badge: '', color: '#C8434F', titleColor: null },
186
+ }
187
+
188
+ /** 根据这一行用的占位符,挑对应的卡片主题(徽章字/颜色) */
189
+ function cardStyle (tokens, theme) {
190
+ const has = (k) => tokens.indexOf(k) !== -1
191
+ if (has('good') || has('goodText')) return Object.assign({}, DEFAULT_CARDS.good, theme.good)
192
+ if (has('bad') || has('badText')) return Object.assign({}, DEFAULT_CARDS.bad, theme.bad)
193
+ if (has('map')) return Object.assign({}, DEFAULT_CARDS.map, theme.map)
194
+ if (has('mapText')) return Object.assign({}, DEFAULT_CARDS.summary, theme.summary)
195
+ return Object.assign({}, DEFAULT_CARDS.summary, theme.summary)
196
+ }
197
+
177
198
  /** 模板行首的对齐标记:'[left]xxx' / '[center]xxx',中文「[左]」「[中]」也行 */
178
199
  const ALIGN_MARKER = /^\[(left|center|左|中)\]/i
179
200
  /** 行首的字号倍率标记:'[size=0.85]xxx',让某一行比整体字号小一点 */
180
201
  const SIZE_MARKER = /^\[size\s*=\s*([\d.]+)\]/i
202
+ /** 行首的卡片标记:'[card]xxx' —— 这一行当卡头渲染(圆角徽章 + 大标题 + 下划分隔线) */
203
+ const CARD_MARKER = /^\[card\]/i
181
204
 
182
205
  function parseLineMarkers (line, fallbackAlign, fallbackSize) {
183
206
  let text = line
184
207
  let align = fallbackAlign
185
208
  let size = fallbackSize
186
- for (let i = 0; i < 4; i++) {
209
+ let card = false
210
+ for (let i = 0; i < 5; i++) {
211
+ const cardMatched = text.match(CARD_MARKER)
212
+ if (cardMatched) {
213
+ card = true
214
+ text = text.slice(cardMatched[0].length)
215
+ continue
216
+ }
187
217
  const alignMatched = text.match(ALIGN_MARKER)
188
218
  if (alignMatched) {
189
219
  const raw = alignMatched[1].toLowerCase()
@@ -200,7 +230,45 @@ function parseLineMarkers (line, fallbackAlign, fallbackSize) {
200
230
  }
201
231
  break
202
232
  }
203
- return { align, size, text }
233
+ return { align, size, card, text }
234
+ }
235
+
236
+ /**
237
+ * 画一个圆角方形徽章。
238
+ * text 为空时只画轮廓(无字徽章,用于「小结」那种)。
239
+ */
240
+ function drawBadge (ctx, x, centerY, size, color, text, fontFamilyList) {
241
+ const radius = size * 0.28
242
+ const left = x
243
+ const top = centerY - size / 2
244
+ ctx.save()
245
+ ctx.beginPath()
246
+ ctx.moveTo(left + radius, top)
247
+ ctx.lineTo(left + size - radius, top)
248
+ ctx.quadraticCurveTo(left + size, top, left + size, top + radius)
249
+ ctx.lineTo(left + size, top + size - radius)
250
+ ctx.quadraticCurveTo(left + size, top + size, left + size - radius, top + size)
251
+ ctx.lineTo(left + radius, top + size)
252
+ ctx.quadraticCurveTo(left, top + size, left, top + size - radius)
253
+ ctx.lineTo(left, top + radius)
254
+ ctx.quadraticCurveTo(left, top, left + radius, top)
255
+ ctx.closePath()
256
+
257
+ if (text) {
258
+ ctx.fillStyle = color
259
+ ctx.fill()
260
+ ctx.fillStyle = '#FFFFFF'
261
+ ctx.font = buildFont(size * 0.56, 'bold', fontFamilyList)
262
+ ctx.textAlign = 'center'
263
+ ctx.textBaseline = 'middle'
264
+ ctx.fillText(text, left + size / 2, centerY + size * 0.03)
265
+ ctx.textAlign = 'left'
266
+ } else {
267
+ ctx.strokeStyle = color
268
+ ctx.lineWidth = Math.max(1, size * 0.1)
269
+ ctx.stroke()
270
+ }
271
+ ctx.restore()
204
272
  }
205
273
 
206
274
  /**
@@ -233,6 +301,7 @@ function detectLabelToken (segments) {
233
301
  function buildLines (template, values, colors, ratio, options) {
234
302
  const opts = options || {}
235
303
  const fallbackAlign = opts.align === 'center' ? 'center' : 'left'
304
+ const cardTheme = opts.cards || DEFAULT_CARDS
236
305
  const lines = []
237
306
  let lastToken = null
238
307
  let lastValue = null
@@ -304,6 +373,7 @@ function buildLines (template, values, colors, ratio, options) {
304
373
  size: marked.size,
305
374
  align: marked.align,
306
375
  isStars: tokens.length === 1 && tokens[0] === 'stars',
376
+ card: marked.card ? cardStyle(tokens, cardTheme) : null,
307
377
  })
308
378
  }
309
379
  return lines
@@ -393,6 +463,111 @@ function drawStar (ctx, cx, cy, outer, filled, color, style) {
393
463
  ctx.restore()
394
464
  }
395
465
 
466
+ /* ------------------------------------------------------------------ *
467
+ * 「宝石质感」星星(参考图那种)
468
+ *
469
+ * 结构从下到上:
470
+ * ① 柔和投影(让星星从纸面上浮起来)
471
+ * ② 外圈描边:深色粗描边,圆角连接
472
+ * ③ 星身填充:竖直线性渐变(顶部亮 → 底部深)
473
+ * ④ 内高光:稍微缩小一圈的淡色描边,做出「边缘反光」的立体感
474
+ * ⑤ 空心星:浅灰平涂 + 深灰粗描边 + 轻微内高光
475
+ * ------------------------------------------------------------------ */
476
+
477
+ const GEM_GOLD = {
478
+ outline: '#C08A2E',
479
+ // 顶部近白 → 中部饱和金 → 底部深金,拉开明暗差才有立体感
480
+ gradient: ['#FFF6C2', '#FFD84F', '#EFA01A', '#D98511'],
481
+ innerLight: '#FFFBE0',
482
+ }
483
+
484
+ const GEM_GRAY = {
485
+ outline: '#8A8A88',
486
+ fill: '#DEDDD8',
487
+ innerLight: '#F4F3EF',
488
+ }
489
+
490
+ function starPathFor (ctx, cx, cy, outer, opts) {
491
+ const inner = outer * (opts.innerRatio || 0.5)
492
+ const points = starPoints(cx, cy, outer, inner, opts.wobble)
493
+ traceStar(ctx, points, true)
494
+ }
495
+
496
+ /**
497
+ * 画一颗宝石质感星星。
498
+ * @param {boolean} filled 点亮 / 未点亮
499
+ * @param {object} conf { filled: {outline,gradient,innerLight}, empty: {outline,fill,innerLight}, shadow }
500
+ */
501
+ function drawGemStar (ctx, cx, cy, outer, filled, conf) {
502
+ const c = conf || {}
503
+ const style = filled ? Object.assign({}, GEM_GOLD, c.filled) : Object.assign({}, GEM_GRAY, c.empty)
504
+ const outlineWidth = Math.max(1, outer * (filled ? 0.145 : 0.18))
505
+ // 参考图的星星比标准五角星饱满:内圈半径调大让星身更胖;
506
+ // wobble 只留一点点不规则感,给大了星角会变成波浪状
507
+ const wobble = c.wobble === undefined ? 0.03 : c.wobble
508
+ // 参数扫描结论:0.44 角最清楚又不尖,再大就变圆球、再小就变瘦星
509
+ const innerRatio = c.innerRatio || 0.44
510
+ const bodyOuter = outer - outlineWidth / 2
511
+
512
+ ctx.save()
513
+ ctx.lineJoin = 'round'
514
+ ctx.lineCap = 'round'
515
+
516
+ // ① 投影
517
+ if (c.shadow !== false) {
518
+ ctx.save()
519
+ ctx.shadowColor = filled ? 'rgba(150, 90, 20, 0.35)' : 'rgba(90, 90, 90, 0.25)'
520
+ ctx.shadowBlur = outer * 0.2
521
+ ctx.shadowOffsetY = outer * 0.07
522
+ starPathFor(ctx, cx, cy, bodyOuter, { innerRatio, wobble })
523
+ ctx.fillStyle = filled ? '#FFD84F' : (style.fill || GEM_GRAY.fill)
524
+ ctx.fill()
525
+ ctx.restore()
526
+ }
527
+
528
+ // ② 星身:渐变从左上到右下,模拟斜上方光源
529
+ starPathFor(ctx, cx, cy, bodyOuter, { innerRatio, wobble })
530
+ if (filled) {
531
+ const grad = ctx.createLinearGradient(cx - outer * 0.5, cy - outer, cx + outer * 0.35, cy + outer)
532
+ const stops = style.gradient || GEM_GOLD.gradient
533
+ stops.forEach((color, i) => grad.addColorStop(i / Math.max(1, stops.length - 1), color))
534
+ ctx.fillStyle = grad
535
+ } else {
536
+ ctx.fillStyle = style.fill || GEM_GRAY.fill
537
+ }
538
+ ctx.fill()
539
+
540
+ // ③ 描边
541
+ ctx.lineWidth = outlineWidth
542
+ ctx.strokeStyle = style.outline
543
+ ctx.stroke()
544
+
545
+ // ④ 内高光:贴在内侧的一圈细亮线,做出「边缘反光」
546
+ const inset = outlineWidth * 1.15
547
+ starPathFor(ctx, cx, cy, Math.max(2, bodyOuter - inset), { innerRatio, wobble })
548
+ ctx.lineWidth = Math.max(1, outlineWidth * 0.45)
549
+ ctx.strokeStyle = style.innerLight || (filled ? GEM_GOLD.innerLight : GEM_GRAY.innerLight)
550
+ ctx.globalAlpha = filled ? 0.55 : 0.45
551
+ ctx.stroke()
552
+ ctx.globalAlpha = 1
553
+
554
+ // ⑤ 中心亮斑:参考图的金星中间有一小块高光,这块最能出「宝石感」
555
+ if (filled) {
556
+ const hl = ctx.createRadialGradient(
557
+ cx - outer * 0.08, cy - outer * 0.16, 0,
558
+ cx - outer * 0.08, cy - outer * 0.16, outer * 0.55,
559
+ )
560
+ hl.addColorStop(0, 'rgba(255, 253, 224, 0.85)')
561
+ hl.addColorStop(0.45, 'rgba(255, 246, 190, 0.35)')
562
+ hl.addColorStop(1, 'rgba(255, 246, 190, 0)')
563
+ starPathFor(ctx, cx, cy, Math.max(2, bodyOuter - inset), { innerRatio, wobble })
564
+ ctx.fillStyle = hl
565
+ ctx.fill()
566
+ }
567
+
568
+ ctx.restore()
569
+ }
570
+
396
571
  /**
397
572
  * 生成一颗「整行星」的绘制块。
398
573
  * 返回一个 run 项:{ sprite: true, w, h, star: {...} }
@@ -425,12 +600,19 @@ function createStarSprite (count, size, total, fillColors, emptyColors, spacing,
425
600
  /** 绘制起点是「第 1 颗星左沿」,不是 center-of-first-star */
426
601
  function paintStarSprite (ctx, sprite, left, centerY) {
427
602
  const star = sprite.star
603
+ const style = star.style || {}
604
+ // style.mode === 'gem' 用参考图那种宝石质感;否则用原来的单色星
605
+ const gem = style.mode === 'gem'
428
606
  for (let i = 0; i < star.total; i++) {
429
607
  const cx = left + star.size / 2 + star.step * i
430
608
  const filled = i < star.count
609
+ if (gem) {
610
+ drawGemStar(ctx, cx, centerY, star.size / 2, filled, style)
611
+ continue
612
+ }
431
613
  const palette = filled ? star.fill : star.empty
432
614
  const color = palette[i % palette.length]
433
- drawStar(ctx, cx, centerY, star.size / 2, filled, color, star.style)
615
+ drawStar(ctx, cx, centerY, star.size / 2, filled, color, style)
434
616
  }
435
617
  }
436
618
 
@@ -591,7 +773,9 @@ async function renderFortune (ctx, options) {
591
773
 
592
774
  // 整图对齐方式:left(默认,和参考排版一致)/ center
593
775
  const align = panel.align === 'center' ? 'center' : 'left'
776
+ const cardTheme = Object.assign({}, DEFAULT_CARDS, panel.cards)
594
777
  const starOptions = {
778
+ mode: panel.starStyle === 'gem' ? 'gem' : 'flat',
595
779
  size: Number(panel.starSize) || 1.15,
596
780
  spacing: Number(panel.starSpacing) || 1.32,
597
781
  fill: (panel.starColors && panel.starColors.length) ? panel.starColors : STAR_FILL_COLORS,
@@ -613,16 +797,31 @@ async function renderFortune (ctx, options) {
613
797
  const buildAndMeasure = (fontSize) => {
614
798
  const laid = []
615
799
  let finalWidth = measureWidth
616
- for (const line of buildLines(template, values, colors, 1, { align })) {
800
+ let lastCard = null
801
+ for (const line of buildLines(template, values, colors, 1, { align, cards: cardTheme })) {
617
802
  const spec = fontSize * (line.ratio || 1) * (line.size || 1)
618
803
 
804
+ // 卡头:左边留出徽章的位置,标题在其右侧起排
805
+ let indent = 0
806
+ let badge = null
807
+ if (line.card) {
808
+ const badgeSize = spec * CARD_BADGE_RATIO
809
+ indent = badgeSize + spec * 0.42
810
+ badge = {
811
+ size: badgeSize,
812
+ color: line.card.color,
813
+ text: line.card.badge || '',
814
+ dividerColor: line.card.divider || line.card.color,
815
+ }
816
+ }
817
+
619
818
  // 整行星:不排版文字,交给绘制阶段画彩色五角星
620
819
  if (line.isStars) {
621
820
  // 星星行必须自己兜住宽度:整行星不参与换行,字号再大也只会把星行撑出面板,
622
821
  // 所以按可用宽度反推星直径上限,星数越多星越小(8 颗星时尤其明显)
623
822
  const starPx = starPixelSize(spec, starTotal, starOptions.size, starOptions.spacing, measureWidth)
624
823
  const sprite = createStarSprite(starCount, starPx, starTotal,
625
- starOptions.fill, starOptions.empty, starOptions.spacing)
824
+ starOptions.fill, starOptions.empty, starOptions.spacing, starOptions)
626
825
  laid.push({
627
826
  runs: [sprite],
628
827
  gap: line.gap,
@@ -630,22 +829,27 @@ async function renderFortune (ctx, options) {
630
829
  height: sprite.h * 1.5,
631
830
  align: 'center',
632
831
  isStars: true,
832
+ badge: null,
833
+ indent: 0,
834
+ divider: false,
633
835
  })
836
+ lastCard = null
634
837
  continue
635
838
  }
636
839
 
637
- let chunks = layoutParagraph(line, finalWidth, g, spec)
840
+ const lineWidth = Math.max(40, finalWidth - indent)
841
+ let chunks = layoutParagraph(line, lineWidth, g, spec)
638
842
 
639
843
  // 孤字控制:如果最后一行只剩一两个字,被硬挤下来会很难看,
640
844
  // 就把排版宽度一点点收窄,逼出更均衡的断行(最多试 25 次)
641
845
  if (chunks.length > 1) {
642
- const natural = layoutParagraph(line, measureWidth, g, spec)
846
+ const natural = layoutParagraph(line, lineWidth, g, spec)
643
847
  const tail = natural[natural.length - 1].map(run => run.char).join('')
644
848
  if (tail.length <= 2 && natural.length <= 4) {
645
- finalWidth = measureWidth
849
+ finalWidth = lineWidth
646
850
  for (let i = 0; i < 25; i++) {
647
- finalWidth -= Math.max(1, measureWidth * 0.02)
648
- if (finalWidth < measureWidth * 0.55) break
851
+ finalWidth -= Math.max(1, lineWidth * 0.02)
852
+ if (finalWidth < lineWidth * 0.55) break
649
853
  const attempt = layoutParagraph(line, finalWidth, g, spec)
650
854
  const attemptTail = attempt[attempt.length - 1].map(run => run.char).join('')
651
855
  if (attemptTail.length > 2 || attempt.length > natural.length + 1) {
@@ -657,6 +861,9 @@ async function renderFortune (ctx, options) {
657
861
  }
658
862
  }
659
863
 
864
+ // 卡头下方的分隔线:只在「接下来还有内容」时画,最后一段不画
865
+ const divider = !!(line.card && lastCard !== null)
866
+
660
867
  for (let i = 0; i < chunks.length; i++) {
661
868
  laid.push({
662
869
  runs: chunks[i],
@@ -665,23 +872,46 @@ async function renderFortune (ctx, options) {
665
872
  height: spec * lineHeightRatio,
666
873
  align: line.align,
667
874
  isStars: false,
875
+ badge: i === 0 ? badge : null,
876
+ indent,
877
+ divider: divider && i === 0,
668
878
  })
669
879
  }
880
+ lastCard = line.card ? true : null
670
881
  }
671
882
  const total = laid.reduce((sum, line) => sum + line.gap * fontSize + line.height, 0)
672
883
  return { lines: laid, total }
673
884
  }
674
885
 
675
886
  // 先按 baseFont 量一次,再二分放大。
676
- // 上限给到 baseFont 的 2.4 倍:baseFont 是「每行几个字」的估算值,内容少的时候
677
- // (比如宜忌没写详细文案)可以放得更大,把面板填得更饱满;放大会触发换行,
678
- // 换行后总高会变,所以二分本身会收敛到一个合适的值。
887
+ // 两条约束同时生效:
888
+ // ① 高度:撑满可用高度(内容少时字就大,填满面板)
889
+ // ② 宽度:不许把「内容最长的那一行」撑到换行 —— 没有这条的话,
890
+ // 自动放大只看高度,长地图名会被硬挤成两行
679
891
  const MAX_FONT_SCALE = 2.4
680
- // 只允许撑到可用高度的 97%:留一点余量,避免笔画降部顶到纸框上
681
892
  const FIT_LIMIT = 0.97
682
- const unit = buildAndMeasure(baseFont)
683
- let fontSize = baseFont
684
- if (unit.total > 0 && unit.total < textHeight) {
893
+ const fitsWidth = (fs) => {
894
+ // 注意:不能量 buildAndMeasure 的结果 —— 那里已经按宽度换过行了,
895
+ // 每段必然「不超宽」,等于没检查。要量的是「这一行原始内容」的自然宽度。
896
+ for (const line of buildLines(template, values, colors, 1, { align, cards: cardTheme })) {
897
+ if (line.isStars) continue
898
+ const spec = fs * (line.ratio || 1) * (line.size || 1)
899
+ let natural = 0
900
+ for (const segment of line.segments) {
901
+ g.font = buildFont(spec * (segment.size || 1), segment.bold ? 'bold' : '', currentFamilies)
902
+ natural += g.measureText(segment.text).width
903
+ }
904
+ const indent = line.card ? spec * CARD_BADGE_RATIO + spec * 0.42 : 0
905
+ if (natural > Math.max(40, measureWidth - indent) + 0.5) return false
906
+ }
907
+ return true
908
+ }
909
+ const findFontSize = () => {
910
+ const unit = buildAndMeasure(baseFont)
911
+ if (unit.total <= 0) return baseFont
912
+ if (unit.total >= textHeight) {
913
+ return baseFont * Math.max(0.5, (textHeight * FIT_LIMIT) / unit.total)
914
+ }
685
915
  let low = 1
686
916
  let high = MAX_FONT_SCALE
687
917
  for (let i = 0; i < 14; i++) {
@@ -689,10 +919,15 @@ async function renderFortune (ctx, options) {
689
919
  if (buildAndMeasure(baseFont * mid).total <= textHeight * FIT_LIMIT) low = mid
690
920
  else high = mid
691
921
  }
692
- fontSize = baseFont * low
693
- } else if (unit.total > textHeight) {
694
- fontSize = baseFont * Math.max(0.5, (textHeight * FIT_LIMIT) / unit.total)
922
+ let fs = baseFont * low
923
+ // 宽度回退:每次退 2%,最多退到 baseFont 的 45%
924
+ for (let i = 0; i < 30 && !fitsWidth(fs); i++) {
925
+ fs *= 0.98
926
+ if (fs < baseFont * 0.45) break
927
+ }
928
+ return fs
695
929
  }
930
+ const fontSize = findFontSize()
696
931
 
697
932
  const fitted = buildAndMeasure(fontSize)
698
933
  const lines = fitted.lines
@@ -708,12 +943,13 @@ async function renderFortune (ctx, options) {
708
943
  const rowCenter = cursorY + line.height / 2
709
944
  const lineWidth = line.runs.reduce((sum, run) => sum + run.w, 0)
710
945
 
711
- // 对齐:center 居中(扣掉行尾整角标点占的空白),left 从面板左内侧起排
946
+ // 对齐:center 居中(扣掉行尾整角标点占的空白),left 从面板左内侧起排(卡头再缩进徽章宽度)
712
947
  const lineAlign = line.align === 'center' ? 'center' : align
713
948
  const trim = lineAlign === 'center' ? trailingPunctWidth(line.runs) : 0
949
+ const indent = line.indent || 0
714
950
  let x = lineAlign === 'center'
715
951
  ? panelRect.x + panelRect.w / 2 - (lineWidth - trim) / 2
716
- : panelRect.x + paddingX
952
+ : panelRect.x + paddingX + indent
717
953
 
718
954
  if (line.isStars) {
719
955
  // 整行星:五角星在面板里居中(paintStarSprite 的 x 参数是第 1 颗星的左沿)
@@ -724,6 +960,12 @@ async function renderFortune (ctx, options) {
724
960
  continue
725
961
  }
726
962
 
963
+ // 卡头左边的圆角徽章
964
+ if (line.badge) {
965
+ drawBadge(g, panelRect.x + paddingX, rowCenter,
966
+ line.badge.size, line.badge.color, line.badge.text, currentFamilies)
967
+ }
968
+
727
969
  for (const run of line.runs) {
728
970
  if (run.sprite) {
729
971
  paintStarSprite(g, run, x, rowCenter)
@@ -735,6 +977,23 @@ async function renderFortune (ctx, options) {
735
977
  g.fillText(run.char, x, rowCenter)
736
978
  x += run.w
737
979
  }
980
+
981
+ // 卡头下方的分隔线(只在后面还有内容时画)
982
+ if (line.divider) {
983
+ const left = panelRect.x + paddingX + indent
984
+ const right = panelRect.x + panelRect.w - paddingX
985
+ const y = cursorY + line.height * 0.98
986
+ g.save()
987
+ // 0.28 在宣纸上几乎看不见,加到 0.5 才有参考图那种分隔感
988
+ g.strokeStyle = withAlpha(line.badge ? line.badge.dividerColor : colors.text, 0.5)
989
+ g.lineWidth = Math.max(1, line.font * 0.06)
990
+ g.beginPath()
991
+ g.moveTo(left, y)
992
+ g.lineTo(right, y)
993
+ g.stroke()
994
+ g.restore()
995
+ }
996
+
738
997
  cursorY += line.height
739
998
  }
740
999
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-ll-bns-fortune",
3
3
  "description": "剑灵怀旧服每日运势:宜/忌、玄学地图、彩色五角星运势指数,自定义底图出图",
4
- "version": "0.2.3",
4
+ "version": "0.4.0",
5
5
  "main": "lib/index.js",
6
6
  "files": [
7
7
  "lib",
package/readme.md CHANGED
@@ -89,7 +89,14 @@ npm i @napi-rs/canvas # 出图用,预编译二进制,不需要编译工
89
89
  3. `panel.paddingY` / `panel.paddingX` 调大 —— 文字区整体收窄
90
90
  4. 还挤就是文案太长,配合改 `template` 或缩短宜忌文案
91
91
 
92
- ## 深色系五角星
92
+ ## 五角星(两种样式)
93
+
94
+ 默认是 **宝石质感**:金色渐变 + 深金描边 + 内圈高光 + 中心亮斑,未点亮的星是浅灰平涂 + 深灰描边。
95
+ 这是参考图那种立体星星,和「深色系平涂」是两个可切换的样式(设置页 panel.starStyle)。
96
+
97
+ ---
98
+
99
+ ## 深色系五角星(flat 样式)
93
100
 
94
101
  运势指数不是文字,而是**画出来的矢量五角星**。默认 **8 颗星,满星 = 8/8**;形状是圆润的
95
102
  「Q 版胖星星」(二次贝塞尔抹圆尖角,内圈半径带一点随机起伏),配色是深色系:
package/assets/bg.png DELETED
Binary file