@mar7th/firework 1.0.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.
@@ -0,0 +1,302 @@
1
+ export const MAX_PARTICLES = 1400;
2
+
3
+ export const defaultConfig = {
4
+ particleCount: 180,
5
+ particleMinSize: 2.5,
6
+ particleMaxSize: 7,
7
+ sizeEndMultiplier: 0.35,
8
+ gravity: 0.18,
9
+ damping: 0.982,
10
+ initialSpeedMin: 2.8,
11
+ initialSpeedMax: 8.2,
12
+ particleLife: 94,
13
+ rotationSpeed: 0.025,
14
+ colorShiftRate: 0.035,
15
+ colorStart: "#ffbf4d",
16
+ colorEnd: "#ff5a3c",
17
+ useRandomColors: false,
18
+ textureType: "circle",
19
+ glowIntensity: 0.9,
20
+ explosionShape: "sphere",
21
+ enableSecondaryBurst: false,
22
+ secondaryBurstRatio: 0.2,
23
+ enableTrails: true,
24
+ showLaunchTrail: true,
25
+ windEnabled: false,
26
+ windX: 0,
27
+ windY: 0,
28
+ flicker: true,
29
+ fadeTrail: 0.18,
30
+ autoLaunch: false,
31
+ autoInterval: 900,
32
+ autoLaunchIntervalSeconds: 0.9,
33
+ backgroundType: "starfield",
34
+ backgroundColor: "#170d17",
35
+ backgroundColor2: "#3a1a24",
36
+ backgroundImageMode: "cover"
37
+ };
38
+
39
+ export const controlsSchema = [
40
+ {
41
+ title: "样式",
42
+ controls: [
43
+ { key: "colorStart", label: "起始颜色", type: "color" },
44
+ { key: "colorEnd", label: "结束颜色", type: "color" },
45
+ { key: "useRandomColors", label: "随机颜色", type: "boolean" },
46
+ {
47
+ key: "textureType",
48
+ label: "粒子形状",
49
+ type: "select",
50
+ options: [
51
+ ["circle", "圆形"],
52
+ ["square", "方形"],
53
+ ["star", "星形"],
54
+ ["heart", "心形"]
55
+ ]
56
+ },
57
+ { key: "glowIntensity", label: "辉光强度", type: "range", min: 0, max: 1.5, step: 0.05 }
58
+ ]
59
+ },
60
+ {
61
+ title: "特效",
62
+ controls: [
63
+ {
64
+ key: "explosionShape",
65
+ label: "爆炸形状",
66
+ type: "select",
67
+ options: [
68
+ ["sphere", "球形"],
69
+ ["ring", "环形"],
70
+ ["starburst", "星形"],
71
+ ["flower", "花朵"],
72
+ ["willow", "柳絮"],
73
+ ["scatter", "随机散落"],
74
+ ["heart", "爱心"]
75
+ ]
76
+ },
77
+ {
78
+ key: "enableSecondaryBurst",
79
+ label: "二次爆炸",
80
+ type: "boolean",
81
+ hint: "二次爆炸对性能影响较大,谨慎开启。"
82
+ },
83
+ {
84
+ key: "secondaryBurstRatio",
85
+ label: "二次比例",
86
+ type: "range",
87
+ min: 0,
88
+ max: 1,
89
+ step: 0.05,
90
+ hint: "这里只控制二次爆炸比例;自定义二次爆炸效果需要使用高级模式自定义脚本。"
91
+ },
92
+ { key: "enableTrails", label: "粒子拖尾", type: "boolean", hint: "拖尾会影响性能,酌情开启。" },
93
+ { key: "showLaunchTrail", label: "升空轨迹", type: "boolean" },
94
+ { key: "windEnabled", label: "风场", type: "boolean" },
95
+ { key: "windX", label: "水平风", type: "range", min: -0.35, max: 0.35, step: 0.01 },
96
+ { key: "windY", label: "垂直风", type: "range", min: -0.35, max: 0.35, step: 0.01 },
97
+ { key: "flicker", label: "闪烁", type: "boolean" },
98
+ { key: "fadeTrail", label: "拖尾长度", type: "range", min: 0.04, max: 0.55, step: 0.01 }
99
+ ]
100
+ },
101
+ {
102
+ title: "粒子",
103
+ controls: [
104
+ { key: "particleCount", label: "粒子数量", type: "range", min: 10, max: 1000, step: 10 },
105
+ { key: "initialSpeedMin", label: "最小速度", type: "range", min: 0, max: 20, step: 0.1 },
106
+ { key: "initialSpeedMax", label: "最大速度", type: "range", min: 0, max: 20, step: 0.1 },
107
+ { key: "damping", label: "速度衰减", type: "range", min: 0.95, max: 1, step: 0.001 },
108
+ { key: "gravity", label: "重力影响", type: "range", min: -1, max: 2, step: 0.01 },
109
+ { key: "particleLife", label: "生命周期", type: "range", min: 30, max: 180, step: 1 },
110
+ { key: "particleMinSize", label: "最小大小", type: "range", min: 2, max: 15, step: 0.5 },
111
+ { key: "particleMaxSize", label: "最大大小", type: "range", min: 2, max: 15, step: 0.5 },
112
+ { key: "sizeEndMultiplier", label: "结束比例", type: "range", min: 0, max: 2, step: 0.05 },
113
+ { key: "rotationSpeed", label: "旋转速度", type: "range", min: 0, max: 0.105, step: 0.005 },
114
+ { key: "colorShiftRate", label: "变色速率", type: "range", min: 0, max: 0.1, step: 0.005 },
115
+ { key: "autoLaunchIntervalSeconds", label: "自动间隔(秒)", type: "range", min: 0.25, max: 3, step: 0.05 }
116
+ ]
117
+ },
118
+ {
119
+ title: "背景",
120
+ controls: [
121
+ {
122
+ key: "backgroundType",
123
+ label: "背景样式",
124
+ type: "select",
125
+ options: [
126
+ ["solid", "纯色"],
127
+ ["linear", "线性渐变"],
128
+ ["radial", "径向渐变"],
129
+ ["starfield", "星空"],
130
+ ["image", "图片"]
131
+ ]
132
+ },
133
+ { key: "backgroundColor", label: "背景色 A", type: "color" },
134
+ { key: "backgroundColor2", label: "背景色 B", type: "color" },
135
+ {
136
+ key: "backgroundImageMode",
137
+ label: "图片填充",
138
+ type: "select",
139
+ options: [
140
+ ["cover", "覆盖"],
141
+ ["contain", "完整"],
142
+ ["tile", "平铺"]
143
+ ]
144
+ },
145
+ { key: "backgroundImage", label: "上传图片", type: "file" }
146
+ ]
147
+ }
148
+ ];
149
+
150
+ export const builtInPresets = {
151
+ "classic-gold": {
152
+ name: "经典礼花",
153
+ config: {
154
+ colorStart: "#ffd166",
155
+ colorEnd: "#ff3d2e",
156
+ explosionShape: "sphere",
157
+ particleCount: 220,
158
+ gravity: 0.22,
159
+ damping: 0.981,
160
+ glowIntensity: 0.9,
161
+ enableSecondaryBurst: false,
162
+ textureType: "circle",
163
+ fadeTrail: 0.18
164
+ }
165
+ },
166
+ aurora: {
167
+ name: "梦幻极光",
168
+ config: {
169
+ colorStart: "#4cc9f0",
170
+ colorEnd: "#80ffdb",
171
+ explosionShape: "ring",
172
+ particleCount: 260,
173
+ gravity: 0.03,
174
+ damping: 0.992,
175
+ glowIntensity: 1.1,
176
+ fadeTrail: 0.08,
177
+ textureType: "circle"
178
+ }
179
+ },
180
+ fountain: {
181
+ name: "星光喷泉",
182
+ config: {
183
+ colorStart: "#f7f3a1",
184
+ colorEnd: "#fca311",
185
+ explosionShape: "flower",
186
+ particleCount: 420,
187
+ particleMinSize: 2,
188
+ particleMaxSize: 4.5,
189
+ initialSpeedMin: 4,
190
+ initialSpeedMax: 11,
191
+ gravity: 0.32,
192
+ damping: 0.976,
193
+ textureType: "star"
194
+ }
195
+ },
196
+ ghost: {
197
+ name: "幽灵鬼火",
198
+ config: {
199
+ colorStart: "#7bdff2",
200
+ colorEnd: "#b2f7ef",
201
+ explosionShape: "scatter",
202
+ particleCount: 160,
203
+ initialSpeedMin: 0.8,
204
+ initialSpeedMax: 4.6,
205
+ gravity: -0.01,
206
+ damping: 0.989,
207
+ enableSecondaryBurst: true,
208
+ glowIntensity: 1.2,
209
+ fadeTrail: 0.12
210
+ }
211
+ },
212
+ meteor: {
213
+ name: "节日流星",
214
+ config: {
215
+ colorStart: "#ffafcc",
216
+ colorEnd: "#ffffff",
217
+ explosionShape: "willow",
218
+ particleCount: 300,
219
+ initialSpeedMin: 7,
220
+ initialSpeedMax: 13,
221
+ damping: 0.965,
222
+ gravity: 0.16,
223
+ windEnabled: true,
224
+ windX: 0.12,
225
+ textureType: "square"
226
+ }
227
+ },
228
+ heart: {
229
+ name: "爱心爆发",
230
+ config: {
231
+ colorStart: "#ff4d6d",
232
+ colorEnd: "#ffd6e0",
233
+ explosionShape: "heart",
234
+ particleCount: 260,
235
+ gravity: 0.08,
236
+ damping: 0.986,
237
+ textureType: "heart",
238
+ glowIntensity: 1
239
+ }
240
+ }
241
+ };
242
+
243
+ export function sanitizeConfig(input) {
244
+ const clean = { ...defaultConfig };
245
+ if (!input || typeof input !== "object") {
246
+ return clean;
247
+ }
248
+
249
+ const schemas = controlsSchema.flatMap((section) => section.controls);
250
+ for (const item of schemas) {
251
+ if (!(item.key in input)) continue;
252
+
253
+ if (item.type === "range") {
254
+ const value = Number(input[item.key]);
255
+ if (Number.isFinite(value)) {
256
+ clean[item.key] = Math.min(item.max, Math.max(item.min, value));
257
+ }
258
+ } else if (item.type === "boolean") {
259
+ clean[item.key] = Boolean(input[item.key]);
260
+ } else if (item.type === "select") {
261
+ const allowed = item.options.map(([value]) => value);
262
+ if (allowed.includes(input[item.key])) clean[item.key] = input[item.key];
263
+ } else if (item.type === "color") {
264
+ const value = String(input[item.key]);
265
+ if (/^#[0-9a-f]{6}$/i.test(value)) clean[item.key] = value;
266
+ }
267
+ }
268
+
269
+ if ("autoLaunch" in input) {
270
+ clean.autoLaunch = Boolean(input.autoLaunch);
271
+ }
272
+
273
+ if ("autoInterval" in input) {
274
+ const intervalMs = Number(input.autoInterval);
275
+ if (Number.isFinite(intervalMs)) {
276
+ clean.autoInterval = clamp(intervalMs, 250, 3000);
277
+ clean.autoLaunchIntervalSeconds = toSeconds(clean.autoInterval);
278
+ }
279
+ }
280
+
281
+ if ("autoLaunchIntervalSeconds" in input) {
282
+ const intervalSeconds = Number(input.autoLaunchIntervalSeconds);
283
+ if (Number.isFinite(intervalSeconds)) {
284
+ clean.autoLaunchIntervalSeconds = clamp(intervalSeconds, 0.25, 3);
285
+ clean.autoInterval = toMilliseconds(clean.autoLaunchIntervalSeconds);
286
+ }
287
+ }
288
+
289
+ return clean;
290
+ }
291
+
292
+ function clamp(value, min, max) {
293
+ return Math.min(max, Math.max(min, value));
294
+ }
295
+
296
+ function toMilliseconds(seconds) {
297
+ return Math.round(seconds * 1000);
298
+ }
299
+
300
+ function toSeconds(milliseconds) {
301
+ return Math.round((milliseconds / 1000) * 100) / 100;
302
+ }
@@ -0,0 +1,213 @@
1
+ import { random, randomInt } from "../utils/math.js";
2
+ import { randomVividColor } from "../utils/color.js";
3
+
4
+ export class FireworkFactory {
5
+ constructor(getConfig) {
6
+ this.getConfig = getConfig;
7
+ }
8
+
9
+ createExplosion(x, y, override = {}) {
10
+ const config = { ...this.getConfig(), ...override };
11
+ const count = Math.min(1000, Math.max(10, Math.round(config.particleCount)));
12
+ const particles = [];
13
+ const randomStart = config.useRandomColors ? randomVividColor() : config.colorStart;
14
+ const randomEnd = config.useRandomColors ? randomVividColor() : config.colorEnd;
15
+ const secondaryCount = config.enableSecondaryBurst
16
+ ? Math.round(count * clampRatio(config.secondaryBurstRatio))
17
+ : 0;
18
+ const secondaryIndexes = createSecondaryIndexes(count, secondaryCount);
19
+
20
+ for (let i = 0; i < count; i += 1) {
21
+ const vector = createVector(config.explosionShape, i, count);
22
+ const speed = vector.speedMultiplier * random(config.initialSpeedMin, config.initialSpeedMax);
23
+ const jitterX = config.explosionShape === "heart" ? vector.offsetX : 0;
24
+ const jitterY = config.explosionShape === "heart" ? vector.offsetY : 0;
25
+ const startColor = config.useRandomColors && i % 4 === 0 ? randomVividColor() : randomStart;
26
+ const endColor = config.useRandomColors && i % 5 === 0 ? randomVividColor() : randomEnd;
27
+ const size = random(config.particleMinSize, config.particleMaxSize);
28
+ const life = random(config.particleLife * 0.78, config.particleLife * 1.18);
29
+
30
+ particles.push({
31
+ x: x + jitterX,
32
+ y: y + jitterY,
33
+ vx: vector.x * speed,
34
+ vy: vector.y * speed,
35
+ size,
36
+ life,
37
+ startColor,
38
+ endColor,
39
+ textureType: config.textureType,
40
+ rotationVelocity: random(-config.rotationSpeed, config.rotationSpeed),
41
+ secondaryEligible: secondaryIndexes.has(i)
42
+ });
43
+ }
44
+
45
+ return particles;
46
+ }
47
+
48
+ createSubExplosion(x, y, source = null) {
49
+ const baseConfig = source?.config || this.getConfig();
50
+ const particleConfig = this.createConfigFromParticle(source, baseConfig);
51
+ const overrides = source?.secondaryBurstConfig || {};
52
+ const config = {
53
+ ...particleConfig,
54
+ ...overrides,
55
+ enableSecondaryBurst: false
56
+ };
57
+ const particleCount = "particleCount" in overrides
58
+ ? config.particleCount
59
+ : Math.max(12, Math.round(particleConfig.particleCount * 0.16));
60
+ const particleLife = "particleLife" in overrides
61
+ ? config.particleLife
62
+ : Math.max(28, particleConfig.particleLife * 0.48);
63
+ const initialSpeedMin = "initialSpeedMin" in overrides
64
+ ? config.initialSpeedMin
65
+ : Math.max(0.4, particleConfig.initialSpeedMin * 0.38);
66
+ const initialSpeedMax = "initialSpeedMax" in overrides
67
+ ? config.initialSpeedMax
68
+ : Math.max(1, particleConfig.initialSpeedMax * 0.56);
69
+
70
+ return this.createExplosion(x, y, {
71
+ ...config,
72
+ particleCount,
73
+ particleLife,
74
+ initialSpeedMin,
75
+ initialSpeedMax,
76
+ enableSecondaryBurst: false
77
+ });
78
+ }
79
+
80
+ createConfigFromParticle(source, baseConfig) {
81
+ if (!source) return baseConfig;
82
+ return {
83
+ ...baseConfig,
84
+ colorStart: source.startColor || baseConfig.colorStart,
85
+ colorEnd: source.endColor || baseConfig.colorEnd,
86
+ useRandomColors: false,
87
+ textureType: source.textureType || baseConfig.textureType,
88
+ particleMinSize: source.size,
89
+ particleMaxSize: source.size,
90
+ particleLife: source.maxLife,
91
+ enableSecondaryBurst: false
92
+ };
93
+ }
94
+
95
+ createLaunch(startX, startY, targetX, targetY) {
96
+ const config = this.getConfig();
97
+ return new LaunchShell(startX, startY, targetX, targetY, config);
98
+ }
99
+ }
100
+
101
+ function clampRatio(value) {
102
+ if (!Number.isFinite(Number(value))) return 0;
103
+ return Math.min(1, Math.max(0, Number(value)));
104
+ }
105
+
106
+ function createSecondaryIndexes(count, secondaryCount) {
107
+ const indexes = new Set();
108
+ while (indexes.size < secondaryCount) {
109
+ indexes.add(randomInt(0, count - 1));
110
+ }
111
+ return indexes;
112
+ }
113
+
114
+ export class LaunchShell {
115
+ constructor(startX, startY, targetX, targetY, config) {
116
+ this.x = startX;
117
+ this.y = startY;
118
+ this.targetX = targetX;
119
+ this.targetY = targetY;
120
+ this.color = config.colorStart;
121
+ this.life = 1;
122
+ this.path = [];
123
+ const dx = targetX - startX;
124
+ const dy = targetY - startY;
125
+ const distance = Math.hypot(dx, dy) || 1;
126
+ const speed = Math.min(18, Math.max(9, distance / 28));
127
+ this.vx = (dx / distance) * speed;
128
+ this.vy = (dy / distance) * speed;
129
+ }
130
+
131
+ update(step) {
132
+ this.path.push({ x: this.x, y: this.y });
133
+ if (this.path.length > 18) this.path.shift();
134
+ this.x += this.vx * step;
135
+ this.y += this.vy * step;
136
+ this.vy += 0.03 * step;
137
+ return Math.hypot(this.targetX - this.x, this.targetY - this.y) < 14 || this.vy > 1.5;
138
+ }
139
+
140
+ draw(ctx, showTrail) {
141
+ ctx.save();
142
+ ctx.strokeStyle = "rgba(255, 230, 170, 0.35)";
143
+ ctx.fillStyle = this.color;
144
+ ctx.shadowColor = this.color;
145
+ ctx.shadowBlur = 16;
146
+
147
+ if (showTrail && this.path.length > 1) {
148
+ ctx.beginPath();
149
+ this.path.forEach((point, index) => {
150
+ if (index === 0) ctx.moveTo(point.x, point.y);
151
+ else ctx.lineTo(point.x, point.y);
152
+ });
153
+ ctx.stroke();
154
+ }
155
+
156
+ ctx.beginPath();
157
+ ctx.arc(this.x, this.y, 3, 0, Math.PI * 2);
158
+ ctx.fill();
159
+ ctx.restore();
160
+ }
161
+ }
162
+
163
+ function createVector(shape, index, count) {
164
+ if (shape === "ring") {
165
+ const angle = (index / count) * Math.PI * 2;
166
+ return { x: Math.cos(angle), y: Math.sin(angle), speedMultiplier: 1 };
167
+ }
168
+
169
+ if (shape === "starburst") {
170
+ const spoke = randomInt(0, 4);
171
+ const base = (spoke / 5) * Math.PI * 2 - Math.PI / 2;
172
+ const angle = base + random(-0.16, 0.16);
173
+ return { x: Math.cos(angle), y: Math.sin(angle), speedMultiplier: index % 3 === 0 ? 1.25 : 0.62 };
174
+ }
175
+
176
+ if (shape === "flower") {
177
+ const angle = (index / count) * Math.PI * 2;
178
+ const petal = 0.58 + Math.abs(Math.sin(angle * 6)) * 0.72;
179
+ return { x: Math.cos(angle), y: Math.sin(angle), speedMultiplier: petal };
180
+ }
181
+
182
+ if (shape === "willow") {
183
+ const angle = random(-Math.PI * 0.92, -Math.PI * 0.08);
184
+ return { x: Math.cos(angle), y: Math.sin(angle), speedMultiplier: random(0.28, 1.35) };
185
+ }
186
+
187
+ if (shape === "scatter") {
188
+ const angle = random(0, Math.PI * 2);
189
+ return { x: Math.cos(angle), y: Math.sin(angle), speedMultiplier: random(0.2, 1.55) };
190
+ }
191
+
192
+ if (shape === "heart") {
193
+ const t = random(0, Math.PI * 2);
194
+ const hx = 16 * Math.pow(Math.sin(t), 3);
195
+ const hy = -(
196
+ 13 * Math.cos(t) -
197
+ 5 * Math.cos(2 * t) -
198
+ 2 * Math.cos(3 * t) -
199
+ Math.cos(4 * t)
200
+ );
201
+ const length = Math.hypot(hx, hy) || 1;
202
+ return {
203
+ x: hx / length,
204
+ y: hy / length,
205
+ offsetX: hx * 1.8,
206
+ offsetY: hy * 1.8,
207
+ speedMultiplier: random(0.72, 1.12)
208
+ };
209
+ }
210
+
211
+ const angle = random(0, Math.PI * 2);
212
+ return { x: Math.cos(angle), y: Math.sin(angle), speedMultiplier: Math.sqrt(Math.random()) };
213
+ }