domma-cms 0.1.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.
Files changed (135) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +469 -0
  3. package/admin/css/admin.css +1123 -0
  4. package/admin/index.html +72 -0
  5. package/admin/js/api.js +210 -0
  6. package/admin/js/app.js +270 -0
  7. package/admin/js/config/sidebar-config.js +107 -0
  8. package/admin/js/lib/card.js +63 -0
  9. package/admin/js/lib/image-editor.js +869 -0
  10. package/admin/js/lib/markdown-toolbar.js +421 -0
  11. package/admin/js/templates/dashboard.html +50 -0
  12. package/admin/js/templates/documentation.html +237 -0
  13. package/admin/js/templates/layouts.html +11 -0
  14. package/admin/js/templates/login.html +58 -0
  15. package/admin/js/templates/media.html +16 -0
  16. package/admin/js/templates/navigation.html +50 -0
  17. package/admin/js/templates/page-editor.html +126 -0
  18. package/admin/js/templates/pages.html +18 -0
  19. package/admin/js/templates/plugins.html +12 -0
  20. package/admin/js/templates/settings.html +190 -0
  21. package/admin/js/templates/tutorials.html +233 -0
  22. package/admin/js/templates/user-editor.html +12 -0
  23. package/admin/js/templates/users.html +10 -0
  24. package/admin/js/views/dashboard.js +48 -0
  25. package/admin/js/views/documentation.js +12 -0
  26. package/admin/js/views/index.js +33 -0
  27. package/admin/js/views/layouts.js +49 -0
  28. package/admin/js/views/login.js +254 -0
  29. package/admin/js/views/media.js +240 -0
  30. package/admin/js/views/navigation.js +152 -0
  31. package/admin/js/views/page-editor.js +479 -0
  32. package/admin/js/views/pages.js +64 -0
  33. package/admin/js/views/plugins.js +100 -0
  34. package/admin/js/views/settings.js +64 -0
  35. package/admin/js/views/tutorials.js +12 -0
  36. package/admin/js/views/user-editor.js +88 -0
  37. package/admin/js/views/users.js +73 -0
  38. package/bin/cli.js +334 -0
  39. package/config/auth.json +20 -0
  40. package/config/content.json +10 -0
  41. package/config/navigation.json +63 -0
  42. package/config/plugins.json +47 -0
  43. package/config/presets.json +34 -0
  44. package/config/server.json +6 -0
  45. package/config/site.json +33 -0
  46. package/package.json +67 -0
  47. package/plugins/back-to-top/admin/templates/back-to-top-settings.html +55 -0
  48. package/plugins/back-to-top/admin/views/back-to-top-settings.js +44 -0
  49. package/plugins/back-to-top/config.js +10 -0
  50. package/plugins/back-to-top/plugin.js +24 -0
  51. package/plugins/back-to-top/plugin.json +36 -0
  52. package/plugins/back-to-top/public/inject-body.html +105 -0
  53. package/plugins/cookie-consent/admin/templates/cookie-consent-settings.html +113 -0
  54. package/plugins/cookie-consent/admin/views/cookie-consent-settings.js +73 -0
  55. package/plugins/cookie-consent/config.js +30 -0
  56. package/plugins/cookie-consent/plugin.js +24 -0
  57. package/plugins/cookie-consent/plugin.json +36 -0
  58. package/plugins/cookie-consent/public/inject-body.html +69 -0
  59. package/plugins/custom-css/admin/templates/custom-css.html +17 -0
  60. package/plugins/custom-css/admin/views/custom-css.js +35 -0
  61. package/plugins/custom-css/config.js +1 -0
  62. package/plugins/custom-css/data/custom.css +0 -0
  63. package/plugins/custom-css/plugin.js +63 -0
  64. package/plugins/custom-css/plugin.json +32 -0
  65. package/plugins/custom-css/public/inject-head.html +1 -0
  66. package/plugins/domma-effects/admin/templates/domma-effects.html +488 -0
  67. package/plugins/domma-effects/admin/views/domma-effects.js +56 -0
  68. package/plugins/domma-effects/config.js +9 -0
  69. package/plugins/domma-effects/plugin.js +22 -0
  70. package/plugins/domma-effects/plugin.json +36 -0
  71. package/plugins/domma-effects/public/celebrations/core/canvas.js +111 -0
  72. package/plugins/domma-effects/public/celebrations/core/particles.js +144 -0
  73. package/plugins/domma-effects/public/celebrations/core/physics.js +166 -0
  74. package/plugins/domma-effects/public/celebrations/index.js +535 -0
  75. package/plugins/domma-effects/public/celebrations/themes/christmas.js +1805 -0
  76. package/plugins/domma-effects/public/celebrations/themes/guy-fawkes.js +1477 -0
  77. package/plugins/domma-effects/public/celebrations/themes/halloween.js +1837 -0
  78. package/plugins/domma-effects/public/celebrations/themes/st-andrews.js +1175 -0
  79. package/plugins/domma-effects/public/celebrations/themes/st-davids.js +1258 -0
  80. package/plugins/domma-effects/public/celebrations/themes/st-georges.js +1754 -0
  81. package/plugins/domma-effects/public/celebrations/themes/st-patricks.js +1290 -0
  82. package/plugins/domma-effects/public/celebrations/themes/valentines.js +1361 -0
  83. package/plugins/domma-effects/public/inject-body.html +268 -0
  84. package/plugins/example-analytics/admin/templates/analytics.html +10 -0
  85. package/plugins/example-analytics/admin/views/analytics.js +51 -0
  86. package/plugins/example-analytics/config.js +6 -0
  87. package/plugins/example-analytics/plugin.js +58 -0
  88. package/plugins/example-analytics/plugin.json +27 -0
  89. package/plugins/example-analytics/public/inject-body.html +13 -0
  90. package/plugins/example-analytics/public/inject-head.html +1 -0
  91. package/plugins/example-analytics/stats.json +1 -0
  92. package/plugins/form-builder/admin/templates/form-editor.html +158 -0
  93. package/plugins/form-builder/admin/templates/form-settings.html +29 -0
  94. package/plugins/form-builder/admin/templates/form-submissions.html +30 -0
  95. package/plugins/form-builder/admin/templates/forms-list.html +17 -0
  96. package/plugins/form-builder/admin/views/form-editor.js +817 -0
  97. package/plugins/form-builder/admin/views/form-settings.js +38 -0
  98. package/plugins/form-builder/admin/views/form-submissions.js +295 -0
  99. package/plugins/form-builder/admin/views/forms-list.js +164 -0
  100. package/plugins/form-builder/config.js +9 -0
  101. package/plugins/form-builder/data/forms/contact-details.json +63 -0
  102. package/plugins/form-builder/data/forms/contact.json +52 -0
  103. package/plugins/form-builder/data/submissions/contact-details.json +1 -0
  104. package/plugins/form-builder/data/submissions/contact.json +14 -0
  105. package/plugins/form-builder/email.js +103 -0
  106. package/plugins/form-builder/plugin.js +454 -0
  107. package/plugins/form-builder/plugin.json +56 -0
  108. package/plugins/form-builder/public/inject-body.html +270 -0
  109. package/plugins/form-builder/public/inject-head.html +42 -0
  110. package/public/css/site.css +189 -0
  111. package/public/js/site.js +109 -0
  112. package/scripts/copy-domma.js +48 -0
  113. package/scripts/fresh.js +41 -0
  114. package/scripts/reset.js +124 -0
  115. package/scripts/seed.js +666 -0
  116. package/scripts/setup.js +263 -0
  117. package/server/config.js +56 -0
  118. package/server/middleware/auth.js +97 -0
  119. package/server/routes/api/auth.js +116 -0
  120. package/server/routes/api/layouts.js +25 -0
  121. package/server/routes/api/media.js +93 -0
  122. package/server/routes/api/navigation.js +37 -0
  123. package/server/routes/api/pages.js +118 -0
  124. package/server/routes/api/plugins.js +46 -0
  125. package/server/routes/api/settings.js +25 -0
  126. package/server/routes/api/users.js +110 -0
  127. package/server/routes/public.js +108 -0
  128. package/server/server.js +169 -0
  129. package/server/services/content.js +298 -0
  130. package/server/services/images.js +334 -0
  131. package/server/services/markdown.js +297 -0
  132. package/server/services/plugins.js +246 -0
  133. package/server/services/renderer.js +80 -0
  134. package/server/services/users.js +212 -0
  135. package/server/templates/page.html +78 -0
@@ -0,0 +1,1477 @@
1
+ /**
2
+ * Guy Fawkes Night Theme for Domma Celebrations
3
+ * (Bonfire Night - November 5th, UK)
4
+ *
5
+ * Features:
6
+ * - Spectacular firework displays with multiple colors
7
+ * - Animated bonfires with crackling flames and embers
8
+ * - Rocket trails shooting upward before exploding
9
+ * - Catherine wheels spinning with colorful sparks
10
+ * - Roman candles shooting multiple bursts
11
+ * - Guy Fawkes effigy silhouette
12
+ * - Floating embers and ash particles
13
+ * - Dark night sky with dramatic pyrotechnics
14
+ */
15
+
16
+ // Import firework functions from Christmas theme (reusable!)
17
+ import christmas from './christmas.js';
18
+
19
+ export default {
20
+ name: 'guy-fawkes',
21
+ displayName: 'Guy Fawkes Night',
22
+ emoji: '🎆',
23
+
24
+ // Intensity configurations
25
+ intensityConfig: {
26
+ light: {
27
+ count: 60,
28
+ initialParticleRatio: 0.25, // Start with 25% of particles (15), build up to full count
29
+ speedRange: [0.5, 1.5],
30
+ sizeRange: [2, 4],
31
+ bonfires: 1,
32
+ fireworkChance: 0.0009, // ~1 firework every 18 seconds
33
+ rocketChance: 0.0007,
34
+ burstChance: 0.0012
35
+ },
36
+ medium: {
37
+ count: 120,
38
+ initialParticleRatio: 0.25, // Start with 25% of particles (30), build up to full count
39
+ speedRange: [0.5, 2.0],
40
+ sizeRange: [2, 5],
41
+ bonfires: 2,
42
+ fireworkChance: 0.0011, // ~1 firework every 15 seconds
43
+ rocketChance: 0.0009,
44
+ burstChance: 0.0015
45
+ },
46
+ heavy: {
47
+ count: 200,
48
+ initialParticleRatio: 0.25, // Start with 25% of particles (50), build up to full count
49
+ speedRange: [0.5, 2.5],
50
+ sizeRange: [3, 6],
51
+ bonfires: 3,
52
+ fireworkChance: 0.0014, // ~1 firework every 12 seconds
53
+ rocketChance: 0.0012,
54
+ burstChance: 0.0018
55
+ }
56
+ },
57
+
58
+ particles: ['ember', 'firework', 'spark', 'rocket', 'burst', 'trail'],
59
+ decorations: ['bonfire', 'guy-effigy', 'catherine-wheel', 'roman-candle', 'sparkler-bundle', 'moon'],
60
+ colors: {
61
+ primary: '#ff4500', // Orange-red flames
62
+ secondary: '#ffd700', // Gold sparks
63
+ accent: '#8b0000', // Deep red
64
+ fire: ['#ff4500', '#ffa500', '#ffff00', '#ff0000'], // Flame colors
65
+ firework: ['#ff0000', '#ffff00', '#00ff00', '#0000ff', '#ff00ff', '#00ffff', '#ffffff']
66
+ },
67
+
68
+ // Lightning effect properties
69
+ lightningChance: 0.0005, // 0.05% chance per frame to strike
70
+ lightningDuration: 200, // Lightning flash lasts 200ms
71
+ lightningTimer: 0,
72
+ lightningActive: false,
73
+ lightningColor: '#fefefe', // Bright white/blue flash
74
+
75
+ /**
76
+ * Draw a procedurally generated lightning bolt
77
+ * @param {CanvasRenderingContext2D} ctx
78
+ * @param {number} x1 - Start X
79
+ * @param {number} y1 - Start Y
80
+ * @param {number} x2 - End X
81
+ * @param {number} y2 - End Y
82
+ * @param {number} segments - Number of segments for the bolt (controls detail)
83
+ * @param {number} displacement - Max displacement for forks
84
+ * @param {number} roughness - How jagged the line is
85
+ * @param {number} branchChance - Probability of branching
86
+ * @param {number} lineWidth - Base line width
87
+ * @param {string} color - Color of the lightning
88
+ */
89
+ drawLightning(ctx, x1, y1, x2, y2, segments, displacement, roughness, branchChance, lineWidth, color) {
90
+ if (segments < 1) {
91
+ return;
92
+ }
93
+
94
+ const midpointX = (x1 + x2) / 2;
95
+ const midpointY = (y1 + y2) / 2;
96
+
97
+ const angle = Math.atan2(y2 - y1, x2 - x1);
98
+ const perpendicularAngle = angle + Math.PI / 2;
99
+
100
+ const offset = (Math.random() - 0.5) * displacement;
101
+ const newMidpointX = midpointX + Math.cos(perpendicularAngle) * offset;
102
+ const newMidpointY = midpointY + Math.sin(perpendicularAngle) * offset;
103
+
104
+ const newSegments = segments - 1;
105
+ const newDisplacement = displacement * roughness;
106
+ const newLineWidth = lineWidth * 0.8;
107
+
108
+ ctx.strokeStyle = color;
109
+ ctx.lineWidth = lineWidth;
110
+ ctx.lineCap = 'round';
111
+ ctx.lineJoin = 'round';
112
+
113
+ // Draw main segment
114
+ ctx.beginPath();
115
+ ctx.moveTo(x1, y1);
116
+ ctx.lineTo(newMidpointX, newMidpointY);
117
+ ctx.lineTo(x2, y2);
118
+ ctx.stroke();
119
+
120
+ // Recursive call for sub-segments
121
+ if (newSegments > 0) {
122
+ this.drawLightning(ctx, x1, y1, newMidpointX, newMidpointY, newSegments, newDisplacement, roughness, branchChance, newLineWidth, color);
123
+ this.drawLightning(ctx, newMidpointX, newMidpointY, x2, y2, newSegments, newDisplacement, roughness, branchChance, newLineWidth, color);
124
+
125
+ // Create a fork
126
+ if (Math.random() < branchChance && newSegments > 1) {
127
+ const forkLength = Math.random() * displacement * 0.5;
128
+ const forkAngle = (Math.random() - 0.5) * Math.PI / 3; // Max 60 degree deviation
129
+ const forkX = newMidpointX + Math.cos(angle + forkAngle) * forkLength;
130
+ const forkY = newMidpointY + Math.sin(angle + forkAngle) * forkLength;
131
+ this.drawLightning(ctx, newMidpointX, newMidpointY, forkX, forkY, newSegments - 1, newDisplacement * 0.5, roughness, branchChance * 0.5, newLineWidth * 0.6, color);
132
+ }
133
+ }
134
+ },
135
+
136
+ /**
137
+ * Create rising ember particle that will explode
138
+ */
139
+ createEmber(canvasWidth, canvasHeight, config) {
140
+ // Ensure config has required properties with defaults
141
+ const sizeRange = config?.sizeRange || [2, 4];
142
+ const speedRange = config?.speedRange || [0.5, 1.5];
143
+
144
+ const emberColors = this.colors.firework;
145
+ const startX = Math.random() * canvasWidth;
146
+ const startY = canvasHeight - 50; // Start near bottom
147
+ const targetY = 100 + Math.random() * 300; // Explode at random height
148
+
149
+ return {
150
+ type: 'ember',
151
+ x: startX,
152
+ y: startY,
153
+ vx: (Math.random() - 0.5) * 1.5, // Horizontal drift
154
+ vy: -2 - Math.random() * 2, // Upward velocity
155
+ targetY: targetY,
156
+ size: sizeRange[0] + Math.random() * (sizeRange[1] - sizeRange[0]),
157
+ speed: speedRange[0] + Math.random() * (speedRange[1] - speedRange[0]),
158
+ opacity: 1,
159
+ color: emberColors[Math.floor(Math.random() * emberColors.length)],
160
+ trailColor: emberColors[Math.floor(Math.random() * emberColors.length)],
161
+ glowPhase: Math.random() * Math.PI * 2,
162
+ exploded: false,
163
+ explosionParticles: [],
164
+ active: true
165
+ };
166
+ },
167
+
168
+ /**
169
+ * Create bonfire decoration
170
+ */
171
+ createBonfire(canvasWidth, canvasHeight, options = {}) {
172
+ return {
173
+ type: 'bonfire',
174
+ x: options.x !== undefined ? options.x : Math.random() * canvasWidth * 0.6 + canvasWidth * 0.2,
175
+ y: options.y !== undefined ? options.y : canvasHeight - 60,
176
+ size: 30 + Math.random() * 20,
177
+ opacity: 1,
178
+ flames: [],
179
+ logs: [],
180
+ embers: [],
181
+ time: 0,
182
+ cracklePhase: 0,
183
+ active: true,
184
+ static: true
185
+ };
186
+ },
187
+
188
+ /**
189
+ * Create Guy Fawkes effigy (on bonfire)
190
+ */
191
+ createGuyEffigy(canvasWidth, canvasHeight, bonfireX, bonfireY) {
192
+ return {
193
+ type: 'guy-effigy',
194
+ x: bonfireX,
195
+ y: bonfireY - 50,
196
+ size: 20 + Math.random() * 10,
197
+ opacity: 0.8,
198
+ burning: false,
199
+ burnProgress: 0,
200
+ active: true,
201
+ static: true
202
+ };
203
+ },
204
+
205
+ /**
206
+ * Create Catherine wheel (spinning firework)
207
+ */
208
+ createCatherineWheel(canvasWidth, canvasHeight, options = {}) {
209
+ return {
210
+ type: 'catherine-wheel',
211
+ x: options.x !== undefined ? options.x : Math.random() * canvasWidth,
212
+ y: options.y !== undefined ? options.y : 100 + Math.random() * 200,
213
+ size: 15 + Math.random() * 10,
214
+ opacity: 1,
215
+ rotation: 0,
216
+ rotationSpeed: 0.05 + Math.random() * 0.1,
217
+ sparks: [], // Keep this for internal spark management
218
+ sparkTimer: 0, // New: timer for emitting sparks
219
+ sparkInterval: 50, // New: emit sparks every 50ms
220
+ time: 0,
221
+ duration: 5000 + Math.random() * 3000,
222
+ active: true,
223
+ static: true
224
+ };
225
+ },
226
+
227
+ /**
228
+ * Create Roman candle (shoots bursts upward)
229
+ */
230
+ createRomanCandle(canvasWidth, canvasHeight, options = {}) {
231
+ return {
232
+ type: 'roman-candle',
233
+ x: options.x !== undefined ? options.x : Math.random() * canvasWidth,
234
+ y: options.y !== undefined ? options.y : canvasHeight - 30,
235
+ size: 8 + Math.random() * 4,
236
+ opacity: 1,
237
+ shots: [],
238
+ shotInterval: 800 + Math.random() * 400,
239
+ lastShotTime: 0,
240
+ shotCount: 0,
241
+ maxShots: 5 + Math.floor(Math.random() * 5),
242
+ time: 0,
243
+ active: true,
244
+ static: true
245
+ };
246
+ },
247
+
248
+ /**
249
+ * Create rocket particle (shoots up then explodes)
250
+ */
251
+ createRocket(canvasWidth, canvasHeight, config) {
252
+ const sizeRange = config?.sizeRange || [2, 4];
253
+ const startX = 50 + Math.random() * (canvasWidth - 100);
254
+ const targetY = 80 + Math.random() * 150; // Explode in upper third
255
+
256
+ return {
257
+ type: 'rocket',
258
+ x: startX,
259
+ y: canvasHeight - 20,
260
+ vx: (Math.random() - 0.5) * 0.5, // Slight drift
261
+ vy: -3 - Math.random() * 2, // Fast upward
262
+ targetY: targetY,
263
+ size: sizeRange[0] + Math.random() * (sizeRange[1] - sizeRange[0]),
264
+ opacity: 1,
265
+ color: this.colors.firework[Math.floor(Math.random() * this.colors.firework.length)],
266
+ trail: [], // Trail particles behind rocket
267
+ exploded: false,
268
+ rotation: Math.random() * Math.PI * 2,
269
+ rotationSpeed: (Math.random() - 0.5) * 0.1,
270
+ active: true
271
+ };
272
+ },
273
+
274
+ /**
275
+ * Create burst particle (explosion fragment)
276
+ */
277
+ createBurst(canvasWidth, canvasHeight, config, origin) {
278
+ const sizeRange = config?.sizeRange || [1, 3];
279
+ const angle = Math.random() * Math.PI * 2;
280
+ const speed = 1 + Math.random() * 4;
281
+
282
+ return {
283
+ type: 'burst',
284
+ x: origin?.x || canvasWidth / 2,
285
+ y: origin?.y || canvasHeight / 2,
286
+ vx: Math.cos(angle) * speed,
287
+ vy: Math.sin(angle) * speed,
288
+ size: sizeRange[0] + Math.random() * (sizeRange[1] - sizeRange[0]),
289
+ opacity: 1,
290
+ color: this.colors.firework[Math.floor(Math.random() * this.colors.firework.length)],
291
+ life: 1.0, // Fades out over time
292
+ fadeRate: 0.015 + Math.random() * 0.015,
293
+ gravity: 0.05,
294
+ sparkle: Math.random() < 0.3, // 30% chance to sparkle
295
+ active: true
296
+ };
297
+ },
298
+
299
+ /**
300
+ * Create trail particle (follows rockets)
301
+ */
302
+ createTrail(canvasWidth, canvasHeight, config, origin) {
303
+ const sizeRange = config?.sizeRange || [1, 2];
304
+
305
+ return {
306
+ type: 'trail',
307
+ x: origin?.x || canvasWidth / 2,
308
+ y: origin?.y || canvasHeight / 2,
309
+ vx: (Math.random() - 0.5) * 0.3,
310
+ vy: (Math.random() - 0.5) * 0.3,
311
+ size: sizeRange[0] + Math.random() * (sizeRange[1] - sizeRange[0]),
312
+ opacity: 0.8,
313
+ color: origin?.color || '#ffd700',
314
+ life: 1.0,
315
+ fadeRate: 0.03 + Math.random() * 0.02,
316
+ active: true
317
+ };
318
+ },
319
+
320
+ /**
321
+ * Create spark particle (small glowing bits)
322
+ */
323
+ createSpark(canvasWidth, canvasHeight, config, origin) {
324
+ const sizeRange = config?.sizeRange || [0.5, 1.5];
325
+ const angle = Math.random() * Math.PI * 2;
326
+ const speed = 0.5 + Math.random() * 2;
327
+
328
+ return {
329
+ type: 'spark',
330
+ x: origin?.x || Math.random() * canvasWidth,
331
+ y: origin?.y || Math.random() * canvasHeight,
332
+ vx: Math.cos(angle) * speed,
333
+ vy: Math.sin(angle) * speed,
334
+ size: sizeRange[0] + Math.random() * (sizeRange[1] - sizeRange[0]),
335
+ opacity: 1,
336
+ color: this.colors.secondary, // Gold sparks
337
+ life: 1.0,
338
+ fadeRate: 0.02 + Math.random() * 0.02,
339
+ active: true
340
+ };
341
+ },
342
+
343
+ /**
344
+ * Create falling particle (embers)
345
+ */
346
+ createFallingParticle(canvasWidth, canvasHeight, config) {
347
+ return this.createEmber(canvasWidth, canvasHeight, config);
348
+ },
349
+
350
+ /**
351
+ * Create initial static decorations (bonfires, Guy effigy, etc.)
352
+ */
353
+ createInitialDecorations(canvasWidth, canvasHeight, config) {
354
+ const decorations = [];
355
+
356
+ // Create bonfires
357
+ const bonfireCount = config.bonfires || 2;
358
+ for (let i = 0; i < bonfireCount; i++) {
359
+ const bonfireX = (canvasWidth / (bonfireCount + 1)) * (i + 1);
360
+ const bonfireY = canvasHeight - 60;
361
+
362
+ decorations.push(this.createBonfire(canvasWidth, canvasHeight, {
363
+ x: bonfireX,
364
+ y: bonfireY
365
+ }));
366
+
367
+ // Guy effigy on top of bonfire
368
+ decorations.push(this.createGuyEffigy(canvasWidth, canvasHeight, bonfireX, bonfireY));
369
+ }
370
+
371
+ // Catherine wheels mounted on sides
372
+ const wheelCount = Math.min(bonfireCount, 2);
373
+ for (let i = 0; i < wheelCount; i++) {
374
+ const side = i % 2 === 0 ? 'left' : 'right';
375
+ decorations.push(this.createCatherineWheel(canvasWidth, canvasHeight, {
376
+ x: side === 'left' ? 100 : canvasWidth - 100,
377
+ y: 150 + i * 100
378
+ }));
379
+ }
380
+
381
+ // Roman candles at ground level
382
+ const candleCount = Math.min(bonfireCount, 2);
383
+ for (let i = 0; i < candleCount; i++) {
384
+ decorations.push(this.createRomanCandle(canvasWidth, canvasHeight, {
385
+ x: 150 + i * ((canvasWidth - 300) / (candleCount)),
386
+ y: canvasHeight - 30
387
+ }));
388
+ }
389
+
390
+ // Sparkler bundles (decorative)
391
+ const sparklerCount = Math.min(bonfireCount + 1, 3);
392
+ for (let i = 0; i < sparklerCount; i++) {
393
+ decorations.push({
394
+ type: 'sparkler-bundle',
395
+ x: 80 + i * ((canvasWidth - 160) / (sparklerCount - 1)),
396
+ y: canvasHeight - 20,
397
+ size: 12 + Math.random() * 4,
398
+ opacity: 0.85,
399
+ sparklePhase: Math.random() * Math.PI * 2,
400
+ active: true,
401
+ static: true
402
+ });
403
+ }
404
+
405
+ // Soft red moon (top right corner)
406
+ decorations.push({
407
+ type: 'moon',
408
+ x: canvasWidth - 120,
409
+ y: 100,
410
+ size: 60 + Math.random() * 20,
411
+ opacity: 0.75,
412
+ glowPhase: Math.random() * Math.PI * 2,
413
+ active: true,
414
+ static: true
415
+ });
416
+
417
+ return decorations;
418
+ },
419
+
420
+ /**
421
+ * Spawn special Guy Fawkes particles
422
+ */
423
+ spawnSpecialParticle(specialParticles, canvasWidth, canvasHeight, config) {
424
+ const choice = Math.random();
425
+
426
+ // Fireworks (uses Christmas firework code!)
427
+ if (choice < config.fireworkChance) {
428
+ // Random explosion height between 20% and 70% of screen height (always visible)
429
+ const targetY = canvasHeight * (0.2 + Math.random() * 0.5);
430
+ const startY = canvasHeight;
431
+ const distance = startY - targetY;
432
+
433
+ // Calculate velocity to reach target in reasonable time
434
+ const flightTime = 40 + Math.random() * 20; // 40-60 frames (~0.7-1 second)
435
+ const vy = -(distance / flightTime);
436
+
437
+ return {
438
+ type: 'firework',
439
+ x: Math.random() * canvasWidth,
440
+ y: startY,
441
+ targetY: targetY, // Explosion height
442
+ vx: (Math.random() - 0.5) * 3, // Slight horizontal drift
443
+ vy: vy, // Calculated to reach target
444
+ size: 2 + Math.random() * 2,
445
+ opacity: 1,
446
+ active: true,
447
+ static: false,
448
+ time: 0,
449
+ exploded: false,
450
+ explosionTime: flightTime, // Time to reach target
451
+ color: this.colors.firework[Math.floor(Math.random() * this.colors.firework.length)]
452
+ };
453
+ }
454
+
455
+ // Bonfire (rare, max 3)
456
+ if (choice < 0.0003) {
457
+ const bonfireCount = specialParticles.filter(p => p.type === 'bonfire').length;
458
+ if (bonfireCount < config.bonfires) {
459
+ return this.createBonfire(canvasWidth, canvasHeight);
460
+ }
461
+ }
462
+
463
+ // Catherine wheel (rare)
464
+ if (choice < 0.0005) {
465
+ return this.createCatherineWheel(canvasWidth, canvasHeight);
466
+ }
467
+
468
+ // Roman candle (rare)
469
+ if (choice < 0.0008) {
470
+ return this.createRomanCandle(canvasWidth, canvasHeight);
471
+ }
472
+
473
+ return null;
474
+ },
475
+
476
+ /**
477
+ * Update special particles (fireworks, sparks, etc.)
478
+ */
479
+ updateSpecialParticles(specialParticles, deltaTime) {
480
+ // Update lightning effect
481
+ if (this.lightningActive) {
482
+ this.lightningTimer += deltaTime;
483
+ if (this.lightningTimer >= this.lightningDuration) {
484
+ this.lightningActive = false;
485
+ this.lightningTimer = 0;
486
+ }
487
+ } else {
488
+ if (Math.random() < this.lightningChance) {
489
+ this.lightningActive = true;
490
+ this.lightningTimer = 0;
491
+ }
492
+ }
493
+
494
+ for (const particle of specialParticles) {
495
+ // Increment time for animated particles
496
+ if (particle.time !== undefined) {
497
+ particle.time++;
498
+ }
499
+
500
+ // Check firework explosion
501
+ if (particle.type === 'firework' && !particle.exploded) {
502
+ // Explode when reached target height OR after flight time
503
+ const reachedTarget = particle.targetY && particle.y <= particle.targetY;
504
+ const timeExpired = particle.time >= particle.explosionTime;
505
+
506
+ if (reachedTarget || timeExpired) {
507
+ particle.exploded = true;
508
+ this.explodeFirework(particle, specialParticles);
509
+ particle.active = false; // Remove the firework itself
510
+ }
511
+ }
512
+
513
+ // Fade out sparks
514
+ if (particle.type === 'spark') {
515
+ particle.opacity -= 0.015;
516
+ particle.vy += 0.15; // Gravity
517
+ if (particle.opacity <= 0) {
518
+ particle.active = false;
519
+ }
520
+ }
521
+
522
+ // Update Catherine Wheel
523
+ if (particle.type === 'catherine-wheel') {
524
+ particle.rotation += particle.rotationSpeed * (deltaTime / 16); // Normalize rotation speed
525
+ particle.time += deltaTime;
526
+
527
+ // Deactivate after duration
528
+ if (particle.time > particle.duration) {
529
+ particle.active = false;
530
+ }
531
+
532
+ // Emit new sparks
533
+ particle.sparkTimer += deltaTime;
534
+ if (particle.sparkTimer >= particle.sparkInterval) {
535
+ particle.sparkTimer = 0;
536
+ // Emit multiple sparks per interval
537
+ const numSparks = 2 + Math.floor(Math.random() * 3); // 2-4 sparks
538
+ for (let i = 0; i < numSparks; i++) {
539
+ const angle = Math.random() * Math.PI * 2; // Random direction
540
+ const speed = 1 + Math.random() * 3;
541
+ particle.sparks.push({
542
+ x: 0, // Relative to wheel center
543
+ y: 0, // Relative to wheel center
544
+ vx: Math.cos(angle) * speed,
545
+ vy: Math.sin(angle) * speed,
546
+ size: 1 + Math.random() * 1.5,
547
+ opacity: 1,
548
+ life: 1.0,
549
+ fadeRate: 0.05 + Math.random() * 0.03,
550
+ color: this.colors.firework[Math.floor(Math.random() * this.colors.firework.length)]
551
+ });
552
+ }
553
+ }
554
+
555
+ // Update existing sparks
556
+ particle.sparks = particle.sparks.filter(spark => {
557
+ spark.x += spark.vx;
558
+ spark.y += spark.vy;
559
+ spark.life -= spark.fadeRate;
560
+ spark.opacity = Math.max(0, spark.life);
561
+ return spark.life > 0;
562
+ });
563
+ }
564
+ }
565
+ },
566
+
567
+ /**
568
+ * Draw floating ember
569
+ */
570
+ drawEmber(ctx, particle, time) {
571
+ // Safety check: validate particle properties
572
+ if (!isFinite(particle.x) || !isFinite(particle.y) || !isFinite(particle.size) ||
573
+ !isFinite(particle.vx) || !isFinite(particle.vy)) {
574
+ console.warn('[Guy Fawkes] Invalid ember particle values, deactivating:', particle);
575
+ particle.active = false;
576
+ return;
577
+ }
578
+
579
+ // Update physics - rising motion
580
+ if (!particle.exploded) {
581
+ particle.x += particle.vx;
582
+ particle.y += particle.vy;
583
+ particle.vy += 0.02; // Slight gravity
584
+
585
+ // Validate after update
586
+ if (!isFinite(particle.x) || !isFinite(particle.y)) {
587
+ console.warn('[Guy Fawkes] Ember particle became non-finite after update, deactivating');
588
+ particle.active = false;
589
+ return;
590
+ }
591
+
592
+ // Check if reached target height
593
+ if (particle.y <= particle.targetY) {
594
+ particle.exploded = true;
595
+ // Create DRAMATIC explosion particles (MORE and BIGGER!)
596
+ const explosionCount = 50 + Math.floor(Math.random() * 40); // 50-90 particles
597
+ for (let i = 0; i < explosionCount; i++) {
598
+ const angle = (i / explosionCount) * Math.PI * 2;
599
+ const speed = 2 + Math.random() * 5; // Faster spread
600
+ const isTracer = i % 5 === 0; // Every 5th particle is a bright tracer
601
+ particle.explosionParticles.push({
602
+ x: particle.x,
603
+ y: particle.y,
604
+ vx: Math.cos(angle) * speed,
605
+ vy: Math.sin(angle) * speed,
606
+ size: isTracer ? 2 + Math.random() * 2 : 0.8 + Math.random() * 2, // Bigger particles
607
+ color: isTracer ? '#ffffff' : this.colors.firework[Math.floor(Math.random() * this.colors.firework.length)],
608
+ opacity: 1,
609
+ life: 1.0,
610
+ trail: [] // Add trail for tracer particles
611
+ });
612
+ }
613
+ }
614
+ }
615
+
616
+ const x = particle.x;
617
+ const y = particle.y;
618
+ const size = particle.size;
619
+
620
+ ctx.save();
621
+
622
+ if (!particle.exploded) {
623
+ // Rising ember with trail
624
+ const glowIntensity = 0.8 + (Math.sin(time * 0.01 + particle.glowPhase) + 1) * 0.2;
625
+
626
+ // Trail
627
+ ctx.globalAlpha = 0.3;
628
+ ctx.strokeStyle = particle.trailColor;
629
+ ctx.lineWidth = size * 0.8;
630
+ ctx.lineCap = 'round';
631
+ ctx.beginPath();
632
+ ctx.moveTo(x, y);
633
+ ctx.lineTo(x - particle.vx * 5, y - particle.vy * 5);
634
+ ctx.stroke();
635
+
636
+ // Glow
637
+ const gradient = ctx.createRadialGradient(x, y, 0, x, y, size * 4);
638
+ gradient.addColorStop(0, particle.color);
639
+ gradient.addColorStop(0.5, `${particle.color}80`);
640
+ gradient.addColorStop(1, 'rgba(255, 69, 0, 0)');
641
+ ctx.globalAlpha = glowIntensity;
642
+ ctx.fillStyle = gradient;
643
+ ctx.fillRect(x - size * 4, y - size * 4, size * 8, size * 8);
644
+
645
+ // Core
646
+ ctx.globalAlpha = 1;
647
+ ctx.fillStyle = particle.color;
648
+ ctx.shadowColor = particle.color;
649
+ ctx.shadowBlur = size * 2;
650
+ ctx.beginPath();
651
+ ctx.arc(x, y, size, 0, Math.PI * 2);
652
+ ctx.fill();
653
+ } else {
654
+ // Draw explosion particles
655
+ particle.explosionParticles = particle.explosionParticles.filter(p => {
656
+ p.x += p.vx;
657
+ p.y += p.vy;
658
+ p.vy += 0.05; // Gravity on explosion particles
659
+ p.life -= 0.015;
660
+ p.opacity = p.life;
661
+
662
+ if (p.life <= 0) return false;
663
+
664
+ // Draw explosion particle
665
+ ctx.globalAlpha = p.opacity;
666
+ ctx.fillStyle = p.color;
667
+ ctx.shadowColor = p.color;
668
+ ctx.shadowBlur = p.size * 3;
669
+ ctx.beginPath();
670
+ ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
671
+ ctx.fill();
672
+
673
+ return true;
674
+ });
675
+
676
+ // Deactivate when all explosion particles are gone
677
+ if (particle.explosionParticles.length === 0) {
678
+ particle.active = false;
679
+ }
680
+ }
681
+
682
+ ctx.restore();
683
+ },
684
+
685
+ /**
686
+ * Draw bonfire with animated flames
687
+ */
688
+ drawBonfire(ctx, particle, time) {
689
+ const x = particle.x;
690
+ const y = particle.y;
691
+ const size = particle.size;
692
+
693
+ ctx.save();
694
+ ctx.translate(x, y);
695
+
696
+ // Logs (wood pile)
697
+ ctx.fillStyle = '#8B4513';
698
+ ctx.strokeStyle = '#654321';
699
+ ctx.lineWidth = 2;
700
+
701
+ // Bottom log
702
+ ctx.fillRect(-size * 0.8, size * 0.3, size * 1.6, size * 0.3);
703
+ ctx.strokeRect(-size * 0.8, size * 0.3, size * 1.6, size * 0.3);
704
+
705
+ // Middle logs (crossed)
706
+ ctx.save();
707
+ ctx.rotate(-0.3);
708
+ ctx.fillRect(-size * 0.7, size * 0.1, size * 1.4, size * 0.25);
709
+ ctx.strokeRect(-size * 0.7, size * 0.1, size * 1.4, size * 0.25);
710
+ ctx.restore();
711
+
712
+ ctx.save();
713
+ ctx.rotate(0.3);
714
+ ctx.fillRect(-size * 0.7, size * 0.1, size * 1.4, size * 0.25);
715
+ ctx.strokeRect(-size * 0.7, size * 0.1, size * 1.4, size * 0.25);
716
+ ctx.restore();
717
+
718
+ // Animated flames (BIGGER and MORE DRAMATIC!)
719
+ const flameCount = 12; // More flames for fuller effect
720
+ for (let i = 0; i < flameCount; i++) {
721
+ const flameX = -size * 0.6 + (i / flameCount) * size * 1.2;
722
+ // Taller, more dynamic flames
723
+ const flameHeight = size * (1.2 + Math.sin(time * 0.006 + i) * 0.5);
724
+ const flameWidth = size * (0.2 + Math.sin(time * 0.01 + i * 0.5) * 0.12);
725
+
726
+ // More dramatic flame gradient with white-hot core
727
+ const flameGradient = ctx.createLinearGradient(flameX, 0, flameX, -flameHeight);
728
+ flameGradient.addColorStop(0, '#ffffff'); // White-hot base
729
+ flameGradient.addColorStop(0.15, '#ffff00'); // Yellow
730
+ flameGradient.addColorStop(0.4, '#ffa500'); // Orange
731
+ flameGradient.addColorStop(0.7, '#ff4500'); // Red-orange
732
+ flameGradient.addColorStop(0.85, '#ff0000'); // Red
733
+ flameGradient.addColorStop(1, 'rgba(139, 0, 0, 0)'); // Dark red fade
734
+
735
+ ctx.fillStyle = flameGradient;
736
+ ctx.globalAlpha = 0.85;
737
+ ctx.shadowColor = '#ff6600';
738
+ ctx.shadowBlur = size * 0.3;
739
+
740
+ ctx.beginPath();
741
+ ctx.moveTo(flameX, 0);
742
+ // More exaggerated, flickering shape
743
+ ctx.bezierCurveTo(
744
+ flameX - flameWidth, -flameHeight * 0.4,
745
+ flameX - flameWidth * 0.6, -flameHeight * 0.7,
746
+ flameX, -flameHeight
747
+ );
748
+ ctx.bezierCurveTo(
749
+ flameX + flameWidth * 0.6, -flameHeight * 0.7,
750
+ flameX + flameWidth, -flameHeight * 0.4,
751
+ flameX, 0
752
+ );
753
+ ctx.closePath();
754
+ ctx.fill();
755
+ }
756
+ ctx.shadowBlur = 0;
757
+ ctx.globalAlpha = 1;
758
+
759
+ // Glow
760
+ const glowGradient = ctx.createRadialGradient(0, 0, 0, 0, 0, size * 2);
761
+ glowGradient.addColorStop(0, 'rgba(255, 165, 0, 0.4)');
762
+ glowGradient.addColorStop(0.5, 'rgba(255, 69, 0, 0.2)');
763
+ glowGradient.addColorStop(1, 'rgba(255, 69, 0, 0)');
764
+ ctx.fillStyle = glowGradient;
765
+ ctx.fillRect(-size * 2, -size * 2, size * 4, size * 2);
766
+
767
+ // Sparks/embers rising from fire
768
+ for (let i = 0; i < 5; i++) {
769
+ const sparkX = (Math.random() - 0.5) * size * 0.8;
770
+ const sparkY = -size * Math.random() * 1.5;
771
+ const sparkSize = 1 + Math.random() * 2;
772
+ ctx.fillStyle = Math.random() < 0.5 ? '#ffa500' : '#ffff00';
773
+ ctx.globalAlpha = Math.random() * 0.8;
774
+ ctx.beginPath();
775
+ ctx.arc(sparkX, sparkY, sparkSize, 0, Math.PI * 2);
776
+ ctx.fill();
777
+ }
778
+
779
+ ctx.restore();
780
+ },
781
+
782
+ /**
783
+ * Draw Guy Fawkes effigy
784
+ */
785
+ drawGuyEffigy(ctx, particle, time) {
786
+ const x = particle.x;
787
+ const y = particle.y;
788
+ const size = particle.size;
789
+
790
+ ctx.save();
791
+ ctx.globalAlpha = particle.opacity * (1 - particle.burnProgress);
792
+ ctx.translate(x, y);
793
+
794
+ // Body (straw/cloth)
795
+ ctx.fillStyle = '#8B7355';
796
+ ctx.fillRect(-size * 0.4, 0, size * 0.8, size * 1.5);
797
+
798
+ // Arms
799
+ ctx.fillRect(-size * 0.9, size * 0.3, size * 0.5, size * 0.2);
800
+ ctx.fillRect(size * 0.4, size * 0.3, size * 0.5, size * 0.2);
801
+
802
+ // Head (sack/mask)
803
+ ctx.fillStyle = '#D2B48C';
804
+ ctx.beginPath();
805
+ ctx.arc(0, -size * 0.3, size * 0.5, 0, Math.PI * 2);
806
+ ctx.fill();
807
+
808
+ // Hat
809
+ ctx.fillStyle = '#4a4a4a';
810
+ ctx.fillRect(-size * 0.6, -size * 0.8, size * 1.2, size * 0.2);
811
+ ctx.fillRect(-size * 0.4, -size * 1.2, size * 0.8, size * 0.4);
812
+
813
+ // Face (scary mask)
814
+ ctx.fillStyle = '#000000';
815
+ // Eyes
816
+ ctx.fillRect(-size * 0.25, -size * 0.4, size * 0.15, size * 0.2);
817
+ ctx.fillRect(size * 0.1, -size * 0.4, size * 0.15, size * 0.2);
818
+
819
+ // Mouth (grin)
820
+ ctx.beginPath();
821
+ ctx.arc(0, -size * 0.1, size * 0.25, 0, Math.PI);
822
+ ctx.fill();
823
+
824
+ // Burning effect if burning
825
+ if (particle.burning) {
826
+ ctx.globalAlpha = 1;
827
+ const fireGradient = ctx.createRadialGradient(0, 0, 0, 0, 0, size * 2);
828
+ fireGradient.addColorStop(0, 'rgba(255, 255, 0, 0.8)');
829
+ fireGradient.addColorStop(0.5, 'rgba(255, 69, 0, 0.5)');
830
+ fireGradient.addColorStop(1, 'rgba(255, 0, 0, 0)');
831
+ ctx.fillStyle = fireGradient;
832
+ ctx.fillRect(-size * 2, -size * 2, size * 4, size * 4);
833
+ }
834
+
835
+ ctx.restore();
836
+ },
837
+
838
+ /**
839
+ * Draw Catherine wheel (spinning firework) - IMPROVED
840
+ */
841
+ drawCatherineWheel(ctx, particle, time) {
842
+ const x = particle.x;
843
+ const y = particle.y;
844
+ const size = particle.size;
845
+
846
+ ctx.save();
847
+ ctx.translate(x, y);
848
+ ctx.rotate(particle.rotation);
849
+
850
+ // Center pin/mount
851
+ ctx.fillStyle = '#654321'; // Dark wood/metal color
852
+ ctx.beginPath();
853
+ ctx.arc(0, 0, size * 0.1, 0, Math.PI * 2);
854
+ ctx.fill();
855
+
856
+ // Wheel structure (concentric circles, more defined)
857
+ ctx.strokeStyle = '#8B4513'; // Brownish for the cardboard/wood wheel
858
+ ctx.lineWidth = size * 0.15;
859
+ ctx.beginPath();
860
+ ctx.arc(0, 0, size * 0.6, 0, Math.PI * 2);
861
+ ctx.stroke();
862
+
863
+ ctx.strokeStyle = '#cc6600'; // Inner ring for color
864
+ ctx.lineWidth = size * 0.1;
865
+ ctx.beginPath();
866
+ ctx.arc(0, 0, size * 0.4, 0, Math.PI * 2);
867
+ ctx.stroke();
868
+
869
+ // Outer firework tubes/spokes
870
+ const tubeCount = 8;
871
+ for (let i = 0; i < tubeCount; i++) {
872
+ const angle = (i / tubeCount) * Math.PI * 2;
873
+ const tubeX = Math.cos(angle) * size * 0.7;
874
+ const tubeY = Math.sin(angle) * size * 0.7;
875
+
876
+ ctx.fillStyle = '#444444'; // Dark grey for tubes
877
+ ctx.strokeStyle = '#222222';
878
+ ctx.lineWidth = 1;
879
+ ctx.beginPath();
880
+ ctx.arc(tubeX, tubeY, size * 0.15, 0, Math.PI * 2);
881
+ ctx.fill();
882
+ ctx.stroke();
883
+ }
884
+
885
+ // Draw emitted sparks
886
+ particle.sparks.forEach(spark => {
887
+ ctx.save();
888
+ ctx.translate(spark.x, spark.y);
889
+
890
+ // Spark glow
891
+ const glowGradient = ctx.createRadialGradient(0, 0, 0, 0, 0, spark.size * 5);
892
+ glowGradient.addColorStop(0, spark.color);
893
+ glowGradient.addColorStop(0.5, `${spark.color}80`);
894
+ glowGradient.addColorStop(1, 'rgba(255, 69, 0, 0)'); // Fade to transparent orange/red
895
+ ctx.fillStyle = glowGradient;
896
+ ctx.globalAlpha = spark.opacity * 0.6;
897
+ ctx.fillRect(-spark.size * 5, -spark.size * 5, spark.size * 10, spark.size * 10);
898
+
899
+ // Spark core
900
+ ctx.globalAlpha = spark.opacity;
901
+ ctx.fillStyle = spark.color;
902
+ ctx.shadowColor = spark.color;
903
+ ctx.shadowBlur = spark.size * 3;
904
+ ctx.beginPath();
905
+ ctx.arc(0, 0, spark.size, 0, Math.PI * 2);
906
+ ctx.fill();
907
+
908
+ ctx.restore();
909
+ });
910
+
911
+ // Pulsing inner glow from the active wheel
912
+ const innerGlowIntensity = 0.6 + Math.sin(time * 0.01) * 0.4;
913
+ ctx.globalAlpha = innerGlowIntensity * particle.opacity;
914
+ const innerGlow = ctx.createRadialGradient(0, 0, 0, 0, 0, size);
915
+ innerGlow.addColorStop(0, 'rgba(255, 200, 0, 0.7)');
916
+ innerGlow.addColorStop(0.5, 'rgba(255, 100, 0, 0.4)');
917
+ innerGlow.addColorStop(1, 'rgba(255, 0, 0, 0)');
918
+ ctx.fillStyle = innerGlow;
919
+ ctx.beginPath();
920
+ ctx.arc(0, 0, size, 0, Math.PI * 2);
921
+ ctx.fill();
922
+
923
+ ctx.shadowBlur = 0;
924
+ ctx.restore();
925
+ },
926
+
927
+ /**
928
+ * Draw Roman candle
929
+ */
930
+ drawRomanCandle(ctx, particle, time) {
931
+ const x = particle.x;
932
+ const y = particle.y;
933
+ const size = particle.size;
934
+
935
+ ctx.save();
936
+ ctx.translate(x, y);
937
+
938
+ // Candle body (tube)
939
+ ctx.fillStyle = '#c00';
940
+ ctx.strokeStyle = '#800';
941
+ ctx.lineWidth = 2;
942
+ ctx.fillRect(-size * 0.4, 0, size * 0.8, size * 2);
943
+ ctx.strokeRect(-size * 0.4, 0, size * 0.8, size * 2);
944
+
945
+ // Gold label
946
+ ctx.fillStyle = '#ffd700';
947
+ ctx.fillRect(-size * 0.3, size * 0.8, size * 0.6, size * 0.5);
948
+
949
+ // Fuse/top
950
+ ctx.fillStyle = '#654321';
951
+ ctx.fillRect(-size * 0.2, -size * 0.3, size * 0.4, size * 0.3);
952
+
953
+ // Spark at top when firing
954
+ if (particle.shotCount < particle.maxShots) {
955
+ const sparkIntensity = Math.sin(time * 0.05) * 0.5 + 0.5;
956
+ ctx.fillStyle = `rgba(255, 255, 0, ${sparkIntensity})`;
957
+ ctx.shadowColor = '#ffff00';
958
+ ctx.shadowBlur = 15;
959
+ ctx.beginPath();
960
+ ctx.arc(0, -size * 0.2, size * 0.3, 0, Math.PI * 2);
961
+ ctx.fill();
962
+ }
963
+
964
+ ctx.shadowBlur = 0;
965
+ ctx.restore();
966
+
967
+ // Draw shots in flight
968
+ particle.shots.forEach(shot => {
969
+ ctx.save();
970
+ ctx.globalAlpha = shot.opacity;
971
+
972
+ // Trail
973
+ ctx.strokeStyle = shot.color;
974
+ ctx.lineWidth = 3;
975
+ ctx.shadowColor = shot.color;
976
+ ctx.shadowBlur = 10;
977
+ ctx.beginPath();
978
+ ctx.moveTo(shot.x, shot.y);
979
+ ctx.lineTo(shot.x, shot.y + 10);
980
+ ctx.stroke();
981
+
982
+ // Core
983
+ ctx.fillStyle = shot.color;
984
+ ctx.beginPath();
985
+ ctx.arc(shot.x, shot.y, shot.size, 0, Math.PI * 2);
986
+ ctx.fill();
987
+
988
+ ctx.shadowBlur = 0;
989
+ ctx.restore();
990
+ });
991
+ },
992
+
993
+ /**
994
+ * Draw sparkler bundle (decorative handheld fireworks)
995
+ */
996
+ drawSparklerBundle(ctx, particle, time) {
997
+ const x = particle.x;
998
+ const y = particle.y;
999
+ const size = particle.size;
1000
+
1001
+ ctx.save();
1002
+ ctx.globalAlpha = particle.opacity;
1003
+ ctx.translate(x, y);
1004
+
1005
+ // Bundle of sparklers (5 sticks)
1006
+ const sparklerCount = 5;
1007
+ const spread = size * 0.15;
1008
+
1009
+ for (let i = 0; i < sparklerCount; i++) {
1010
+ const offsetX = (i - 2) * spread;
1011
+ const tilt = (i - 2) * 0.05;
1012
+
1013
+ ctx.save();
1014
+ ctx.translate(offsetX, 0);
1015
+ ctx.rotate(tilt);
1016
+
1017
+ // Stick (metallic wire)
1018
+ ctx.strokeStyle = '#888888';
1019
+ ctx.lineWidth = size * 0.08;
1020
+ ctx.lineCap = 'round';
1021
+ ctx.beginPath();
1022
+ ctx.moveTo(0, 0);
1023
+ ctx.lineTo(0, -size * 1.8);
1024
+ ctx.stroke();
1025
+
1026
+ // Sparkler tip (lit)
1027
+ const sparkIntensity = Math.sin(time * 0.01 + particle.sparklePhase + i * 0.5) * 0.5 + 0.5;
1028
+ const tipGradient = ctx.createRadialGradient(0, -size * 1.8, 0, 0, -size * 1.8, size * 0.5);
1029
+ tipGradient.addColorStop(0, `rgba(255, 255, 255, ${sparkIntensity})`);
1030
+ tipGradient.addColorStop(0.4, `rgba(255, 215, 0, ${sparkIntensity * 0.8})`);
1031
+ tipGradient.addColorStop(0.7, `rgba(255, 140, 0, ${sparkIntensity * 0.5})`);
1032
+ tipGradient.addColorStop(1, 'rgba(255, 69, 0, 0)');
1033
+
1034
+ ctx.fillStyle = tipGradient;
1035
+ ctx.beginPath();
1036
+ ctx.arc(0, -size * 1.8, size * 0.5, 0, Math.PI * 2);
1037
+ ctx.fill();
1038
+
1039
+ // Tiny sparks flying off
1040
+ const sparkCount = 3;
1041
+ for (let j = 0; j < sparkCount; j++) {
1042
+ const sparkAngle = Math.random() * Math.PI * 2;
1043
+ const sparkDist = size * (0.6 + Math.random() * 0.4);
1044
+ const sparkX = Math.cos(sparkAngle) * sparkDist;
1045
+ const sparkY = -size * 1.8 + Math.sin(sparkAngle) * sparkDist;
1046
+ const sparkSize = size * (0.05 + Math.random() * 0.08);
1047
+
1048
+ ctx.fillStyle = Math.random() < 0.5 ? '#ffffff' : '#ffd700';
1049
+ ctx.shadowColor = ctx.fillStyle;
1050
+ ctx.shadowBlur = size * 0.3;
1051
+ ctx.globalAlpha = particle.opacity * Math.random() * 0.8;
1052
+
1053
+ ctx.beginPath();
1054
+ ctx.arc(sparkX, sparkY, sparkSize, 0, Math.PI * 2);
1055
+ ctx.fill();
1056
+ }
1057
+
1058
+ ctx.restore();
1059
+ }
1060
+
1061
+ // Handle/holder at bottom
1062
+ ctx.fillStyle = '#654321';
1063
+ ctx.fillRect(-size * 0.5, -size * 0.2, size, size * 0.4);
1064
+
1065
+ ctx.shadowBlur = 0;
1066
+ ctx.restore();
1067
+ },
1068
+
1069
+ /**
1070
+ * Draw firework (reuses Christmas firework code)
1071
+ */
1072
+ drawFirework(ctx, particle) {
1073
+ return christmas.drawFirework(ctx, particle);
1074
+ },
1075
+
1076
+ /**
1077
+ * Explode firework (reuses Christmas explosion code)
1078
+ */
1079
+ explodeFirework(particle, specialParticles) {
1080
+ // Use themed colors instead of Christmas colors
1081
+ const sparkCount = 60 + Math.random() * 60;
1082
+ const colors = this.colors.firework;
1083
+
1084
+ for (let i = 0; i < sparkCount; i++) {
1085
+ const angle = Math.random() * Math.PI * 2;
1086
+ const speed = Math.random() * 6 + 3;
1087
+ specialParticles.push({
1088
+ type: 'spark',
1089
+ x: particle.x,
1090
+ y: particle.y,
1091
+ vx: Math.cos(angle) * speed,
1092
+ vy: Math.sin(angle) * speed,
1093
+ size: 2 + Math.random() * 2.5,
1094
+ opacity: 1,
1095
+ active: true,
1096
+ static: false,
1097
+ color: colors[Math.floor(Math.random() * colors.length)],
1098
+ trail: []
1099
+ });
1100
+ }
1101
+ },
1102
+
1103
+ /**
1104
+ * Draw soft red moon (Bonfire Night atmosphere)
1105
+ */
1106
+ drawMoon(ctx, particle, time) {
1107
+ const x = particle.x;
1108
+ const y = particle.y;
1109
+ const size = particle.size;
1110
+
1111
+ ctx.save();
1112
+ ctx.globalAlpha = particle.opacity;
1113
+ ctx.translate(x, y);
1114
+
1115
+ // Moon body (soft red/orange tint)
1116
+ const moonGradient = ctx.createRadialGradient(-size * 0.2, -size * 0.2, 0, 0, 0, size);
1117
+ moonGradient.addColorStop(0, '#ffb6a0'); // Soft peachy center
1118
+ moonGradient.addColorStop(0.5, '#ff8c66'); // Warm orange-red
1119
+ moonGradient.addColorStop(1, '#cc6644'); // Deeper red edge
1120
+ ctx.fillStyle = moonGradient;
1121
+ ctx.beginPath();
1122
+ ctx.arc(0, 0, size, 0, Math.PI * 2);
1123
+ ctx.fill();
1124
+
1125
+ // Craters (darker red)
1126
+ ctx.fillStyle = 'rgba(120, 50, 40, 0.25)';
1127
+ ctx.beginPath();
1128
+ ctx.arc(-size * 0.3, -size * 0.2, size * 0.15, 0, Math.PI * 2);
1129
+ ctx.fill();
1130
+ ctx.beginPath();
1131
+ ctx.arc(size * 0.25, size * 0.1, size * 0.2, 0, Math.PI * 2);
1132
+ ctx.fill();
1133
+ ctx.beginPath();
1134
+ ctx.arc(size * 0.1, -size * 0.4, size * 0.12, 0, Math.PI * 2);
1135
+ ctx.fill();
1136
+
1137
+ // Soft smoke wisps drifting across moon surface
1138
+ ctx.strokeStyle = 'rgba(100, 60, 50, 0.15)';
1139
+ ctx.lineWidth = size * 0.08;
1140
+ ctx.lineCap = 'round';
1141
+ const smokeWave = Math.sin(time * 0.001 + particle.glowPhase) * size * 0.3;
1142
+ for (let i = 0; i < 3; i++) {
1143
+ ctx.beginPath();
1144
+ const yPos = -size * 0.4 + i * size * 0.4;
1145
+ ctx.moveTo(-size * 0.8, yPos + smokeWave * (i % 2 === 0 ? 1 : -1));
1146
+ ctx.bezierCurveTo(
1147
+ -size * 0.3, yPos + smokeWave * 0.5,
1148
+ size * 0.3, yPos - smokeWave * 0.5,
1149
+ size * 0.8, yPos + smokeWave * (i % 2 === 0 ? -1 : 1)
1150
+ );
1151
+ ctx.stroke();
1152
+ }
1153
+
1154
+ // Soft red glow (pulsing gently)
1155
+ const glowIntensity = 0.25 + Math.sin(time * 0.0015 + particle.glowPhase) * 0.1;
1156
+ ctx.globalAlpha = glowIntensity;
1157
+ const glowGradient = ctx.createRadialGradient(0, 0, size * 0.8, 0, 0, size * 2.0);
1158
+ glowGradient.addColorStop(0, 'rgba(255, 140, 100, 0.6)');
1159
+ glowGradient.addColorStop(0.5, 'rgba(255, 100, 80, 0.3)');
1160
+ glowGradient.addColorStop(1, 'rgba(255, 80, 60, 0)');
1161
+ ctx.fillStyle = glowGradient;
1162
+ ctx.beginPath();
1163
+ ctx.arc(0, 0, size * 2.0, 0, Math.PI * 2);
1164
+ ctx.fill();
1165
+
1166
+ ctx.restore();
1167
+ },
1168
+
1169
+ /**
1170
+ * Draw rocket particle (shoots up with trail, then explodes)
1171
+ */
1172
+ drawRocket(ctx, particle, time) {
1173
+ ctx.save();
1174
+
1175
+ if (!particle.exploded) {
1176
+ // Update physics - upward flight
1177
+ particle.x += particle.vx;
1178
+ particle.y += particle.vy;
1179
+ particle.rotation += particle.rotationSpeed;
1180
+
1181
+ // Check if reached target explosion height
1182
+ if (particle.y <= particle.targetY) {
1183
+ particle.exploded = true;
1184
+
1185
+ // Create MASSIVE explosion - more burst particles than embers!
1186
+ const explosionCount = 80 + Math.floor(Math.random() * 60); // 80-140 particles!
1187
+ particle.explosionParticles = [];
1188
+
1189
+ for (let i = 0; i < explosionCount; i++) {
1190
+ const angle = (i / explosionCount) * Math.PI * 2;
1191
+ const speed = 2 + Math.random() * 6; // Fast explosive spread
1192
+ const isTracer = i % 6 === 0; // Every 6th particle is bright tracer
1193
+
1194
+ particle.explosionParticles.push({
1195
+ x: particle.x,
1196
+ y: particle.y,
1197
+ vx: Math.cos(angle) * speed,
1198
+ vy: Math.sin(angle) * speed,
1199
+ size: isTracer ? 2.5 + Math.random() * 2 : 1 + Math.random() * 2,
1200
+ color: isTracer ? '#ffffff' : this.colors.firework[Math.floor(Math.random() * this.colors.firework.length)],
1201
+ opacity: 1,
1202
+ life: 1.0,
1203
+ gravity: 0.05,
1204
+ sparkle: Math.random() < 0.3
1205
+ });
1206
+ }
1207
+ }
1208
+
1209
+ const x = particle.x;
1210
+ const y = particle.y;
1211
+ const size = particle.size;
1212
+
1213
+ // Draw rocket trail
1214
+ const trailLength = 15;
1215
+ ctx.globalAlpha = 0.6;
1216
+ const trailGradient = ctx.createLinearGradient(x, y, x - particle.vx * trailLength, y - particle.vy * trailLength);
1217
+ trailGradient.addColorStop(0, particle.color);
1218
+ trailGradient.addColorStop(0.5, `${particle.color}80`);
1219
+ trailGradient.addColorStop(1, 'rgba(255, 69, 0, 0)');
1220
+ ctx.strokeStyle = trailGradient;
1221
+ ctx.lineWidth = size * 1.5;
1222
+ ctx.lineCap = 'round';
1223
+ ctx.beginPath();
1224
+ ctx.moveTo(x, y);
1225
+ ctx.lineTo(x - particle.vx * trailLength, y - particle.vy * trailLength);
1226
+ ctx.stroke();
1227
+
1228
+ // Rocket glow
1229
+ ctx.globalAlpha = 0.8;
1230
+ const glowGradient = ctx.createRadialGradient(x, y, 0, x, y, size * 5);
1231
+ glowGradient.addColorStop(0, particle.color);
1232
+ glowGradient.addColorStop(0.5, `${particle.color}60`);
1233
+ glowGradient.addColorStop(1, 'rgba(255, 69, 0, 0)');
1234
+ ctx.fillStyle = glowGradient;
1235
+ ctx.fillRect(x - size * 5, y - size * 5, size * 10, size * 10);
1236
+
1237
+ // Rocket core (bright white-hot center)
1238
+ ctx.globalAlpha = 1;
1239
+ ctx.fillStyle = '#ffffff';
1240
+ ctx.shadowColor = particle.color;
1241
+ ctx.shadowBlur = size * 3;
1242
+ ctx.beginPath();
1243
+ ctx.arc(x, y, size * 1.2, 0, Math.PI * 2);
1244
+ ctx.fill();
1245
+
1246
+ // Outer colored ring
1247
+ ctx.fillStyle = particle.color;
1248
+ ctx.shadowBlur = size * 2;
1249
+ ctx.beginPath();
1250
+ ctx.arc(x, y, size * 0.8, 0, Math.PI * 2);
1251
+ ctx.fill();
1252
+ } else {
1253
+ // Draw explosion particles (similar to ember explosions)
1254
+ particle.explosionParticles = particle.explosionParticles.filter(p => {
1255
+ p.x += p.vx;
1256
+ p.y += p.vy;
1257
+ p.vy += p.gravity; // Gravity pulls down
1258
+ p.life -= 0.012; // Faster fade than embers for dramatic effect
1259
+ p.opacity = p.life;
1260
+
1261
+ if (p.life <= 0) return false;
1262
+
1263
+ // Draw explosion particle with glow
1264
+ ctx.globalAlpha = p.opacity * 0.6;
1265
+ const glowGradient = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.size * 4);
1266
+ glowGradient.addColorStop(0, p.color);
1267
+ glowGradient.addColorStop(0.5, `${p.color}60`);
1268
+ glowGradient.addColorStop(1, 'rgba(255, 69, 0, 0)');
1269
+ ctx.fillStyle = glowGradient;
1270
+ ctx.fillRect(p.x - p.size * 4, p.y - p.size * 4, p.size * 8, p.size * 8);
1271
+
1272
+ // Core
1273
+ ctx.globalAlpha = p.opacity;
1274
+ ctx.fillStyle = p.color;
1275
+ ctx.shadowColor = p.color;
1276
+ ctx.shadowBlur = p.size * 3;
1277
+
1278
+ // Sparkle effect
1279
+ if (p.sparkle && Math.random() < 0.3) {
1280
+ ctx.shadowBlur = p.size * 5;
1281
+ ctx.fillStyle = '#ffffff';
1282
+ }
1283
+
1284
+ ctx.beginPath();
1285
+ ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
1286
+ ctx.fill();
1287
+
1288
+ return true;
1289
+ });
1290
+
1291
+ // Deactivate rocket when all explosion particles are gone
1292
+ if (particle.explosionParticles.length === 0) {
1293
+ particle.active = false;
1294
+ }
1295
+ }
1296
+
1297
+ ctx.shadowBlur = 0;
1298
+ ctx.restore();
1299
+ },
1300
+
1301
+ /**
1302
+ * Draw burst particle (explosion fragment)
1303
+ */
1304
+ drawBurst(ctx, particle) {
1305
+ const x = particle.x;
1306
+ const y = particle.y;
1307
+ const size = particle.size;
1308
+
1309
+ ctx.save();
1310
+
1311
+ // Update physics
1312
+ particle.x += particle.vx;
1313
+ particle.y += particle.vy;
1314
+ particle.vy += particle.gravity; // Gravity pulls down
1315
+ particle.life -= particle.fadeRate;
1316
+ particle.opacity = Math.max(0, particle.life);
1317
+
1318
+ // Deactivate when faded out
1319
+ if (particle.life <= 0) {
1320
+ particle.active = false;
1321
+ ctx.restore();
1322
+ return;
1323
+ }
1324
+
1325
+ // Glow effect
1326
+ ctx.globalAlpha = particle.opacity * 0.6;
1327
+ const glowGradient = ctx.createRadialGradient(x, y, 0, x, y, size * 4);
1328
+ glowGradient.addColorStop(0, particle.color);
1329
+ glowGradient.addColorStop(0.5, `${particle.color}60`);
1330
+ glowGradient.addColorStop(1, 'rgba(255, 69, 0, 0)');
1331
+ ctx.fillStyle = glowGradient;
1332
+ ctx.fillRect(x - size * 4, y - size * 4, size * 8, size * 8);
1333
+
1334
+ // Core particle
1335
+ ctx.globalAlpha = particle.opacity;
1336
+ ctx.fillStyle = particle.color;
1337
+ ctx.shadowColor = particle.color;
1338
+ ctx.shadowBlur = size * 2;
1339
+
1340
+ // Sparkle effect (randomly brighter)
1341
+ if (particle.sparkle && Math.random() < 0.3) {
1342
+ ctx.shadowBlur = size * 4;
1343
+ ctx.fillStyle = '#ffffff';
1344
+ }
1345
+
1346
+ ctx.beginPath();
1347
+ ctx.arc(x, y, size, 0, Math.PI * 2);
1348
+ ctx.fill();
1349
+
1350
+ ctx.shadowBlur = 0;
1351
+ ctx.restore();
1352
+ },
1353
+
1354
+ /**
1355
+ * Draw trail particle (follows rockets)
1356
+ */
1357
+ drawTrail(ctx, particle) {
1358
+ const x = particle.x;
1359
+ const y = particle.y;
1360
+ const size = particle.size;
1361
+
1362
+ ctx.save();
1363
+
1364
+ // Update physics
1365
+ particle.x += particle.vx;
1366
+ particle.y += particle.vy;
1367
+ particle.life -= particle.fadeRate;
1368
+ particle.opacity = Math.max(0, particle.life * 0.8);
1369
+
1370
+ // Deactivate when faded out
1371
+ if (particle.life <= 0) {
1372
+ particle.active = false;
1373
+ ctx.restore();
1374
+ return;
1375
+ }
1376
+
1377
+ // Glow
1378
+ ctx.globalAlpha = particle.opacity * 0.4;
1379
+ const glowGradient = ctx.createRadialGradient(x, y, 0, x, y, size * 3);
1380
+ glowGradient.addColorStop(0, particle.color);
1381
+ glowGradient.addColorStop(0.6, `${particle.color}40`);
1382
+ glowGradient.addColorStop(1, 'rgba(255, 215, 0, 0)');
1383
+ ctx.fillStyle = glowGradient;
1384
+ ctx.fillRect(x - size * 3, y - size * 3, size * 6, size * 6);
1385
+
1386
+ // Core
1387
+ ctx.globalAlpha = particle.opacity;
1388
+ ctx.fillStyle = particle.color;
1389
+ ctx.shadowColor = particle.color;
1390
+ ctx.shadowBlur = size * 1.5;
1391
+ ctx.beginPath();
1392
+ ctx.arc(x, y, size, 0, Math.PI * 2);
1393
+ ctx.fill();
1394
+
1395
+ ctx.shadowBlur = 0;
1396
+ ctx.restore();
1397
+ },
1398
+
1399
+ /**
1400
+ * Draw spark particle (small glowing bits)
1401
+ */
1402
+ drawSpark(ctx, particle) {
1403
+ const x = particle.x;
1404
+ const y = particle.y;
1405
+ const size = particle.size;
1406
+
1407
+ ctx.save();
1408
+
1409
+ // Update physics
1410
+ particle.x += particle.vx;
1411
+ particle.y += particle.vy;
1412
+ particle.life -= particle.fadeRate;
1413
+ particle.opacity = Math.max(0, particle.life);
1414
+
1415
+ // Deactivate when faded out
1416
+ if (particle.life <= 0) {
1417
+ particle.active = false;
1418
+ ctx.restore();
1419
+ return;
1420
+ }
1421
+
1422
+ // Glow
1423
+ ctx.globalAlpha = particle.opacity * 0.5;
1424
+ const glowGradient = ctx.createRadialGradient(x, y, 0, x, y, size * 3);
1425
+ glowGradient.addColorStop(0, particle.color);
1426
+ glowGradient.addColorStop(0.5, `${particle.color}60`);
1427
+ glowGradient.addColorStop(1, 'rgba(255, 215, 0, 0)');
1428
+ ctx.fillStyle = glowGradient;
1429
+ ctx.fillRect(x - size * 3, y - size * 3, size * 6, size * 6);
1430
+
1431
+ // Core (very small, bright gold)
1432
+ ctx.globalAlpha = particle.opacity;
1433
+ ctx.fillStyle = particle.color;
1434
+ ctx.shadowColor = particle.color;
1435
+ ctx.shadowBlur = size * 2;
1436
+ ctx.beginPath();
1437
+ ctx.arc(x, y, size * 0.8, 0, Math.PI * 2);
1438
+ ctx.fill();
1439
+
1440
+ ctx.shadowBlur = 0;
1441
+ ctx.restore();
1442
+ },
1443
+
1444
+ /**
1445
+ * Draw global theme effects (e.g., lightning)
1446
+ */
1447
+ drawGlobalEffects(ctx, currentTime, canvasWidth, canvasHeight) {
1448
+ if (this.lightningActive) {
1449
+ ctx.save();
1450
+ ctx.globalAlpha = 0.8 + Math.sin(this.lightningTimer * 0.01) * 0.2; // Pulsing effect during flash
1451
+ ctx.shadowColor = this.lightningColor;
1452
+ ctx.shadowBlur = 20;
1453
+
1454
+ // Draw main lightning bolt from top-center to random bottom position
1455
+ const startX = canvasWidth * (0.4 + Math.random() * 0.2); // Top middle
1456
+ const startY = 0;
1457
+ const endX = canvasWidth * Math.random();
1458
+ const endY = canvasHeight;
1459
+
1460
+ this.drawLightning(
1461
+ ctx,
1462
+ startX,
1463
+ startY,
1464
+ endX,
1465
+ endY,
1466
+ 5 + Math.floor(Math.random() * 3), // 5-7 segments
1467
+ 50 + Math.random() * 50, // 50-100 displacement
1468
+ 0.7, // Roughness
1469
+ 0.3, // Branch chance
1470
+ 3 + Math.random() * 2, // Line width
1471
+ this.lightningColor
1472
+ );
1473
+
1474
+ ctx.restore();
1475
+ }
1476
+ }
1477
+ };