koishi-plugin-bns-fortune 1.0.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 ADDED
@@ -0,0 +1,105 @@
1
+ # koishi-plugin-bns-fortune
2
+
3
+ 剑灵风格**每日好运签**。基于 Canvas 实时绘制签图,文字完全可变,天然不含原图「西普大陆」logo,并融入剑灵(Blade & Soul)水墨视觉元素。
4
+
5
+ ## 特性
6
+
7
+ - 🎴 **每日一签**:同一用户当天结果固定,由 `日期 + 用户ID + masterKey` 哈希确定,杜绝刷屏改命。
8
+ - 🎨 **Canvas 实时绘制**:背景模板(绸带 / 圆形印章 / 福字暗纹 / 古典金边 / 四角剑形符文)全部代码绘制,**天然无原图 logo**;文字每次叠加,100% 可变。
9
+ - ⚔️ **剑灵水墨配色**:墨黑 / 朱砂红 / 赤金 / 米色,装饰加入剑气光线与剑形符文(可切换为传统朱金)。
10
+ - 🖼️ **背景可替换**:填一个图片路径/URL 即可换成你自己的背景,无需改代码。
11
+ - 📚 **40 条签文**:覆盖上上签 → 下签六个等级,签诗 + 白话解签 + 分类运势。
12
+ - 🔤 **中文字体友好**:可指定字体目录自动注册 ttf/otf,缺失时回退系统楷体/宋体。
13
+
14
+ ## 截图占位
15
+
16
+ > 运行 `fortune` 命令即可生成签图。示意:顶部红绸烫金标题(如「上上签」)+ 中部圆形朱红印章「灵签」+ 竖排签诗 + 横排解签 + 运势标签 + 底部日期/昵称/签号。
17
+
18
+ ## 安装
19
+
20
+ ### 1. 安装本插件
21
+
22
+ 在 Koishi 控制台「插件市场」搜索 `bns-fortune` 安装;或在工作区手动安装:
23
+
24
+ ```bash
25
+ npm install koishi-plugin-bns-fortune
26
+ ```
27
+
28
+ ### 2. 必装依赖:canvas 服务
29
+
30
+ 本插件依赖 canvas 服务来绘图。请在 Koishi 中**额外安装并启用** [`koishi-plugin-canvas`](https://github.com/idlist/koishi-plugin-canvas)(基于 skia-canvas,性能好):
31
+
32
+ ```bash
33
+ npm install koishi-plugin-canvas
34
+ ```
35
+
36
+ > ⚠️ 若未安装 canvas 服务,命令会返回错误提示,不会绘图。
37
+
38
+ ## 使用
39
+
40
+ 发送以下任一指令即可抽签:
41
+
42
+ | 指令 | 别名 |
43
+ |------|------|
44
+ | `fortune` | `求签` / `jrrp` |
45
+
46
+ 机器人会返回一张当天的签图。同一用户当天再抽仍是同一签。
47
+
48
+ ## 配置项
49
+
50
+ | 配置项 | 类型 | 默认 | 说明 |
51
+ |--------|------|------|------|
52
+ | `masterKey` | string | `bns-fortune-2024` | 随机密钥。**修改后所有用户的签会被重新打乱**,可用作「定期换签」。 |
53
+ | `theme` | `ink` \| `gold-red` | `ink` | 配色主题。`ink`=剑灵·水墨,`gold-red`=传统·朱金。 |
54
+ | `fontFamily` | string | 空 | 签图正文字体族名。需为系统字体或已注册字体;留空则回退内置楷体栈。 |
55
+ | `fontsDir` | 目录 | 空 | 字体目录。插件启动时会自动注册其中的 `.ttf`/`.otf`。 |
56
+ | `customBackground` | string | 空 | 自定义背景图。填本地绝对路径或 http(s) URL,留空则用代码绘制的模板。 |
57
+ | `results` | array | null | 自定义签文库(高级),完整覆盖内置 40 签。 |
58
+
59
+ ### 推荐字体(可选)
60
+
61
+ 内置渲染已含中文字体回退栈(LXGW 霞鹜文楷 / 楷体 / 宋体 等)。若想要更统一的书法效果,推荐下载免费中文字体放入 `fontsDir`:
62
+
63
+ - [LXGW WenKai 霞鹜文楷](https://github.com/lxgw/LxgwWenKai)(推荐,楷书风格,免费)
64
+ - 思源宋体 / 思源黑体
65
+
66
+ 放置后在配置 `fontsDir` 填该目录路径,并把 `fontFamily` 填为字体名(如 `LXGW WenKai`)即可。
67
+
68
+ ### 自定义背景图
69
+
70
+ 1. 准备一张约 **600 × 860**(或同比例)的图片。
71
+ 2. 在 `customBackground` 填本地路径(如 `D:/imgs/bg.png`)或图片 URL。
72
+ 3. 插件会以 cover 方式铺满,并叠加边框、暗角与所有动态文字,保持风格统一。
73
+
74
+ > 留空时使用代码绘制的剑灵水墨模板,无需任何素材即可使用。
75
+
76
+ ## 签文结构(自定义 results 时参考)
77
+
78
+ ```ts
79
+ {
80
+ level: '上上签', // 上上签 / 上签 / 中吉签 / 中签 / 中平签 / 下签
81
+ number: 1, // 签号
82
+ poem: ['第一句', '第二句', '第三句', '第四句'], // 四句签诗
83
+ interpretation: '白话解签文字',
84
+ luck: { // 分类运势,至少一项
85
+ 财运: '财源广进',
86
+ 姻缘: '天作之合',
87
+ 修炼: '顿悟飞升',
88
+ 健康: '百病不侵',
89
+ }
90
+ }
91
+ ```
92
+
93
+ 等级权重(抽到概率):上上签 < 上签 < 中吉签 < 中平签 < 下签 < 中签。
94
+
95
+ ## 开发
96
+
97
+ ```bash
98
+ npm install # 安装依赖
99
+ npm run build # 编译 src → lib
100
+ npm run typecheck # 仅类型检查
101
+ ```
102
+
103
+ ## 许可证
104
+
105
+ MIT
@@ -0,0 +1,24 @@
1
+ /**
2
+ * 定签算法:确定性随机
3
+ *
4
+ * 同一 (日期 + 用户ID + masterKey) 永远得到同一支签,
5
+ * 杜绝同一用户当天刷屏改命。
6
+ */
7
+ import { Fortune } from './fortunes';
8
+ /** 格式化日期为 YYYY-MM-DD(基于本地时区) */
9
+ export declare function formatDate(d: Date): string;
10
+ export interface DrawResult {
11
+ fortune: Fortune;
12
+ /** 签文在该等级内的序号(用于展示"今日第 X 签") */
13
+ index: number;
14
+ /** 用于展示的种子 */
15
+ seed: number;
16
+ }
17
+ /**
18
+ * 抽签
19
+ * @param dateStr YYYY-MM-DD
20
+ * @param userId 用户 ID
21
+ * @param masterKey 随机密钥(可由配置提供)
22
+ * @param library 签文库(默认内置)
23
+ */
24
+ export declare function drawFortune(dateStr: string, userId: string, masterKey: string, library?: Fortune[]): DrawResult;
package/lib/fortune.js ADDED
@@ -0,0 +1,71 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.formatDate = formatDate;
4
+ exports.drawFortune = drawFortune;
5
+ /**
6
+ * 定签算法:确定性随机
7
+ *
8
+ * 同一 (日期 + 用户ID + masterKey) 永远得到同一支签,
9
+ * 杜绝同一用户当天刷屏改命。
10
+ */
11
+ const fortunes_1 = require("./fortunes");
12
+ /** 简单字符串哈希(FNV-1a 变体),返回 32 位无符号整数 */
13
+ function hashString(str) {
14
+ let hash = 2166136261;
15
+ for (let i = 0; i < str.length; i++) {
16
+ hash ^= str.charCodeAt(i);
17
+ hash = Math.imul(hash, 16777619);
18
+ }
19
+ // 转无符号
20
+ return hash >>> 0;
21
+ }
22
+ /** mulberry32:种子 → 确定性 PRNG,返回 [0,1) 的函数 */
23
+ function mulberry32(seed) {
24
+ let a = seed >>> 0;
25
+ return function () {
26
+ a |= 0;
27
+ a = (a + 0x6d2b79f5) | 0;
28
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
29
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
30
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
31
+ };
32
+ }
33
+ /** 格式化日期为 YYYY-MM-DD(基于本地时区) */
34
+ function formatDate(d) {
35
+ const y = d.getFullYear();
36
+ const m = String(d.getMonth() + 1).padStart(2, '0');
37
+ const day = String(d.getDate()).padStart(2, '0');
38
+ return `${y}-${m}-${day}`;
39
+ }
40
+ /**
41
+ * 抽签
42
+ * @param dateStr YYYY-MM-DD
43
+ * @param userId 用户 ID
44
+ * @param masterKey 随机密钥(可由配置提供)
45
+ * @param library 签文库(默认内置)
46
+ */
47
+ function drawFortune(dateStr, userId, masterKey, library = fortunes_1.FORTUNES) {
48
+ if (library.length === 0) {
49
+ throw new Error('签文库为空,无法抽签');
50
+ }
51
+ const seed = hashString(`${dateStr}|${userId}|${masterKey}`);
52
+ const rand = mulberry32(seed);
53
+ // 1. 按等级权重先定等级
54
+ const levelList = Object.keys(fortunes_1.LEVEL_WEIGHTS);
55
+ const totalWeight = levelList.reduce((s, lv) => s + fortunes_1.LEVEL_WEIGHTS[lv], 0);
56
+ let r = rand() * totalWeight;
57
+ let chosenLevel = levelList[0];
58
+ for (const lv of levelList) {
59
+ if (r < fortunes_1.LEVEL_WEIGHTS[lv]) {
60
+ chosenLevel = lv;
61
+ break;
62
+ }
63
+ r -= fortunes_1.LEVEL_WEIGHTS[lv];
64
+ }
65
+ // 2. 在该等级的签中再随机一条;若该等级无签则回退到整库随机
66
+ const candidates = library.filter((f) => f.level === chosenLevel);
67
+ const pool = candidates.length > 0 ? candidates : library;
68
+ const index = Math.floor(rand() * pool.length);
69
+ const fortune = pool[index];
70
+ return { fortune, index, seed };
71
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * 内置签文库
3
+ *
4
+ * 签诗保持传统风格,视觉层由 render.ts 以剑灵水墨配色呈现。
5
+ * 用户可通过配置项 `results` 覆盖或扩展本库。
6
+ */
7
+ export type FortuneLevel = '上上签' | '上签' | '中吉签' | '中签' | '中平签' | '下签';
8
+ export interface Fortune {
9
+ /** 签等级 */
10
+ level: FortuneLevel;
11
+ /** 签号(第一签、第二签 …) */
12
+ number: number;
13
+ /** 四句签诗 */
14
+ poem: string[];
15
+ /** 白话解签 */
16
+ interpretation: string;
17
+ /** 分类运势 */
18
+ luck: {
19
+ 财运?: string;
20
+ 姻缘?: string;
21
+ 修炼?: string;
22
+ 健康?: string;
23
+ 出行?: string;
24
+ };
25
+ }
26
+ export declare const FORTUNES: Fortune[];
27
+ /** 等级权重:上上签稀有,中签居多 */
28
+ export declare const LEVEL_WEIGHTS: Record<FortuneLevel, number>;
@@ -0,0 +1,306 @@
1
+ "use strict";
2
+ /**
3
+ * 内置签文库
4
+ *
5
+ * 签诗保持传统风格,视觉层由 render.ts 以剑灵水墨配色呈现。
6
+ * 用户可通过配置项 `results` 覆盖或扩展本库。
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.LEVEL_WEIGHTS = exports.FORTUNES = void 0;
10
+ exports.FORTUNES = [
11
+ // ============================ 上上签 (5) ============================
12
+ {
13
+ level: '上上签',
14
+ number: 1,
15
+ poem: ['紫气东来瑞气生', '金鸡报晓叩天门', '一箭穿云千万里', '功名福禄此时成'],
16
+ interpretation: '鸿运当头,万事亨通。所求皆遂,吉无不利。',
17
+ luck: { 财运: '财源广进', 姻缘: '天作之合', 修炼: '顿悟飞升', 健康: '百病不侵' },
18
+ },
19
+ {
20
+ level: '上上签',
21
+ number: 9,
22
+ poem: ['日出扶桑万象新', '祥云瑞气满乾坤', '龙腾九霄风云会', '一举成名天下闻'],
23
+ interpretation: '旭日东升,前程似锦。机缘已至,放手为之。',
24
+ luck: { 财运: '横财入户', 姻缘: '良缘天定', 修炼: '功力大进', 出行: '一路平安' },
25
+ },
26
+ {
27
+ level: '上上签',
28
+ number: 18,
29
+ poem: ['春风得意马蹄疾', '一日看尽长安花', '人逢喜事精神爽', '福禄寿全到我家'],
30
+ interpretation: '春风得意,喜事临门。诸事顺遂,宜把握良机。',
31
+ luck: { 财运: '财喜双至', 姻缘: '喜结良缘', 修炼: '精进有成', 健康: '神清气爽' },
32
+ },
33
+ {
34
+ level: '上上签',
35
+ number: 27,
36
+ poem: ['鲲鹏展翅九万里', '翻动扶摇羊角风', '一朝风雷凌云志', '扶摇直上九重天'],
37
+ interpretation: '志向高远,终成大器。时机一到,一飞冲天。',
38
+ luck: { 财运: '一本万利', 姻缘: '终成眷属', 修炼: '破境而出', 出行: '贵人相助' },
39
+ },
40
+ {
41
+ level: '上上签',
42
+ number: 36,
43
+ poem: ['丹凤朝阳展彩霞', '麒麟献瑞到君家', '天降甘霖苏万物', '荣华富贵享无涯'],
44
+ interpretation: '祥瑞临门,福泽深厚。积善之家,必有余庆。',
45
+ luck: { 财运: '盆满钵满', 姻缘: '百年好合', 修炼: '福慧双修', 健康: '延年益寿' },
46
+ },
47
+ // ============================ 上签 (7) ============================
48
+ {
49
+ level: '上签',
50
+ number: 2,
51
+ poem: ['天开黄道大吉祥', '福禄寿三星照堂', '谋事在人成在天', '天随人愿喜洋洋'],
52
+ interpretation: '吉星高照,所谋皆成。但有诚心,必得善果。',
53
+ luck: { 财运: '进财顺利', 姻缘: '情投意合', 修炼: '稳步精进', 健康: '安康无恙' },
54
+ },
55
+ {
56
+ level: '上签',
57
+ number: 7,
58
+ poem: ['明月当空照九州', '清辉万里共悠悠', '前途自有光明处', '且向高歌一展眸'],
59
+ interpretation: '前程明朗,心思通透。前路虽有曲折,终见光明。',
60
+ luck: { 财运: '渐入佳境', 姻缘: '两情相悦', 修炼: '心境澄明', 出行: '顺风顺水' },
61
+ },
62
+ {
63
+ level: '上签',
64
+ number: 12,
65
+ poem: ['一帆风顺济沧溟', '百尺竿头更进层', '莫道前路多艰险', '守得云开见月明'],
66
+ interpretation: '虽有波折,终得顺利。坚持本心,必有收获。',
67
+ luck: { 财运: '稳定增长', 姻缘: '缘定三生', 修炼: '苦尽甘来', 健康: '宜调养息' },
68
+ },
69
+ {
70
+ level: '上签',
71
+ number: 19,
72
+ poem: ['绿柳抽枝迎早春', '燕泥新垒筑芳辰', '生机勃发无穷尽', '事事如意自随心'],
73
+ interpretation: '万象更新,生机盎然。宜开拓进取,把握当下。',
74
+ luck: { 财运: '财气渐旺', 姻缘: '红鸾星动', 修炼: '功到自然', 出行: '宜远行' },
75
+ },
76
+ {
77
+ level: '上签',
78
+ number: 24,
79
+ poem: ['玉树临风映彩霞', '金枝含露绽奇花', '灵芝瑞草生庭院', '积德行善福无涯'],
80
+ interpretation: '德行深厚,福报自来。宜广结善缘,持续积累。',
81
+ luck: { 财运: '积少成多', 姻缘: '琴瑟和鸣', 修炼: '功德圆满', 健康: '身心康泰' },
82
+ },
83
+ {
84
+ level: '上签',
85
+ number: 31,
86
+ poem: ['锦上添花富贵春', '兰因絮果总随身', '善心常存天不负', '福田广种福临门'],
87
+ interpretation: '善有善报,勤勉有得。莫因小失大,放眼长远。',
88
+ luck: { 财运: '财喜双全', 姻缘: '美满如意', 修炼: '渐入佳境', 出行: '宜会友' },
89
+ },
90
+ {
91
+ level: '上签',
92
+ number: 38,
93
+ poem: ['苍松翠柏立山巅', '历尽风霜志更坚', '待到春来花满树', '清香一缕入云天'],
94
+ interpretation: '历经磨砺,终成正果。守得初心,方得始终。',
95
+ luck: { 财运: '厚积薄发', 姻缘: '终成眷属', 修炼: '修成正果', 健康: '筋骨强健' },
96
+ },
97
+ // ============================ 中吉签 (8) ============================
98
+ {
99
+ level: '中吉签',
100
+ number: 3,
101
+ poem: ['半江瑟瑟半江红', '渔舟唱晚入秋风', '得鱼何必羡龙鲤', '知足常乐自从容'],
102
+ interpretation: '不必强求,知足常乐。安于所得,反生欢喜。',
103
+ luck: { 财运: '平稳有余', 姻缘: '平淡是真', 修炼: '宜守静功', 健康: '宜养心' },
104
+ },
105
+ {
106
+ level: '中吉签',
107
+ number: 8,
108
+ poem: ['桃李不言下自成', '春风化雨润无声', '德修于内形于外', '不求名时名自迎'],
109
+ interpretation: '埋头耕耘,莫问收获。待以时日,自见成效。',
110
+ luck: { 财运: '细水长流', 姻缘: '日久生情', 修炼: '韬光养晦', 出行: '宜近不宜远' },
111
+ },
112
+ {
113
+ level: '中吉签',
114
+ number: 11,
115
+ poem: ['细雨润物细无声', '春苗破土向天生', '莫嫌生长来时慢', '他日参天荫众生'],
116
+ interpretation: '进步虽缓,前途可观。耐住寂寞,必有厚报。',
117
+ luck: { 财运: '渐次增进', 姻缘: '水到渠成', 修炼: '宜打基础', 健康: '宜滋补' },
118
+ },
119
+ {
120
+ level: '中吉签',
121
+ number: 15,
122
+ poem: ['行到水穷坐看云', '云起时处自氤氲', '绝处逢生须淡定', '柳暗花明又一村'],
123
+ interpretation: '困境之中,勿失信心。转机将至,静待其变。',
124
+ luck: { 财运: '先抑后扬', 姻缘: '波折转好', 修炼: '瓶颈将破', 出行: '宜谨慎' },
125
+ },
126
+ {
127
+ level: '中吉签',
128
+ number: 20,
129
+ poem: ['秋水共长天一色', '落霞与孤鹜齐飞', '诗酒趁年华正好', '且将心事付微醺'],
130
+ interpretation: '及时行乐,莫负韶华。珍惜眼前,享受当下。',
131
+ luck: { 财运: '收支平衡', 姻缘: '随缘自在', 修炼: '宜悟道法', 出行: '宜出游' },
132
+ },
133
+ {
134
+ level: '中吉签',
135
+ number: 23,
136
+ poem: ['梅须逊雪三分白', '雪却输梅一段香', '尺有所短寸有长', '取长补短方为强'],
137
+ interpretation: '各有千秋,莫相比较。扬长避短,自得其所。',
138
+ luck: { 财运: '中规中矩', 姻缘: '互相欣赏', 修炼: '宜找良师', 健康: '宜调身' },
139
+ },
140
+ {
141
+ level: '中吉签',
142
+ number: 29,
143
+ poem: ['清泉石上流涓涓', '明月松间照清妍', '清心寡欲身自在', '不慕繁华守田园'],
144
+ interpretation: '淡泊明志,宁静致远。少欲知足,身心安康。',
145
+ luck: { 财运: '够用即可', 姻缘: '平实温暖', 修炼: '宜修清心', 健康: '心宽体健' },
146
+ },
147
+ {
148
+ level: '中吉签',
149
+ number: 33,
150
+ poem: ['竹杖芒鞋轻胜马', '谁怕一蓑烟雨任', '回首向来萧瑟处', '也无风雨也无晴'],
151
+ interpretation: '宠辱不惊,去留无意。心境豁达,处处逢春。',
152
+ luck: { 财运: '随遇而安', 姻缘: '看淡得失', 修炼: '宜修心性', 出行: '无碍' },
153
+ },
154
+ // ============================ 中签 (10) ============================
155
+ {
156
+ level: '中签',
157
+ number: 4,
158
+ poem: ['山重水复疑无路', '柳暗花明又一村', '凡事不可太执着', '换个方向又逢春'],
159
+ interpretation: '此路不通,自有别径。转变思路,豁然开朗。',
160
+ luck: { 财运: '需思变通', 姻缘: '宜主动些', 修炼: '宜换法门', 健康: '注意调养' },
161
+ },
162
+ {
163
+ level: '中签',
164
+ number: 6,
165
+ poem: ['云开雾散见青天', '峰回路转又逢源', '凡事不可心太急', '循序渐进自周全'],
166
+ interpretation: '急则生乱,缓则得安。按部就班,水到渠成。',
167
+ luck: { 财运: '稳步推进', 姻缘: '急不得成', 修炼: '忌好高骛', 出行: '宜缓行' },
168
+ },
169
+ {
170
+ level: '中签',
171
+ number: 10,
172
+ poem: ['舟行碧波上有风', '波浪起伏向前涌', '心中有定方不乱', '稳坐钓台自从容'],
173
+ interpretation: '外界波动,内心当定。沉着应对,终能渡过。',
174
+ luck: { 财运: '起伏不定', 姻缘: '需经考验', 修炼: '宜修定力', 健康: '注意情绪' },
175
+ },
176
+ {
177
+ level: '中签',
178
+ number: 13,
179
+ poem: ['欲速不达古之训', '事缓则圆莫争锋', '万事皆有其时序', '顺其自然福自生'],
180
+ interpretation: '欲速不达,适得其反。顺其自然,方得圆满。',
181
+ luck: { 财运: '忌急功近利', 姻缘: '急则生变', 修炼: '忌贪功冒进', 出行: '宜择日' },
182
+ },
183
+ {
184
+ level: '中签',
185
+ number: 16,
186
+ poem: ['滴水穿石非一日', '绳锯木断见功夫', '只要功夫深到底', '铁杵磨针终成器'],
187
+ interpretation: '贵在坚持,恒者能成。日积月累,自有回报。',
188
+ luck: { 财运: '靠积攒', 姻缘: '靠经营', 修炼: '靠恒心', 健康: '靠保养' },
189
+ },
190
+ {
191
+ level: '中签',
192
+ number: 21,
193
+ poem: ['塞翁失马焉知祸', '祸福相依本无常', '得不足喜失勿忧', '泰然处之自吉祥'],
194
+ interpretation: '得失无常,泰然处之。祸福相依,不必过忧。',
195
+ luck: { 财运: '有得有失', 姻缘: '随缘就好', 修炼: '修平常心', 出行: '平常看待' },
196
+ },
197
+ {
198
+ level: '中签',
199
+ number: 22,
200
+ poem: ['千里之行始足下', '万丈高楼平地起', '大事必作于细处', '勿轻小事误全局'],
201
+ interpretation: '重视细节,脚踏实地。勿轻小事,方能成大。',
202
+ luck: { 财运: '积小为大', 姻缘: '从小事起', 修炼: '从基础始', 健康: '重日常' },
203
+ },
204
+ {
205
+ level: '中签',
206
+ number: 25,
207
+ poem: ['清官难断家务事', '且让三分又何妨', '退一步海阔天空', '忍一时风平浪静'],
208
+ interpretation: '遇事忍让,退一步海阔天空。和睦为贵,计较生烦。',
209
+ luck: { 财运: '宜守不宜争', 姻缘: '多些包容', 修炼: '宜修忍辱', 出行: '和气生财' },
210
+ },
211
+ {
212
+ level: '中签',
213
+ number: 30,
214
+ poem: ['路漫漫其修远兮', '吾将上下而求索', '前途虽远终有至', '但凭坚韧与执着'],
215
+ interpretation: '路途虽远,坚持必至。求索不止,终有所获。',
216
+ luck: { 财运: '长远看好', 姻缘: '需用耐心', 修炼: '宜耐寂寞', 出行: '宜长远' },
217
+ },
218
+ {
219
+ level: '中签',
220
+ number: 35,
221
+ poem: ['人生不如意十之', '八九何必挂心间', '且把烦恼抛脑后', '笑看明月照长天'],
222
+ interpretation: '人生难免挫折,何必自苦。放下烦恼,豁达自在。',
223
+ luck: { 财运: '平常心待', 姻缘: '莫强求', 修炼: '宜放执念', 健康: '宜开心' },
224
+ },
225
+ // ============================ 中平签 (6) ============================
226
+ {
227
+ level: '中平签',
228
+ number: 5,
229
+ poem: ['天有不测风云起', '人有旦夕祸福临', '凡事谨慎多思量', '未雨绸缪保安宁'],
230
+ interpretation: '世事难料,宜多留心。未雨绸缪,方能安稳。',
231
+ luck: { 财运: '宜储蓄', 姻缘: '需经营', 修炼: '宜守成', 出行: '宜慎行' },
232
+ },
233
+ {
234
+ level: '中平签',
235
+ number: 14,
236
+ poem: ['鱼与熊掌难兼得', '有舍有得是常理', '莫为贪心失大局', '权衡利弊再决断'],
237
+ interpretation: '取舍之间,需明智断。贪多嚼不烂,专注方有成。',
238
+ luck: { 财运: '宜专注', 姻缘: '勿三心', 修炼: '宜专一', 健康: '勿过劳' },
239
+ },
240
+ {
241
+ level: '中平签',
242
+ number: 17,
243
+ poem: ['骄兵必败古来传', '满招损来谦受益', '得意之时莫忘形', '失意之际莫灰心'],
244
+ interpretation: '胜不骄,败不馁。保持谦逊,行稳致远。',
245
+ luck: { 财运: '忌张扬', 姻缘: '忌骄纵', 修炼: '忌自满', 健康: '忌纵欲' },
246
+ },
247
+ {
248
+ level: '中平签',
249
+ number: 26,
250
+ poem: ['言多必失祸从口', '祸从口出病从口', '谨言慎行是良训', '沉默是金胜千言'],
251
+ interpretation: '谨言慎行,少说多做。管住嘴巴,免招是非。',
252
+ luck: { 财运: '忌轻信', 姻缘: '多沟通', 修炼: '忌妄语', 健康: '注意饮食' },
253
+ },
254
+ {
255
+ level: '中平签',
256
+ number: 32,
257
+ poem: ['近朱者赤近墨黑', '交友不可不谨慎', '益友相扶前路广', '损友相误悔莫及'],
258
+ interpretation: '交友需慎,近朱者赤。择善而交,受益良多。',
259
+ luck: { 财运: '慎合伙人', 姻缘: '察人品', 修炼: '择良师', 出行: '慎结伴' },
260
+ },
261
+ {
262
+ level: '中平签',
263
+ number: 37,
264
+ poem: ['树欲静而风不止', '子欲养而亲不待', '珍惜眼前人与事', '莫待失去空悲切'],
265
+ interpretation: '珍惜当下,莫留遗憾。眼前人与事,当用心待。',
266
+ luck: { 财运: '惜已有', 姻缘: '惜眼前人', 修炼: '惜机缘', 健康: '惜身体' },
267
+ },
268
+ // ============================ 下签 (4) ============================
269
+ {
270
+ level: '下签',
271
+ number: 28,
272
+ poem: ['乌云压城城欲摧', '甲光向日金鳞开', '风雨飘摇时难熬', '守得云开待晴回'],
273
+ interpretation: '运势低迷,宜守不宜进。韬光养晦,静待转机。',
274
+ luck: { 财运: '忌投资', 姻缘: '宜让步', 修炼: '宜闭关', 出行: '忌远行' },
275
+ },
276
+ {
277
+ level: '下签',
278
+ number: 34,
279
+ poem: ['逆水行舟不进退', '用力撑篙勿松懈', '此刻艰难需挺住', '一旦松劲前功弃'],
280
+ interpretation: '逆水行舟,不进则退。艰难时刻,咬牙坚持。',
281
+ luck: { 财运: '防破财', 姻缘: '需忍让', 修炼: '忌懈怠', 健康: '忌过劳' },
282
+ },
283
+ {
284
+ level: '下签',
285
+ number: 39,
286
+ poem: ['前路茫茫多坎坷', '荆棘丛生步难行', '此时不宜冒然进', '退守蓄力待时机'],
287
+ interpretation: '前路受阻,不宜冒进。退一步想,养精蓄锐。',
288
+ luck: { 财运: '宜保守', 姻缘: '宜冷静', 修炼: '宜蛰伏', 出行: '宜暂缓' },
289
+ },
290
+ {
291
+ level: '下签',
292
+ number: 40,
293
+ poem: ['病来如山倒地摧', '病去如抽丝慢慢', '此时宜静不宜动', '调养生息待春回'],
294
+ interpretation: '身心疲惫,宜静养息。莫勉强为之,调养为先。',
295
+ luck: { 财运: '防损耗', 姻缘: '宜独处', 修炼: '宜固本', 健康: '重调养' },
296
+ },
297
+ ];
298
+ /** 等级权重:上上签稀有,中签居多 */
299
+ exports.LEVEL_WEIGHTS = {
300
+ 上上签: 3,
301
+ 上签: 6,
302
+ 中吉签: 9,
303
+ 中签: 12,
304
+ 中平签: 7,
305
+ 下签: 4,
306
+ };
package/lib/index.d.ts ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * koishi-plugin-bns-fortune
3
+ * 剑灵风格每日好运签 —— Canvas 绘制,每日一签,文字可变
4
+ */
5
+ import { Context, Schema } from 'koishi';
6
+ import { Fortune } from './fortunes';
7
+ export interface Config {
8
+ /** 随机密钥:改它会重洗全局结果 */
9
+ masterKey: string;
10
+ /** 自定义背景图路径/URL(为空则用代码绘制模板) */
11
+ customBackground: string;
12
+ /** 字体族名(需已注册或系统自带) */
13
+ fontFamily: string;
14
+ /** 配色主题 */
15
+ theme: 'ink' | 'gold-red';
16
+ /** 字体目录(插件会尝试自动注册其中的 ttf/otf) */
17
+ fontsDir: string;
18
+ /** 自定义签文库(覆盖内置) */
19
+ results: Fortune[];
20
+ }
21
+ export declare const Config: Schema<Config>;
22
+ export declare const name = "bns-fortune";
23
+ export declare const reusable = true;
24
+ export { Config as config };
25
+ export declare function apply(ctx: Context, config: Config): void;
26
+ declare const _default: {
27
+ name: string;
28
+ apply: typeof apply;
29
+ Config: Schema<Config>;
30
+ };
31
+ export default _default;
package/lib/index.js ADDED
@@ -0,0 +1,169 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.config = exports.reusable = exports.name = exports.Config = void 0;
4
+ exports.apply = apply;
5
+ /**
6
+ * koishi-plugin-bns-fortune
7
+ * 剑灵风格每日好运签 —— Canvas 绘制,每日一签,文字可变
8
+ */
9
+ const koishi_1 = require("koishi");
10
+ const node_path_1 = require("node:path");
11
+ const node_fs_1 = require("node:fs");
12
+ const fortunes_1 = require("./fortunes");
13
+ const fortune_1 = require("./fortune");
14
+ const render_1 = require("./render");
15
+ const logger = new koishi_1.Logger('bns-fortune');
16
+ exports.Config = koishi_1.Schema.intersect([
17
+ koishi_1.Schema.object({
18
+ masterKey: koishi_1.Schema.string()
19
+ .description('随机密钥。修改后所有用户的签会被重新打乱。')
20
+ .default('bns-fortune-2024'),
21
+ theme: koishi_1.Schema.union([
22
+ koishi_1.Schema.const('ink').description('剑灵·水墨(墨黑朱砂赤金)'),
23
+ koishi_1.Schema.const('gold-red').description('传统·朱金'),
24
+ ]).description('配色主题。').default('ink'),
25
+ fontFamily: koishi_1.Schema.string()
26
+ .description('签图正文字体族名。需为系统字体或已注册字体,否则回退楷体。')
27
+ .default(''),
28
+ fontsDir: koishi_1.Schema.path({
29
+ filters: ['directory'],
30
+ allowCreate: true,
31
+ }).description('字体目录。插件启动时会把其中的 ttf/otf 自动注册,便于中文字体渲染。留空则不注册。')
32
+ .default(''),
33
+ customBackground: koishi_1.Schema.string()
34
+ .description('自定义背景图。可填本地图片绝对路径或 http(s) URL。留空则使用代码绘制的剑灵水墨模板。')
35
+ .default(''),
36
+ results: koishi_1.Schema.array(koishi_1.Schema.object({
37
+ level: koishi_1.Schema.union([
38
+ koishi_1.Schema.const('上上签'), koishi_1.Schema.const('上签'), koishi_1.Schema.const('中吉签'),
39
+ koishi_1.Schema.const('中签'), koishi_1.Schema.const('中平签'), koishi_1.Schema.const('下签'),
40
+ ]).description('签等级。'),
41
+ number: koishi_1.Schema.number().description('签号。'),
42
+ poem: koishi_1.Schema.array(koishi_1.Schema.string()).description('四句签诗。'),
43
+ interpretation: koishi_1.Schema.string().description('白话解签。'),
44
+ luck: koishi_1.Schema.object({
45
+ 财运: koishi_1.Schema.string(),
46
+ 姻缘: koishi_1.Schema.string(),
47
+ 修炼: koishi_1.Schema.string(),
48
+ 健康: koishi_1.Schema.string(),
49
+ 出行: koishi_1.Schema.string(),
50
+ }).description('分类运势。'),
51
+ }).description('签文对象')).description('自定义签文库(高级)。全覆盖内置 40 签,留空则用内置库。')
52
+ .default([]),
53
+ }),
54
+ ]);
55
+ exports.config = exports.Config;
56
+ exports.name = 'bns-fortune';
57
+ exports.reusable = true;
58
+ /**
59
+ * 获取 canvas 服务。
60
+ * koishi-plugin-canvas(基于 skia-canvas)会注入 ctx.canvas。
61
+ */
62
+ function getCanvas(ctx) {
63
+ // koishi-plugin-canvas 注入的服务挂载在 ctx.canvas
64
+ const canvas = ctx.canvas;
65
+ if (!canvas) {
66
+ throw new Error('未检测到 canvas 服务。请在 Koishi 中安装并启用 "koishi-plugin-canvas" 插件,本插件依赖它来绘制签图。');
67
+ }
68
+ return canvas;
69
+ }
70
+ /** 注册字体目录下的 ttf/otf(若提供) */
71
+ function tryRegisterFonts(dir) {
72
+ if (!dir)
73
+ return;
74
+ const abs = (0, node_path_1.resolve)(dir);
75
+ if (!(0, node_fs_1.existsSync)(abs)) {
76
+ logger.warn('字体目录不存在,跳过注册:%s', abs);
77
+ return;
78
+ }
79
+ let files = [];
80
+ try {
81
+ files = (0, node_fs_1.readdirSync)(abs);
82
+ }
83
+ catch (e) {
84
+ logger.warn('读取字体目录失败:%o', e);
85
+ return;
86
+ }
87
+ // skia-canvas 提供 Fontlibrary.use;node-canvas 提供 registerFont。
88
+ // 两者都尝试,命中其一即可。
89
+ for (const f of files) {
90
+ const lower = f.toLowerCase();
91
+ if (!lower.endsWith('.ttf') && !lower.endsWith('.otf'))
92
+ continue;
93
+ const full = (0, node_path_1.resolve)(abs, f);
94
+ const family = f.replace(/\.(ttf|otf)$/i, '');
95
+ try {
96
+ // skia-canvas
97
+ const skia = globalThis.FontLibrary;
98
+ if (skia?.use) {
99
+ skia.use(family, [full]);
100
+ continue;
101
+ }
102
+ }
103
+ catch { /* ignore */ }
104
+ try {
105
+ // node-canvas
106
+ const nodeCanvas = require('canvas');
107
+ if (typeof nodeCanvas.registerFont === 'function') {
108
+ nodeCanvas.registerFont(full, { family });
109
+ }
110
+ }
111
+ catch { /* ignore */ }
112
+ }
113
+ }
114
+ function apply(ctx, config) {
115
+ // 启动时注册字体(失败不阻断)
116
+ try {
117
+ tryRegisterFonts(config.fontsDir);
118
+ }
119
+ catch (e) {
120
+ logger.warn('字体注册异常:%o', e);
121
+ }
122
+ const library = Array.isArray(config.results) && config.results.length > 0
123
+ ? config.results
124
+ : fortunes_1.FORTUNES;
125
+ const theme = render_1.THEMES[config.theme] ?? render_1.THEMES.ink;
126
+ let renderer;
127
+ function getRenderer() {
128
+ if (!renderer)
129
+ renderer = new render_1.FortuneRenderer(getCanvas(ctx));
130
+ return renderer;
131
+ }
132
+ // 每日每用户渲染缓存:key = `${date}|${userId}`
133
+ const renderCache = new Map();
134
+ // 每天换日时清理
135
+ ctx.setInterval(() => renderCache.clear(), 60 * 60 * 1000);
136
+ const cmd = ctx
137
+ .command('fortune', '剑灵每日好运签')
138
+ .alias('求签')
139
+ .alias('jrrp')
140
+ .action(async ({ session }) => {
141
+ if (!session?.userId)
142
+ return '无法识别用户。';
143
+ const date = (0, fortune_1.formatDate)(new Date());
144
+ const cacheKey = `${date}|${session.userId}`;
145
+ // 命中缓存
146
+ const cached = renderCache.get(cacheKey);
147
+ if (cached)
148
+ return koishi_1.h.image(cached, 'image/png');
149
+ try {
150
+ const { fortune } = (0, fortune_1.drawFortune)(date, session.userId, config.masterKey, library);
151
+ const png = await getRenderer().render({
152
+ fortune,
153
+ date,
154
+ nickname: session.username || session.author?.nickname,
155
+ fontFamily: config.fontFamily,
156
+ theme,
157
+ customBackground: config.customBackground || undefined,
158
+ });
159
+ renderCache.set(cacheKey, png);
160
+ return koishi_1.h.image(png, 'image/png');
161
+ }
162
+ catch (e) {
163
+ logger.warn('抽签/渲染失败:%o', e);
164
+ return '抽签失败:' + (e instanceof Error ? e.message : String(e));
165
+ }
166
+ });
167
+ ctx.on('dispose', () => cmd.dispose());
168
+ }
169
+ exports.default = { name: exports.name, apply, Config: exports.Config };
@@ -0,0 +1,76 @@
1
+ /**
2
+ * 渲染:背景模板(Canvas 重绘)+ 动态文字叠加
3
+ *
4
+ * 设计思路:
5
+ * - 背景(绸带/印章框/福字暗纹/边框)由代码绘制,按 主题+尺寸 缓存复用,
6
+ * 支持 customBackground 配置项替换为外部图片。
7
+ * - 动态文字(签等级/签诗/解签/运势/日期/昵称/签号)每次叠加在背景之上。
8
+ * - 配色用剑灵水墨风(墨黑 / 朱砂红 / 赤金 / 米色),装饰加入剑气、符文元素。
9
+ */
10
+ import { Fortune } from './fortunes';
11
+ export interface CanvasLike {
12
+ width: number;
13
+ height: number;
14
+ getContext(type: '2d'): CanvasRenderingContext2D;
15
+ /** 兼容 skia-canvas / node-canvas:不传参数时两者都默认返回 PNG Buffer */
16
+ toBuffer(format?: string, config?: unknown): Buffer;
17
+ }
18
+ export interface ImageLike {
19
+ width: number;
20
+ height: number;
21
+ }
22
+ export interface CanvasService {
23
+ createCanvas(width: number, height: number): CanvasLike;
24
+ loadImage(source: string | Buffer): Promise<ImageLike>;
25
+ }
26
+ export interface Theme {
27
+ /** 主题名 */
28
+ name: string;
29
+ /** 米色底(中心) */
30
+ bg: string;
31
+ /** 边缘渐变深色 */
32
+ bgDark: string;
33
+ /** 主文字色(水墨黑) */
34
+ ink: string;
35
+ /** 朱砂红(绸带、印章、解签) */
36
+ red: string;
37
+ /** 赤金(描边、标题、装饰) */
38
+ gold: string;
39
+ /** 暗金(福字暗纹) */
40
+ goldDim: string;
41
+ /** 卷轴内衬底色(半透明) */
42
+ scroll: string;
43
+ }
44
+ export declare const THEMES: Record<string, Theme>;
45
+ export declare const W = 600;
46
+ export declare const H = 860;
47
+ export interface RenderInput {
48
+ fortune: Fortune;
49
+ /** YYYY-MM-DD */
50
+ date: string;
51
+ /** 用户昵称 */
52
+ nickname?: string;
53
+ /** 字体族(需已注册或为系统字体) */
54
+ fontFamily: string;
55
+ /** 主题 */
56
+ theme: Theme;
57
+ /** 自定义背景图路径/URL;为空则用代码绘制 */
58
+ customBackground?: string;
59
+ }
60
+ export declare class FortuneRenderer {
61
+ private canvas;
62
+ /** 背景缓存:key = `${themeName}|${customBg ?? 'builtin'}` */
63
+ private bgCache;
64
+ /** 自定义背景图缓存:key = url */
65
+ private imageCache;
66
+ /** 缓存条数上限 */
67
+ private static MAX_CACHE;
68
+ constructor(canvas: CanvasService);
69
+ private evict;
70
+ /** 字体声明字符串,含中文回退 */
71
+ private fontStack;
72
+ /** 获取(或绘制并缓存)背景画布 */
73
+ private getBackground;
74
+ /** 渲染整张签图,返回 PNG Buffer */
75
+ render(input: RenderInput): Promise<Buffer>;
76
+ }
package/lib/render.js ADDED
@@ -0,0 +1,438 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.FortuneRenderer = exports.H = exports.W = exports.THEMES = void 0;
4
+ exports.THEMES = {
5
+ /** 剑灵·水墨:墨黑朱砂赤金 */
6
+ ink: {
7
+ name: '剑灵·水墨',
8
+ bg: '#f3e9d2',
9
+ bgDark: '#d9c79a',
10
+ ink: '#1c1a17',
11
+ red: '#8e1a1a',
12
+ gold: '#c9a24b',
13
+ goldDim: '#b89a5c',
14
+ scroll: 'rgba(253,247,232,0.55)',
15
+ },
16
+ /** 传统·朱金:朱红描金 */
17
+ 'gold-red': {
18
+ name: '传统·朱金',
19
+ bg: '#fdf3df',
20
+ bgDark: '#e8c98c',
21
+ ink: '#3a1d1d',
22
+ red: '#c0392b',
23
+ gold: '#d4af37',
24
+ goldDim: '#caa84a',
25
+ scroll: 'rgba(255,250,235,0.6)',
26
+ },
27
+ };
28
+ /* ------------------------------------------------------------------ */
29
+ /* 画布尺寸 */
30
+ /* ------------------------------------------------------------------ */
31
+ exports.W = 600;
32
+ exports.H = 860;
33
+ class FortuneRenderer {
34
+ constructor(canvas) {
35
+ this.canvas = canvas;
36
+ /** 背景缓存:key = `${themeName}|${customBg ?? 'builtin'}` */
37
+ this.bgCache = new Map();
38
+ /** 自定义背景图缓存:key = url */
39
+ this.imageCache = new Map();
40
+ }
41
+ evict(map) {
42
+ while (map.size > FortuneRenderer.MAX_CACHE) {
43
+ const first = map.keys().next().value;
44
+ map.delete(first);
45
+ }
46
+ }
47
+ /** 字体声明字符串,含中文回退 */
48
+ fontStack(family) {
49
+ const f = family && family.trim();
50
+ const main = f ? `"${f}",` : '';
51
+ return `${main}"LXGW WenKai","KaiTi","STKaiti","楷体","Microsoft YaHei","微软雅黑","SimSun","宋体",serif`;
52
+ }
53
+ /** 获取(或绘制并缓存)背景画布 */
54
+ async getBackground(theme, customBg) {
55
+ const key = `${theme.name}|${customBg ?? 'builtin'}`;
56
+ const cached = this.bgCache.get(key);
57
+ if (cached)
58
+ return cached;
59
+ let bg;
60
+ if (customBg) {
61
+ // 自定义背景:加载图片并缩放绘制到画布
62
+ let img = this.imageCache.get(customBg);
63
+ if (!img) {
64
+ img = await this.canvas.loadImage(customBg);
65
+ this.imageCache.set(customBg, img);
66
+ this.evict(this.imageCache);
67
+ }
68
+ bg = this.canvas.createCanvas(exports.W, exports.H);
69
+ const ctx = bg.getContext('2d');
70
+ // 先填底色,避免透明
71
+ ctx.fillStyle = theme.bg;
72
+ ctx.fillRect(0, 0, exports.W, exports.H);
73
+ // 等比铺满(cover)
74
+ const scale = Math.max(exports.W / img.width, exports.H / img.height);
75
+ const dw = img.width * scale;
76
+ const dh = img.height * scale;
77
+ ctx.drawImage(img, (exports.W - dw) / 2, (exports.H - dh) / 2, dw, dh);
78
+ // 仍叠加边框与暗角,保持风格统一
79
+ drawVignette(ctx, exports.W, exports.H, theme);
80
+ drawBorder(ctx, exports.W, exports.H, theme);
81
+ }
82
+ else {
83
+ bg = this.canvas.createCanvas(exports.W, exports.H);
84
+ const ctx = bg.getContext('2d');
85
+ drawBackground(ctx, exports.W, exports.H, theme);
86
+ }
87
+ this.bgCache.set(key, bg);
88
+ this.evict(this.bgCache);
89
+ return bg;
90
+ }
91
+ /** 渲染整张签图,返回 PNG Buffer */
92
+ async render(input) {
93
+ const { fortune, date, nickname, fontFamily, theme, customBackground } = input;
94
+ const bg = await this.getBackground(theme, customBackground);
95
+ const canvas = this.canvas.createCanvas(exports.W, exports.H);
96
+ const ctx = canvas.getContext('2d');
97
+ ctx.drawImage(bg, 0, 0);
98
+ // 2. 顶部绸带 + 签等级标题(动态)
99
+ drawRibbon(ctx, exports.W / 2, 78, theme);
100
+ drawLevelTitle(ctx, exports.W / 2, 86, fortune.level, this.fontStack(fontFamily), theme);
101
+ // 3. 圆形印章装饰(固定文字「灵签」,剑灵符文感)
102
+ drawSeal(ctx, exports.W / 2, 178, 52, theme);
103
+ // 4. 签诗竖排(主体)
104
+ drawPoemVertical(ctx, exports.W / 2, 286, fortune.poem, this.fontStack(fontFamily), theme);
105
+ // 5. 解签(横排)
106
+ drawInterpretation(ctx, exports.W / 2, 566, fortune.interpretation, this.fontStack(fontFamily), theme);
107
+ // 6. 运势小标签
108
+ drawLuckTags(ctx, exports.W / 2, 638, fortune.luck, this.fontStack(fontFamily), theme);
109
+ // 7. 底部:日期 / 昵称 / 签号
110
+ drawFooter(ctx, exports.W, exports.H, date, nickname, fortune.number, this.fontStack(fontFamily), theme);
111
+ // 不传参数:skia-canvas 与 node-canvas 均默认返回 PNG,兼容性最好
112
+ return canvas.toBuffer();
113
+ }
114
+ }
115
+ exports.FortuneRenderer = FortuneRenderer;
116
+ /** 缓存条数上限 */
117
+ FortuneRenderer.MAX_CACHE = 12;
118
+ /* ================================================================== */
119
+ /* 背景绘制 */
120
+ /* ================================================================== */
121
+ function drawBackground(ctx, w, h, theme) {
122
+ // 1. 米色径向渐变底
123
+ const grad = ctx.createRadialGradient(w / 2, h * 0.42, 60, w / 2, h * 0.5, h * 0.75);
124
+ grad.addColorStop(0, theme.bg);
125
+ grad.addColorStop(1, theme.bgDark);
126
+ ctx.fillStyle = grad;
127
+ ctx.fillRect(0, 0, w, h);
128
+ // 2. 福字暗纹(平铺)
129
+ drawFuPattern(ctx, w, h, theme);
130
+ // 3. 暗角
131
+ drawVignette(ctx, w, h, theme);
132
+ // 4. 双层金边框 + 四角符文
133
+ drawBorder(ctx, w, h, theme);
134
+ }
135
+ /** 福字暗纹:稀疏平铺的半透明「福」字 */
136
+ function drawFuPattern(ctx, w, h, theme) {
137
+ ctx.save();
138
+ ctx.fillStyle = theme.goldDim;
139
+ ctx.globalAlpha = 0.08;
140
+ ctx.textAlign = 'center';
141
+ ctx.textBaseline = 'middle';
142
+ ctx.font = `64px ${fontBase()}`;
143
+ const step = 150;
144
+ for (let y = step / 2; y < h; y += step) {
145
+ for (let x = step / 2; x < w; x += step) {
146
+ // 错位排列
147
+ const offset = (Math.floor(y / step) % 2) * (step / 2);
148
+ ctx.fillText('福', x + offset, y);
149
+ }
150
+ }
151
+ ctx.restore();
152
+ }
153
+ /** 暗角:四角加深 */
154
+ function drawVignette(ctx, w, h, theme) {
155
+ ctx.save();
156
+ const g = ctx.createRadialGradient(w / 2, h / 2, h * 0.3, w / 2, h / 2, h * 0.75);
157
+ g.addColorStop(0, 'rgba(0,0,0,0)');
158
+ g.addColorStop(1, 'rgba(60,40,20,0.28)');
159
+ ctx.fillStyle = g;
160
+ ctx.fillRect(0, 0, w, h);
161
+ ctx.restore();
162
+ }
163
+ /** 双层金边框 + 四角剑形符文 */
164
+ function drawBorder(ctx, w, h, theme) {
165
+ ctx.save();
166
+ // 外粗金线
167
+ ctx.strokeStyle = theme.gold;
168
+ ctx.lineWidth = 3;
169
+ ctx.strokeRect(22, 22, w - 44, h - 44);
170
+ // 内细朱线
171
+ ctx.strokeStyle = theme.red;
172
+ ctx.lineWidth = 1;
173
+ ctx.strokeRect(30, 30, w - 60, h - 60);
174
+ // 四角剑形符文装饰
175
+ const corners = [
176
+ [30, 30], [w - 30, 30], [30, h - 30], [w - 30, h - 30],
177
+ ];
178
+ ctx.fillStyle = theme.gold;
179
+ ctx.globalAlpha = 0.85;
180
+ for (const [cx, cy] of corners)
181
+ drawCornerSigil(ctx, cx, cy, theme);
182
+ ctx.restore();
183
+ }
184
+ /** 四角小符文:六芒剑纹 */
185
+ function drawCornerSigil(ctx, cx, cy, theme) {
186
+ ctx.save();
187
+ ctx.translate(cx, cy);
188
+ ctx.strokeStyle = theme.gold;
189
+ ctx.lineWidth = 1.2;
190
+ ctx.beginPath();
191
+ // 一个小菱形 + 中心点,象征剑锋符文
192
+ const r = 7;
193
+ ctx.moveTo(0, -r);
194
+ ctx.lineTo(r, 0);
195
+ ctx.lineTo(0, r);
196
+ ctx.lineTo(-r, 0);
197
+ ctx.closePath();
198
+ ctx.stroke();
199
+ ctx.fillStyle = theme.red;
200
+ ctx.beginPath();
201
+ ctx.arc(0, 0, 1.6, 0, Math.PI * 2);
202
+ ctx.fill();
203
+ ctx.restore();
204
+ }
205
+ /* ================================================================== */
206
+ /* 动态元素绘制 */
207
+ /* ================================================================== */
208
+ /** 顶部红色绸带(含两端剑形流苏) */
209
+ function drawRibbon(ctx, cx, cy, theme) {
210
+ const w = 380;
211
+ const hh = 64;
212
+ const x0 = cx - w / 2;
213
+ ctx.save();
214
+ // 绸带主体(带下凹弧线)
215
+ ctx.fillStyle = theme.red;
216
+ ctx.beginPath();
217
+ ctx.moveTo(x0 - 18, cy - hh / 2);
218
+ ctx.lineTo(x0 + w + 18, cy - hh / 2);
219
+ ctx.quadraticCurveTo(x0 + w + 30, cy, x0 + w + 18, cy + hh / 2);
220
+ ctx.lineTo(x0 - 18, cy + hh / 2);
221
+ ctx.quadraticCurveTo(x0 - 30, cy, x0 - 18, cy - hh / 2);
222
+ ctx.closePath();
223
+ ctx.fill();
224
+ // 金色描边
225
+ ctx.strokeStyle = theme.gold;
226
+ ctx.lineWidth = 2;
227
+ ctx.stroke();
228
+ // 两端剑形流苏
229
+ drawSwordTassel(ctx, x0 - 18, cy, -1, theme);
230
+ drawSwordTassel(ctx, x0 + w + 18, cy, 1, theme);
231
+ // 绸带高光
232
+ ctx.fillStyle = 'rgba(255,255,255,0.12)';
233
+ ctx.fillRect(x0 - 6, cy - hh / 2 + 4, w + 12, 8);
234
+ ctx.restore();
235
+ }
236
+ /** 剑形流苏:绸带末端的小剑形装饰 */
237
+ function drawSwordTassel(ctx, x, y, dir, theme) {
238
+ ctx.save();
239
+ ctx.translate(x, y);
240
+ ctx.scale(dir, 1);
241
+ ctx.fillStyle = theme.gold;
242
+ // 剑身
243
+ ctx.beginPath();
244
+ ctx.moveTo(0, -3);
245
+ ctx.lineTo(22, -3);
246
+ ctx.lineTo(28, 0);
247
+ ctx.lineTo(22, 3);
248
+ ctx.lineTo(0, 3);
249
+ ctx.closePath();
250
+ ctx.fill();
251
+ // 流苏穗
252
+ ctx.strokeStyle = theme.gold;
253
+ ctx.lineWidth = 1.5;
254
+ ctx.beginPath();
255
+ ctx.moveTo(28, 0);
256
+ ctx.lineTo(40, 0);
257
+ ctx.stroke();
258
+ ctx.restore();
259
+ }
260
+ /** 绸带上的签等级标题 */
261
+ function drawLevelTitle(ctx, cx, cy, level, font, theme) {
262
+ ctx.save();
263
+ ctx.textAlign = 'center';
264
+ ctx.textBaseline = 'middle';
265
+ ctx.font = `bold 34px ${font}`;
266
+ // 金色描边 + 米黄填充,呈现烫金感
267
+ ctx.lineWidth = 5;
268
+ ctx.strokeStyle = theme.red;
269
+ ctx.strokeText(level, cx, cy);
270
+ ctx.lineWidth = 2;
271
+ ctx.strokeStyle = theme.gold;
272
+ ctx.strokeText(level, cx, cy);
273
+ ctx.fillStyle = '#fff4cf';
274
+ ctx.fillText(level, cx, cy);
275
+ ctx.restore();
276
+ }
277
+ /** 圆形印章:固定篆书感「灵签」+ 剑气光线 */
278
+ function drawSeal(ctx, cx, cy, r, theme) {
279
+ ctx.save();
280
+ // 外圈剑气光线
281
+ ctx.strokeStyle = theme.gold;
282
+ ctx.globalAlpha = 0.5;
283
+ ctx.lineWidth = 1;
284
+ for (let i = 0; i < 24; i++) {
285
+ const a = (i / 24) * Math.PI * 2;
286
+ ctx.beginPath();
287
+ ctx.moveTo(cx + Math.cos(a) * (r + 6), cy + Math.sin(a) * (r + 6));
288
+ ctx.lineTo(cx + Math.cos(a) * (r + 12), cy + Math.sin(a) * (r + 12));
289
+ ctx.stroke();
290
+ }
291
+ ctx.globalAlpha = 1;
292
+ // 外圆(朱红)
293
+ ctx.strokeStyle = theme.red;
294
+ ctx.lineWidth = 3;
295
+ ctx.beginPath();
296
+ ctx.arc(cx, cy, r, 0, Math.PI * 2);
297
+ ctx.stroke();
298
+ // 内圆(金)
299
+ ctx.strokeStyle = theme.gold;
300
+ ctx.lineWidth = 1.5;
301
+ ctx.beginPath();
302
+ ctx.arc(cx, cy, r - 6, 0, Math.PI * 2);
303
+ ctx.stroke();
304
+ // 中心文字「灵签」竖排
305
+ ctx.fillStyle = theme.red;
306
+ ctx.textAlign = 'center';
307
+ ctx.textBaseline = 'middle';
308
+ ctx.font = `bold 22px ${fontBase()}`;
309
+ ctx.fillText('灵', cx, cy - 13);
310
+ ctx.fillText('签', cx, cy + 13);
311
+ ctx.restore();
312
+ }
313
+ /** 签诗竖排(右起,每列 7 字) */
314
+ function drawPoemVertical(ctx, cx, startY, poem, font, theme) {
315
+ ctx.save();
316
+ ctx.fillStyle = theme.ink;
317
+ ctx.textAlign = 'center';
318
+ ctx.textBaseline = 'middle';
319
+ const fontSize = 31;
320
+ const lineGap = 40; // 字间距
321
+ const colGap = 60; // 列间距
322
+ ctx.font = `${fontSize}px ${font}`;
323
+ // 右起:第 0 句最右
324
+ const cols = poem.length;
325
+ const firstColX = cx + ((cols - 1) / 2) * colGap;
326
+ poem.forEach((line, i) => {
327
+ const x = firstColX - i * colGap;
328
+ for (let j = 0; j < line.length; j++) {
329
+ ctx.fillText(line[j], x, startY + j * lineGap);
330
+ }
331
+ });
332
+ ctx.restore();
333
+ }
334
+ /** 解签(横排,居中,自动换行) */
335
+ function drawInterpretation(ctx, cx, cy, text, font, theme) {
336
+ ctx.save();
337
+ ctx.fillStyle = theme.red;
338
+ ctx.textAlign = 'center';
339
+ ctx.textBaseline = 'middle';
340
+ ctx.font = `20px ${font}`;
341
+ const maxWidth = 460;
342
+ const lines = wrapText(ctx, `解曰:${text}`, maxWidth);
343
+ const lineHeight = 28;
344
+ const startY = cy - ((lines.length - 1) * lineHeight) / 2;
345
+ lines.forEach((ln, i) => ctx.fillText(ln, cx, startY + i * lineHeight));
346
+ ctx.restore();
347
+ }
348
+ /** 运势小标签(横排) */
349
+ function drawLuckTags(ctx, cx, cy, luck, font, theme) {
350
+ const entries = Object.entries(luck);
351
+ if (entries.length === 0)
352
+ return;
353
+ ctx.save();
354
+ ctx.textAlign = 'center';
355
+ ctx.textBaseline = 'middle';
356
+ ctx.font = `16px ${font}`;
357
+ const tagW = 150;
358
+ const tagH = 44;
359
+ const gap = 14;
360
+ const totalW = entries.length * tagW + (entries.length - 1) * gap;
361
+ const startX = cx - totalW / 2;
362
+ entries.forEach(([key, val], i) => {
363
+ const x = startX + i * (tagW + gap);
364
+ // 标签底
365
+ roundRect(ctx, x, cy - tagH / 2, tagW, tagH, 8);
366
+ ctx.fillStyle = theme.scroll;
367
+ ctx.fill();
368
+ ctx.strokeStyle = theme.gold;
369
+ ctx.lineWidth = 1.2;
370
+ ctx.stroke();
371
+ // 分类名(金)
372
+ ctx.fillStyle = theme.gold;
373
+ ctx.font = `bold 15px ${font}`;
374
+ ctx.fillText(key, x + tagW * 0.24, cy);
375
+ // 内容(墨)
376
+ ctx.fillStyle = theme.ink;
377
+ ctx.font = `15px ${font}`;
378
+ // 内容可能较长,截断
379
+ const maxLen = 5;
380
+ const shown = val.length > maxLen ? val.slice(0, maxLen) + '…' : val;
381
+ ctx.fillText(shown, x + tagW * 0.66, cy);
382
+ });
383
+ ctx.restore();
384
+ }
385
+ /** 底部:日期 / 昵称 / 签号 */
386
+ function drawFooter(ctx, w, h, date, nickname, number, font, theme) {
387
+ ctx.save();
388
+ ctx.fillStyle = theme.gold;
389
+ ctx.textBaseline = 'middle';
390
+ ctx.font = `18px ${font}`;
391
+ // 左:日期
392
+ ctx.textAlign = 'left';
393
+ ctx.fillText(date, 56, h - 50);
394
+ // 右:昵称
395
+ ctx.textAlign = 'right';
396
+ ctx.fillText(nickname ? `${nickname} 求得` : '', w - 56, h - 50);
397
+ // 中:签号
398
+ ctx.textAlign = 'center';
399
+ ctx.fillStyle = theme.red;
400
+ ctx.font = `bold 20px ${font}`;
401
+ ctx.fillText(`第 ${number} 签`, w / 2, h - 50);
402
+ ctx.restore();
403
+ }
404
+ /* ================================================================== */
405
+ /* 工具函数 */
406
+ /* ================================================================== */
407
+ /** 基础中文字体栈(用于装饰固定文字) */
408
+ function fontBase() {
409
+ return `"LXGW WenKai","KaiTi","STKaiti","楷体","SimSun","宋体",serif`;
410
+ }
411
+ /** 中文/英文混排自动换行 */
412
+ function wrapText(ctx, text, maxWidth) {
413
+ const lines = [];
414
+ let current = '';
415
+ for (const ch of text) {
416
+ const test = current + ch;
417
+ if (ctx.measureText(test).width > maxWidth && current) {
418
+ lines.push(current);
419
+ current = ch;
420
+ }
421
+ else {
422
+ current = test;
423
+ }
424
+ }
425
+ if (current)
426
+ lines.push(current);
427
+ return lines;
428
+ }
429
+ /** 圆角矩形路径 */
430
+ function roundRect(ctx, x, y, w, h, r) {
431
+ ctx.beginPath();
432
+ ctx.moveTo(x + r, y);
433
+ ctx.arcTo(x + w, y, x + w, y + h, r);
434
+ ctx.arcTo(x + w, y + h, x, y + h, r);
435
+ ctx.arcTo(x, y + h, x, y, r);
436
+ ctx.arcTo(x, y, x + w, y, r);
437
+ ctx.closePath();
438
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "koishi-plugin-bns-fortune",
3
+ "description": "剑灵风格每日好运签:每日一签,Canvas 绘制签图,文字可变,支持自定义背景与主题。",
4
+ "version": "1.0.1",
5
+ "main": "lib/index.js",
6
+ "typings": "lib/index.d.ts",
7
+ "files": [
8
+ "lib"
9
+ ],
10
+ "koishi": {
11
+ "description": {
12
+ "zh": "剑灵好运签(每日一签)"
13
+ },
14
+ "manifest": {
15
+ "services": [
16
+ "canvas"
17
+ ]
18
+ }
19
+ },
20
+ "keywords": [
21
+ "chatbot",
22
+ "koishi",
23
+ "plugin",
24
+ "fortune",
25
+ "签",
26
+ "好运签",
27
+ "剑灵",
28
+ "canvas"
29
+ ],
30
+ "license": "MIT",
31
+ "scripts": {
32
+ "build": "tsc -b",
33
+ "clean": "rimraf lib",
34
+ "typecheck": "tsc --noEmit"
35
+ },
36
+ "peerDependencies": {
37
+ "koishi": "^4.15.0",
38
+ "koishi-plugin-canvas": "^0.2.2",
39
+ "canvas": "^2.0.0"
40
+ },
41
+ "peerDependenciesMeta": {
42
+ "canvas": {
43
+ "optional": true
44
+ }
45
+ },
46
+ "devDependencies": {
47
+ "koishi": "^4.15.0",
48
+ "koishi-plugin-canvas": "^0.2.2",
49
+ "rimraf": "^5.0.0",
50
+ "typescript": "^5.4.0"
51
+ }
52
+ }