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,1805 @@
1
+ /**
2
+ * Christmas Theme for Domma Celebrations
3
+ *
4
+ * Features:
5
+ * - 6-pointed crystalline snowflakes with rotation and depth layers
6
+ * - Decorated Christmas trees with twinkling lights, baubles, tinsel, and gold star
7
+ * - Christmas wreaths with bows and ornaments
8
+ * - Santa's sleigh with 5 reindeer (including Rudolph with glowing red nose)
9
+ * - Smooth sine wave flight motion for sleigh
10
+ * - Christmas Steam Train with animated smoke, carriages, decorations
11
+ * - Walking elves in green costumes
12
+ * - Robins with Santa hats doing swoop flights
13
+ * - Festive fireworks
14
+ * - Wind gusts and realistic physics simulation
15
+ * - Mobile-responsive particle reduction
16
+ */
17
+
18
+ import { createParticle } from './../core/particles.js';
19
+
20
+ export default {
21
+ name: 'christmas',
22
+ displayName: 'Christmas',
23
+ emoji: '🎄',
24
+
25
+ // Intensity configurations
26
+ intensityConfig: {
27
+ light: {
28
+ count: 50,
29
+ speedRange: [0.5, 1.5],
30
+ sizeRange: [1, 3],
31
+ trees: 3,
32
+ wreaths: 2,
33
+ northStars: 1,
34
+ snowmen: 2
35
+ },
36
+ medium: {
37
+ count: 150,
38
+ speedRange: [0.8, 2.5],
39
+ sizeRange: [1, 4],
40
+ trees: 6,
41
+ wreaths: 3,
42
+ northStars: 1,
43
+ snowmen: 3
44
+ },
45
+ heavy: {
46
+ count: 300,
47
+ speedRange: [1.0, 3.5],
48
+ sizeRange: [1, 5],
49
+ trees: 10,
50
+ wreaths: 4,
51
+ northStars: 1,
52
+ snowmen: 4
53
+ }
54
+ },
55
+
56
+ particles: ['snowflake'],
57
+ decorations: ['tree', 'wreath', 'sleigh', 'robin', 'train', 'elf', 'firework', 'north-star', 'snowman'],
58
+ colors: {
59
+ primary: '#ffffff', // Snow white
60
+ secondary: '#228B22', // Forest green
61
+ accent: '#c00', // Christmas red
62
+ gold: '#FFD700' // Gold star/trim
63
+ },
64
+
65
+ /**
66
+ * Create a snowflake particle
67
+ */
68
+ createSnowflakeParticle(canvasWidth, canvasHeight, config) {
69
+ const particle = createParticle(config, canvasWidth, canvasHeight);
70
+ particle.type = 'snowflake';
71
+ return particle;
72
+ },
73
+
74
+ /**
75
+ * Create falling particle (snowflakes)
76
+ */
77
+ createFallingParticle(canvasWidth, canvasHeight, config) {
78
+ return this.createSnowflakeParticle(canvasWidth, canvasHeight, config);
79
+ },
80
+
81
+ /**
82
+ * Create Christmas tree decoration
83
+ */
84
+ createTree(canvasWidth, canvasHeight, options = {}) {
85
+ return {
86
+ type: 'tree',
87
+ x: options.x !== undefined ? options.x : Math.random() * canvasWidth,
88
+ y: options.y !== undefined ? options.y : Math.random() * canvasHeight,
89
+ vx: 0,
90
+ vy: 0,
91
+ size: 20 + Math.random() * 15,
92
+ opacity: 0.6 + Math.random() * 0.3,
93
+ rotation: 0,
94
+ rotationSpeed: 0,
95
+ active: true,
96
+ static: true
97
+ };
98
+ },
99
+
100
+ /**
101
+ * Create Christmas wreath decoration
102
+ */
103
+ createWreath(canvasWidth, canvasHeight, options = {}) {
104
+ // Generate the wreath's shape data once
105
+ const wreathShape = [];
106
+ const segments = 20;
107
+ for (let i = 0; i < segments; i++) {
108
+ const angle = (i / segments) * Math.PI * 2;
109
+ wreathShape.push({
110
+ angle: angle,
111
+ radius: 0.9 + Math.random() * 0.2,
112
+ thickness: 0.2 + Math.random() * 0.15,
113
+ color: i % 2 === 0 ? '#1a6b1a' : '#228B22'
114
+ });
115
+ }
116
+
117
+ return {
118
+ type: 'wreath',
119
+ x: options.x !== undefined ? options.x : Math.random() * canvasWidth,
120
+ y: options.y !== undefined ? options.y : Math.random() * canvasHeight,
121
+ vx: 0,
122
+ vy: 0,
123
+ size: 15 + Math.random() * 10,
124
+ opacity: 0.7 + Math.random() * 0.2,
125
+ rotation: 0,
126
+ rotationSpeed: 0,
127
+ active: true,
128
+ static: true,
129
+ shape: wreathShape
130
+ };
131
+ },
132
+
133
+ /**
134
+ * Create North Star (Star of Bethlehem) decoration
135
+ */
136
+ createNorthStar(canvasWidth, canvasHeight, options = {}) {
137
+ return {
138
+ type: 'north-star',
139
+ x: options.x !== undefined ? options.x : canvasWidth / 2, // Center by default
140
+ y: options.y !== undefined ? options.y : 80, // Top of screen by default
141
+ vx: 0,
142
+ vy: 0,
143
+ size: 25, // Fixed size for prominence
144
+ opacity: 1.0,
145
+ twinklePhase: Math.random() * Math.PI * 2,
146
+ twinkleSpeed: 0.003, // Extremely slow, barely noticeable twinkle
147
+ active: true,
148
+ static: true
149
+ };
150
+ },
151
+
152
+ /**
153
+ * Create snowman decoration
154
+ */
155
+ createSnowman(canvasWidth, canvasHeight, options = {}) {
156
+ return {
157
+ type: 'snowman',
158
+ x: options.x !== undefined ? options.x : Math.random() * canvasWidth,
159
+ y: options.y !== undefined ? options.y : canvasHeight - 50,
160
+ vx: 0,
161
+ vy: 0,
162
+ size: 15 + Math.random() * 10,
163
+ opacity: 1.0,
164
+ time: Math.random() * 1000,
165
+ wavePhase: Math.random() * Math.PI * 2,
166
+ active: true,
167
+ static: true
168
+ };
169
+ },
170
+
171
+ /**
172
+ * Create initial static decorations (trees, wreaths, and North Stars)
173
+ */
174
+ createInitialDecorations(canvasWidth, canvasHeight, config) {
175
+ const decorations = [];
176
+
177
+ // Create trees
178
+ const treeCount = config.trees || 6;
179
+ for (let i = 0; i < treeCount; i++) {
180
+ decorations.push(this.createTree(canvasWidth, canvasHeight, {
181
+ x: (canvasWidth / (treeCount + 1)) * (i + 1),
182
+ y: canvasHeight - 60 - Math.random() * 20
183
+ }));
184
+ }
185
+
186
+ // Create wreaths
187
+ const wreathCount = config.wreaths || 3;
188
+ for (let i = 0; i < wreathCount; i++) {
189
+ decorations.push(this.createWreath(canvasWidth, canvasHeight, {
190
+ x: (canvasWidth / (wreathCount + 1)) * (i + 1),
191
+ y: 50 + Math.random() * 100
192
+ }));
193
+ }
194
+
195
+ // Create North Star (Star of Bethlehem) - single centered star
196
+ if (config.northStars) {
197
+ decorations.push(this.createNorthStar(canvasWidth, canvasHeight, {
198
+ x: canvasWidth / 2,
199
+ y: 60
200
+ }));
201
+ }
202
+
203
+ // Create snowmen
204
+ const snowmanCount = config.snowmen || 3;
205
+ for (let i = 0; i < snowmanCount; i++) {
206
+ decorations.push(this.createSnowman(canvasWidth, canvasHeight, {
207
+ x: (canvasWidth / (snowmanCount + 1)) * (i + 1),
208
+ y: canvasHeight - 50 - Math.random() * 10
209
+ }));
210
+ }
211
+
212
+ return decorations;
213
+ },
214
+
215
+ /**
216
+ * Spawn special particles with configurable probability
217
+ */
218
+ spawnSpecialParticle(specialParticles, canvasWidth, canvasHeight) {
219
+ const choice = Math.random();
220
+
221
+ // Santa's sleigh (0.05% chance, max 1)
222
+ if (choice < 0.0005) {
223
+ if (specialParticles.some(p => p.type === 'sleigh')) {
224
+ return null;
225
+ }
226
+ const fromLeft = Math.random() < 0.5;
227
+ const startX = fromLeft ? -100 : canvasWidth + 100;
228
+ const baseY = 100 + Math.random() * (canvasHeight * 0.3);
229
+ return {
230
+ type: 'sleigh',
231
+ x: startX,
232
+ startX: startX, // Store starting X for arc calculation
233
+ y: baseY,
234
+ baseY: baseY,
235
+ targetX: fromLeft ? canvasWidth + 100 : -100, // Store target X
236
+ canvasWidth: canvasWidth, // Store canvas width for arc calculation
237
+ vx: fromLeft ? 3 + Math.random() * 2 : -(3 + Math.random() * 2),
238
+ vy: 0,
239
+ arcHeight: 150 + Math.random() * 100, // Height of the arc
240
+ time: 0,
241
+ size: 15 + Math.random() * 10,
242
+ opacity: 0.9,
243
+ rotation: 0,
244
+ active: true,
245
+ static: false
246
+ };
247
+ } else if (choice < 0.0013) { // Elf (0.08% chance) - Sequential range after sleigh
248
+ const fromLeft = Math.random() < 0.5;
249
+ return {
250
+ type: 'elf',
251
+ x: fromLeft ? -50 : canvasWidth + 50,
252
+ y: canvasHeight - 30,
253
+ baseY: canvasHeight - 30,
254
+ vx: fromLeft ? 1.5 + Math.random() * 1 : -(1.5 + Math.random() * 1),
255
+ waveAmplitude: 3,
256
+ waveFrequency: 0.05,
257
+ waveOffset: Math.random() * Math.PI * 2,
258
+ time: 0,
259
+ size: 10 + Math.random() * 5,
260
+ opacity: 0.95,
261
+ rotation: 0,
262
+ active: true,
263
+ static: false
264
+ };
265
+ } else if (choice < 0.005) { // Christmas train (0.37% chance - much more frequent)
266
+ if (specialParticles.some(p => p.type === 'train')) {
267
+ return null;
268
+ }
269
+ const fromLeft = Math.random() < 0.5;
270
+ const startX = fromLeft ? -500 : canvasWidth + 500;
271
+ const trainSize = 21 + Math.random() * 9; // 21-30 (40% reduction from 35-50)
272
+
273
+ // Calculate Y position so wheels sit near the bottom
274
+ // size = trainSize * 1.8
275
+ // baseUnit = size / 20
276
+ // wheelRadius = baseUnit * 8 = (trainSize * 1.8 / 20) * 8 = trainSize * 0.72
277
+ const wheelRadius = trainSize * 0.72;
278
+ const trainY = canvasHeight - wheelRadius - 10; // Position wheels 10px from bottom
279
+
280
+ return {
281
+ type: 'train',
282
+ x: startX,
283
+ y: trainY,
284
+ baseY: trainY,
285
+ vx: fromLeft ? 4 + Math.random() * 2 : -(4 + Math.random() * 2),
286
+ vy: 0,
287
+ size: trainSize,
288
+ opacity: 1,
289
+ time: 0,
290
+ smoke: [],
291
+ active: true,
292
+ static: false,
293
+ carriages: 2 + Math.floor(Math.random() * 2)
294
+ };
295
+ } else if (choice < 0.008) { // Fireworks (0.3% chance) - Sequential range after train
296
+ return {
297
+ type: 'firework',
298
+ x: Math.random() * canvasWidth,
299
+ y: canvasHeight,
300
+ vx: (Math.random() - 0.5) * 4,
301
+ vy: -10 - Math.random() * 5,
302
+ size: 2 + Math.random() * 2,
303
+ opacity: 1,
304
+ active: true,
305
+ static: false,
306
+ time: 0,
307
+ exploded: false,
308
+ explosionTime: 30 + Math.random() * 30
309
+ };
310
+ } else if (choice < 0.012) { // Robin (0.4% chance, max 1) - Sequential range after fireworks
311
+ if (specialParticles.some(p => p.type === 'robin')) {
312
+ return null;
313
+ }
314
+ const fromLeft = Math.random() < 0.5;
315
+ const startY = Math.random() * (canvasHeight * 0.2);
316
+ const startX = fromLeft ? -50 : canvasWidth + 50;
317
+ const robinSize = 10 + Math.random() * 5;
318
+ // Ensure targetY is within reasonable visible bounds
319
+ const targetY = Math.max(robinSize * 3, Math.min(canvasHeight * 0.6 - robinSize * 2, canvasHeight * 0.2 + Math.random() * (canvasHeight * 0.4)));
320
+ const targetX = Math.random() * (canvasWidth * 0.6) + (canvasWidth * 0.2); // Also ensure targetX is not too far off
321
+
322
+ return {
323
+ type: 'robin',
324
+ state: 'flying_in',
325
+ x: startX,
326
+ y: startY,
327
+ startX: startX,
328
+ startY: startY,
329
+ targetX: targetX,
330
+ targetY: targetY,
331
+ vx: fromLeft ? 0.5 + Math.random() * 0.5 : -(0.5 + Math.random() * 0.5),
332
+ vy: 0,
333
+ size: robinSize,
334
+ opacity: 0.95,
335
+ active: true,
336
+ static: false,
337
+ sitTime: 3000 + Math.random() * 2000,
338
+ sitStartTime: 0,
339
+ flightProgress: 0,
340
+ time: 0,
341
+ waveOffset: Math.random() * Math.PI * 2
342
+ };
343
+ }
344
+
345
+ return null;
346
+ },
347
+
348
+ /**
349
+ * Update special particles (sleigh, robin, train, firework)
350
+ */
351
+ updateSpecialParticles(specialParticles, deltaTime, canvasWidth = 1024, canvasHeight = 768) { // Added default canvas dimensions
352
+ specialParticles.forEach(particle => {
353
+ // Increment time for animated particles
354
+ if (particle.time !== undefined) {
355
+ particle.time += deltaTime;
356
+ }
357
+
358
+ switch (particle.type) {
359
+ case 'sleigh':
360
+ // Sleigh movement (arc motion is handled in drawSleigh based on particle.x)
361
+ // Just need to ensure it deactivates when off-screen
362
+ if ((particle.vx > 0 && particle.x > particle.targetX) || (particle.vx < 0 && particle.x < particle.targetX)) {
363
+ particle.active = false;
364
+ }
365
+ break;
366
+
367
+ case 'robin':
368
+ // Robin flight pattern: fly in -> sit -> flit off
369
+ switch (particle.state) {
370
+ case 'flying_in':
371
+ const dx = particle.targetX - particle.x;
372
+ const dy = particle.targetY - particle.y;
373
+ const distance = Math.sqrt(dx * dx + dy * dy);
374
+
375
+ if (distance < 20) {
376
+ particle.state = 'sitting';
377
+ particle.vx = 0;
378
+ particle.vy = 0;
379
+ particle.sitStartTime = particle.time;
380
+ } else {
381
+ // Calculate desired velocity components based on direction to target
382
+ const targetDirectionX = dx / distance;
383
+ const targetDirectionY = dy / distance;
384
+
385
+ // Max speed for flying in
386
+ const maxFlightSpeed = 3;
387
+
388
+ // Base horizontal speed towards target
389
+ particle.vx = targetDirectionX * maxFlightSpeed;
390
+
391
+ // Add undulating wave motion (bird-like bobbing flight)
392
+ const waveAmplitude = 30; // Vertical wave height
393
+ const waveFrequency = 0.008; // Wave frequency
394
+ const waveMotion = Math.sin(particle.time * waveFrequency + particle.waveOffset) * waveAmplitude;
395
+
396
+ // Calculate vertical velocity: base direction + wave derivative
397
+ const waveDerivative = Math.cos(particle.time * waveFrequency + particle.waveOffset) * waveAmplitude * waveFrequency;
398
+ particle.vy = targetDirectionY * maxFlightSpeed * 0.3 + waveDerivative;
399
+ }
400
+ break;
401
+
402
+ case 'sitting':
403
+ particle.vx = 0;
404
+ particle.vy = 0;
405
+ if (particle.time - particle.sitStartTime > particle.sitTime) {
406
+ particle.state = 'flying_away';
407
+ // Set base horizontal velocity (away from center)
408
+ const baseVx = particle.x < canvasWidth / 2 ? -4 : 4;
409
+ particle.vx = baseVx;
410
+ particle.flyAwayStartTime = particle.time;
411
+ }
412
+ break;
413
+
414
+ case 'flying_away':
415
+ // Maintain horizontal velocity
416
+ const flyDirection = particle.vx > 0 ? 1 : -1;
417
+ particle.vx = flyDirection * 4;
418
+
419
+ // Add undulating wave motion for natural bird flight
420
+ const waveAmplitude = 25;
421
+ const waveFrequency = 0.01;
422
+ const flyTime = particle.time - particle.flyAwayStartTime;
423
+
424
+ // Upward bias + wave motion
425
+ const waveMotion = Math.sin(flyTime * waveFrequency + particle.waveOffset) * waveAmplitude;
426
+ const waveDerivative = Math.cos(flyTime * waveFrequency + particle.waveOffset) * waveAmplitude * waveFrequency;
427
+ particle.vy = -1.5 + waveDerivative; // Gentle upward + wave
428
+
429
+ if (particle.x < -50 || particle.x > canvasWidth + 50 || particle.y < -50) {
430
+ particle.active = false;
431
+ }
432
+ break;
433
+ }
434
+ break;
435
+
436
+ case 'train':
437
+ // Initialize smoke array if needed
438
+ if (!particle.smoke) {
439
+ particle.smoke = [];
440
+ }
441
+
442
+ // Train deactivates when off-screen
443
+ if ((particle.vx > 0 && particle.x > canvasWidth + 500) || (particle.vx < 0 && particle.x < -500)) {
444
+ particle.active = false;
445
+ }
446
+
447
+ // Emit smoke puffs - every 150ms
448
+ if (!particle.lastSmokeTime) {
449
+ particle.lastSmokeTime = 0;
450
+ }
451
+
452
+ if (particle.time - particle.lastSmokeTime > 150) {
453
+ particle.lastSmokeTime = particle.time;
454
+
455
+ // Calculate smoke position from chimney - must match drawing code exactly
456
+ const size = particle.size * 1.8;
457
+ const baseUnit = size / 20;
458
+ const dir = particle.vx > 0 ? 1 : -1;
459
+
460
+ // From drawing code:
461
+ const wheelRadius = baseUnit * 8;
462
+ const chassisHeight = baseUnit * 7;
463
+ const boilerRadius = baseUnit * 10;
464
+ const engineChassisBottomY = -wheelRadius - baseUnit; // -9 * baseUnit
465
+ const boilerTopY = engineChassisBottomY - chassisHeight - boilerRadius * 2; // -36 * baseUnit
466
+ const chimneyTopY = boilerTopY - baseUnit * 8; // -44 * baseUnit
467
+
468
+ const engineLength = baseUnit * 70;
469
+ const cabWidth = baseUnit * 25;
470
+ const boilerWidth = engineLength - cabWidth; // 45 * baseUnit
471
+ const chimneyX = boilerWidth * 0.7; // 31.5 * baseUnit
472
+
473
+ // Convert from local drawing coords to world coords
474
+ const smokeX = particle.x + (chimneyX * dir);
475
+ const smokeY = particle.y + chimneyTopY; // chimneyTopY is negative, so this goes UP
476
+
477
+ const smokeParticle = {
478
+ x: smokeX,
479
+ y: smokeY,
480
+ vx: (Math.random() - 0.5) * 0.8,
481
+ vy: -0.8 - Math.random() * 0.4,
482
+ size: 8 + Math.random() * 6,
483
+ opacity: 0.7 + Math.random() * 0.2,
484
+ fadeRate: 0.012 + Math.random() * 0.008
485
+ };
486
+
487
+ particle.smoke.push(smokeParticle);
488
+ }
489
+
490
+ // Update smoke puffs
491
+ particle.smoke = particle.smoke.filter(smoke => {
492
+ smoke.x += smoke.vx;
493
+ smoke.y += smoke.vy;
494
+ smoke.vy *= 0.99; // Slow vertical lift
495
+ smoke.size *= 1.02; // Expand as it rises
496
+ smoke.opacity -= smoke.fadeRate;
497
+ return smoke.opacity > 0;
498
+ });
499
+ break;
500
+
501
+ case 'firework':
502
+ // Check firework explosion
503
+ if (!particle.exploded) {
504
+ // Explode when reached target height OR after flight time
505
+ const reachedTarget = particle.y <= particle.targetY;
506
+ const timeExpired = particle.time >= particle.explosionTime;
507
+
508
+ if (reachedTarget || timeExpired) {
509
+ particle.exploded = true;
510
+ this.explodeFirework(particle, specialParticles);
511
+ particle.active = false; // Remove the firework itself
512
+ }
513
+ }
514
+ break;
515
+ }
516
+ });
517
+ },
518
+
519
+ /**
520
+ * Draw 6-pointed crystalline snowflake
521
+ */
522
+ drawSnowflake(ctx, particle) {
523
+ ctx.save();
524
+ ctx.translate(particle.x, particle.y);
525
+ ctx.rotate(particle.rotation);
526
+ ctx.globalAlpha = particle.opacity;
527
+ ctx.fillStyle = '#ffffff';
528
+ ctx.strokeStyle = '#ffffff';
529
+ ctx.lineWidth = Math.max(particle.size * 0.15, 0.5);
530
+
531
+ const branches = 6;
532
+ const radius = particle.size;
533
+
534
+ for (let i = 0; i < branches; i++) {
535
+ const angle = (Math.PI * 2 * i) / branches;
536
+
537
+ // Main branch
538
+ ctx.beginPath();
539
+ ctx.moveTo(0, 0);
540
+ ctx.lineTo(
541
+ Math.cos(angle) * radius,
542
+ Math.sin(angle) * radius
543
+ );
544
+ ctx.stroke();
545
+
546
+ // Side branches
547
+ const sideLength = radius * 0.4;
548
+ const sideAngle = Math.PI / 6;
549
+ const midX = Math.cos(angle) * (radius * 0.6);
550
+ const midY = Math.sin(angle) * (radius * 0.6);
551
+
552
+ // Left side branch
553
+ ctx.beginPath();
554
+ ctx.moveTo(midX, midY);
555
+ ctx.lineTo(
556
+ midX + Math.cos(angle - sideAngle) * sideLength,
557
+ midY + Math.sin(angle - sideAngle) * sideLength
558
+ );
559
+ ctx.stroke();
560
+
561
+ // Right side branch
562
+ ctx.beginPath();
563
+ ctx.moveTo(midX, midY);
564
+ ctx.lineTo(
565
+ midX + Math.cos(angle + sideAngle) * sideLength,
566
+ midY + Math.sin(angle + sideAngle) * sideLength
567
+ );
568
+ ctx.stroke();
569
+ }
570
+
571
+ ctx.restore();
572
+ },
573
+
574
+ /**
575
+ * Draw Christmas tree with lights, baubles, tinsel, and star
576
+ */
577
+ drawTree(ctx, particle, twinkleTime) {
578
+ const x = particle.x;
579
+ const y = particle.y;
580
+ const size = particle.size;
581
+
582
+ ctx.save();
583
+ ctx.translate(x, y);
584
+ ctx.rotate(particle.rotation);
585
+
586
+ // Trunk
587
+ ctx.fillStyle = '#654321';
588
+ ctx.beginPath();
589
+ ctx.moveTo(-size * 0.2, size * 0.8);
590
+ ctx.lineTo(size * 0.2, size * 0.8);
591
+ ctx.lineTo(size * 0.15, size * 1.3);
592
+ ctx.lineTo(-size * 0.15, size * 1.3);
593
+ ctx.closePath();
594
+ ctx.fill();
595
+
596
+ // Trunk texture
597
+ ctx.strokeStyle = '#4a2f1a';
598
+ ctx.lineWidth = 1;
599
+ for (let i = 0; i < 3; i++) {
600
+ ctx.beginPath();
601
+ ctx.moveTo(-size * 0.15, size * 0.9 + i * size * 0.12);
602
+ ctx.lineTo(size * 0.15, size * 0.9 + i * size * 0.12);
603
+ ctx.stroke();
604
+ }
605
+
606
+ // Tree layers (3 triangular layers)
607
+ ctx.fillStyle = '#228B22';
608
+ for (let i = 0; i < 3; i++) {
609
+ const layerY = i * size * 0.4;
610
+ const layerSize = size * (1.2 - i * 0.2);
611
+ ctx.beginPath();
612
+ ctx.moveTo(0, -layerY);
613
+ ctx.lineTo(-layerSize, size * 0.3 - layerY);
614
+ ctx.lineTo(layerSize, size * 0.3 - layerY);
615
+ ctx.closePath();
616
+ ctx.fill();
617
+ }
618
+
619
+ // Tinsel
620
+ ctx.strokeStyle = '#C0C0C0';
621
+ ctx.lineWidth = 1.5;
622
+ for (let layer = 0; layer < 3; layer++) {
623
+ const layerY = layer * size * 0.4;
624
+ const layerSize = size * (1.2 - layer * 0.2);
625
+ ctx.beginPath();
626
+ for (let i = 0; i <= 6; i++) {
627
+ const xPos = -layerSize + (i / 6) * layerSize * 2;
628
+ const yPos = size * 0.15 - layerY + (i % 2 === 0 ? -size * 0.1 : 0);
629
+ if (i === 0) ctx.moveTo(xPos, yPos);
630
+ else ctx.lineTo(xPos, yPos);
631
+ }
632
+ ctx.stroke();
633
+ }
634
+
635
+ // Baubles
636
+ const baubleColors = ['#ff0000', '#0000ff', '#ffd700', '#ff69b4', '#00ff00'];
637
+ for (let i = 0; i < 8; i++) {
638
+ const layer = Math.floor(i / 3);
639
+ const layerY = layer * size * 0.4;
640
+ const layerSize = size * (1.2 - layer * 0.2) * 0.7;
641
+ const angle = (i % 3) * (Math.PI * 2 / 3) + layer * 0.5;
642
+ const baubleX = Math.cos(angle) * layerSize;
643
+ const baubleY = size * 0.1 - layerY;
644
+
645
+ ctx.fillStyle = baubleColors[i % baubleColors.length];
646
+ ctx.beginPath();
647
+ ctx.arc(baubleX, baubleY, size * 0.12, 0, Math.PI * 2);
648
+ ctx.fill();
649
+
650
+ // Highlight
651
+ ctx.fillStyle = 'rgba(255,255,255,0.5)';
652
+ ctx.beginPath();
653
+ ctx.arc(baubleX - size * 0.04, baubleY - size * 0.04, size * 0.04, 0, Math.PI * 2);
654
+ ctx.fill();
655
+ }
656
+
657
+ // Twinkling lights
658
+ const lightColors = ['#ffff00', '#ff0000', '#00ff00', '#0000ff', '#ffffff'];
659
+ for (let i = 0; i < 12; i++) {
660
+ const layer = Math.floor(i / 4);
661
+ const layerY = layer * size * 0.4;
662
+ const layerSize = size * (1.2 - layer * 0.2) * 0.85;
663
+ const angle = (i % 4) * (Math.PI * 2 / 4) + layer * 0.3;
664
+ const lightX = Math.cos(angle) * layerSize;
665
+ const lightY = size * 0.2 - layerY;
666
+
667
+ // Twinkle effect
668
+ const twinkleIntensity = (Math.sin((twinkleTime * 0.003) + (i * 0.5)) + 1) * 0.5;
669
+ const glowOpacity = 0.3 + (twinkleIntensity * 0.7);
670
+
671
+ // Glow
672
+ const gradient = ctx.createRadialGradient(lightX, lightY, 0, lightX, lightY, size * 0.15);
673
+ const color = lightColors[i % lightColors.length];
674
+ gradient.addColorStop(0, color);
675
+ gradient.addColorStop(1, 'rgba(255,255,255,0)');
676
+ ctx.globalAlpha = glowOpacity;
677
+ ctx.fillStyle = gradient;
678
+ ctx.beginPath();
679
+ ctx.arc(lightX, lightY, size * 0.15, 0, Math.PI * 2);
680
+ ctx.fill();
681
+
682
+ // Light bulb
683
+ ctx.globalAlpha = 0.5 + (twinkleIntensity * 0.5);
684
+ ctx.fillStyle = color;
685
+ ctx.beginPath();
686
+ ctx.arc(lightX, lightY, size * 0.08, 0, Math.PI * 2);
687
+ ctx.fill();
688
+ ctx.globalAlpha = 1;
689
+ }
690
+
691
+ // Gold star with glow
692
+ const starSize = size * 0.35;
693
+ const starY = -size * 1.4;
694
+
695
+ // Star glow
696
+ const starGradient = ctx.createRadialGradient(0, starY, 0, 0, starY, starSize * 2);
697
+ starGradient.addColorStop(0, 'rgba(255,223,0,1)');
698
+ starGradient.addColorStop(0.3, 'rgba(255,215,0,0.7)');
699
+ starGradient.addColorStop(0.6, 'rgba(255,215,0,0.3)');
700
+ starGradient.addColorStop(1, 'rgba(255,215,0,0)');
701
+ ctx.fillStyle = starGradient;
702
+ ctx.beginPath();
703
+ ctx.arc(0, starY, starSize * 2, 0, Math.PI * 2);
704
+ ctx.fill();
705
+
706
+ // Star
707
+ ctx.fillStyle = '#FFD700';
708
+ ctx.strokeStyle = '#FFA500';
709
+ ctx.lineWidth = 2;
710
+ ctx.save();
711
+ ctx.translate(0, starY);
712
+ ctx.beginPath();
713
+ for (let i = 0; i < 10; i++) {
714
+ const angle = (Math.PI * 2 * i) / 10 - Math.PI / 2;
715
+ const radius = i % 2 === 0 ? starSize : starSize * 0.4;
716
+ const pointX = Math.cos(angle) * radius;
717
+ const pointY = Math.sin(angle) * radius;
718
+ if (i === 0) ctx.moveTo(pointX, pointY);
719
+ else ctx.lineTo(pointX, pointY);
720
+ }
721
+ ctx.closePath();
722
+ ctx.fill();
723
+ ctx.stroke();
724
+ ctx.restore();
725
+
726
+ ctx.restore();
727
+ },
728
+
729
+ /**
730
+ * Draw Christmas wreath with bow and lights
731
+ */
732
+ drawWreath(ctx, particle, twinkleTime) {
733
+ const x = particle.x;
734
+ const y = particle.y;
735
+ const size = particle.size;
736
+
737
+ ctx.save();
738
+ ctx.translate(x, y);
739
+ ctx.rotate(particle.rotation);
740
+
741
+ // Wreath body (pre-generated irregular shape)
742
+ particle.shape.forEach(segment => {
743
+ ctx.lineWidth = size * segment.thickness;
744
+ ctx.strokeStyle = segment.color;
745
+ ctx.beginPath();
746
+ const startAngle = segment.angle - (Math.PI / particle.shape.length);
747
+ const endAngle = segment.angle + (Math.PI / particle.shape.length);
748
+ ctx.arc(0, 0, size * segment.radius, startAngle, endAngle);
749
+ ctx.stroke();
750
+ });
751
+
752
+ // Twinkling lights on wreath
753
+ const lightColors = ['#ff0000', '#ffff00', '#0000ff'];
754
+ for (let i = 0; i < 4; i++) {
755
+ const angle = (i / 4) * Math.PI * 2 + particle.rotation;
756
+ const lightX = Math.cos(angle) * size;
757
+ const lightY = Math.sin(angle) * size;
758
+ const twinkleIntensity = (Math.sin((twinkleTime * 0.002) + (i * 0.7)) + 1) / 2;
759
+
760
+ if (twinkleIntensity > 0.5) {
761
+ const glowOpacity = (twinkleIntensity - 0.5) * 2;
762
+ const color = lightColors[i % lightColors.length];
763
+ const gradient = ctx.createRadialGradient(lightX, lightY, 0, lightX, lightY, size * 0.15);
764
+ gradient.addColorStop(0, color);
765
+ gradient.addColorStop(1, 'rgba(255,255,255,0)');
766
+ ctx.globalAlpha = glowOpacity;
767
+ ctx.fillStyle = gradient;
768
+ ctx.beginPath();
769
+ ctx.arc(lightX, lightY, size * 0.15, 0, Math.PI * 2);
770
+ ctx.fill();
771
+ ctx.globalAlpha = 1;
772
+ }
773
+ }
774
+
775
+ // Red bow at top
776
+ ctx.fillStyle = '#c00';
777
+ const bowY = -size;
778
+
779
+ // Left loop
780
+ ctx.beginPath();
781
+ ctx.ellipse(-size * 0.3, bowY, size * 0.3, size * 0.4, -0.3, 0, Math.PI * 2);
782
+ ctx.fill();
783
+
784
+ // Right loop
785
+ ctx.beginPath();
786
+ ctx.ellipse(size * 0.3, bowY, size * 0.3, size * 0.4, 0.3, 0, Math.PI * 2);
787
+ ctx.fill();
788
+
789
+ // Bow center knot
790
+ ctx.beginPath();
791
+ ctx.arc(0, bowY, size * 0.2, 0, Math.PI * 2);
792
+ ctx.fill();
793
+
794
+ ctx.restore();
795
+ },
796
+
797
+ /**
798
+ * Draw North Star (Star of Bethlehem) - Silver 4-pointed star, non-spinning
799
+ */
800
+ drawNorthStar(ctx, particle, time) {
801
+ const x = particle.x;
802
+ const y = particle.y;
803
+ const size = particle.size;
804
+
805
+ // Calculate twinkle intensity (gentle pulsing)
806
+ const twinkleIntensity = 0.8 + Math.sin(time * particle.twinkleSpeed + particle.twinklePhase) * 0.2;
807
+
808
+ ctx.save();
809
+ ctx.translate(x, y);
810
+
811
+ // Outer glow halo (pulsing silver aura)
812
+ const glowSize = size * 4 * twinkleIntensity;
813
+ const haloGradient = ctx.createRadialGradient(0, 0, 0, 0, 0, glowSize);
814
+ haloGradient.addColorStop(0, 'rgba(220, 230, 240, 0.4)');
815
+ haloGradient.addColorStop(0.4, 'rgba(200, 210, 220, 0.2)');
816
+ haloGradient.addColorStop(1, 'rgba(180, 190, 200, 0)');
817
+ ctx.fillStyle = haloGradient;
818
+ ctx.beginPath();
819
+ ctx.arc(0, 0, glowSize, 0, Math.PI * 2);
820
+ ctx.fill();
821
+
822
+ // Draw 4-pointed star with pointed tips (taller and thinner)
823
+ const horizontalLength = size * 1.0; // Horizontal arms
824
+ const verticalLength = size * 1.5; // Vertical arms (much taller)
825
+ const armWidth = size * 0.12; // Width at base of each arm (thinner)
826
+
827
+ // Silver gradient for star body
828
+ const starGradient = ctx.createRadialGradient(0, 0, 0, 0, 0, size);
829
+ starGradient.addColorStop(0, '#ffffff'); // Bright white center
830
+ starGradient.addColorStop(0.3, '#f0f0f0'); // Light silver
831
+ starGradient.addColorStop(0.6, '#c0c0c0'); // Silver
832
+ starGradient.addColorStop(1, '#a0a0a0'); // Darker silver edge
833
+
834
+ ctx.shadowColor = 'rgba(220, 230, 240, 0.9)';
835
+ ctx.shadowBlur = size * 0.8 * twinkleIntensity;
836
+ ctx.fillStyle = starGradient;
837
+
838
+ // Draw 4-pointed star with pointed tips
839
+ ctx.beginPath();
840
+ // Top point
841
+ ctx.moveTo(0, -verticalLength);
842
+ ctx.lineTo(armWidth, -armWidth);
843
+ // Right point
844
+ ctx.lineTo(horizontalLength, 0);
845
+ ctx.lineTo(armWidth, armWidth);
846
+ // Bottom point
847
+ ctx.lineTo(0, verticalLength);
848
+ ctx.lineTo(-armWidth, armWidth);
849
+ // Left point
850
+ ctx.lineTo(-horizontalLength, 0);
851
+ ctx.lineTo(-armWidth, -armWidth);
852
+ ctx.closePath();
853
+ ctx.fill();
854
+
855
+ // Inner bright white core (circular center)
856
+ ctx.shadowBlur = size * 1.2 * twinkleIntensity;
857
+ ctx.shadowColor = 'rgba(255, 255, 255, 1)';
858
+ const coreGradient = ctx.createRadialGradient(0, 0, 0, 0, 0, size * 0.3);
859
+ coreGradient.addColorStop(0, '#ffffff');
860
+ coreGradient.addColorStop(0.6, '#f5f5f5');
861
+ coreGradient.addColorStop(1, 'rgba(200, 200, 200, 0.8)');
862
+ ctx.fillStyle = coreGradient;
863
+ ctx.beginPath();
864
+ ctx.arc(0, 0, size * 0.3, 0, Math.PI * 2);
865
+ ctx.fill();
866
+
867
+ ctx.shadowBlur = 0;
868
+ ctx.restore();
869
+ },
870
+
871
+ /**
872
+ * Draw Santa's sleigh with 5 reindeer (including Rudolph)
873
+ */
874
+ drawSleigh(ctx, particle) {
875
+ const x = particle.x;
876
+
877
+ // Calculate progress across screen (0 to 1)
878
+ const totalDistance = Math.abs(particle.targetX - particle.startX);
879
+ const traveled = Math.abs(particle.x - particle.startX);
880
+ const progress = Math.min(1, traveled / totalDistance);
881
+
882
+ // Half-sine arc path: starts at baseY, arcs upward to peak at middle, returns to baseY
883
+ // Math.sin(progress * Math.PI) gives: 0 → 1 (peak) → 0
884
+ const arcOffset = Math.sin(progress * Math.PI) * particle.arcHeight;
885
+ const y = particle.baseY - arcOffset; // Subtract to arc upward
886
+
887
+ const size = particle.size * 1.5;
888
+ const dir = particle.vx > 0 ? 1 : -1;
889
+ // Half-sine wave for galloping/running motion (0 to 1 range)
890
+ const runCycle = Math.abs(Math.sin(particle.time * 0.007));
891
+
892
+ // Safety check for NaN values
893
+ if (!isFinite(x) || !isFinite(y) || !isFinite(size)) {
894
+ console.warn('[Christmas] Invalid sleigh values:', {x, y, size});
895
+ return;
896
+ }
897
+
898
+ // Reindeer positions (5 reindeer)
899
+ const reindeerPositions = [
900
+ {x: 4.8, y: 0}, // Rudolph (lead)
901
+ {x: 4.0, y: -0.3}, // Second row left
902
+ {x: 4.0, y: 0.3}, // Second row right
903
+ {x: 3.2, y: -0.15}, // Back row left
904
+ {x: 3.2, y: 0.15} // Back row right
905
+ ];
906
+
907
+ reindeerPositions.forEach((pos, i) => {
908
+ const reindeerX = x + dir * (size * pos.x);
909
+ const offsetY = pos.y * size;
910
+
911
+ // Reindeer body
912
+ ctx.fillStyle = '#9c6e49';
913
+ ctx.strokeStyle = '#7b563a';
914
+ ctx.lineWidth = 1;
915
+ ctx.beginPath();
916
+ ctx.moveTo(reindeerX - dir * size * 0.4, y + offsetY);
917
+ ctx.quadraticCurveTo(reindeerX, y + offsetY - size * 0.4, reindeerX + dir * size * 0.4, y + offsetY);
918
+ ctx.quadraticCurveTo(reindeerX, y + offsetY + size * 0.4, reindeerX - dir * size * 0.4, y + offsetY);
919
+ ctx.fill();
920
+ ctx.stroke();
921
+
922
+ // Head
923
+ const headX = reindeerX + dir * size * 0.5;
924
+ const headY = y + offsetY - size * 0.3;
925
+ ctx.beginPath();
926
+ ctx.ellipse(headX, headY, size * 0.25, size * 0.2, 0, 0, Math.PI * 2);
927
+ ctx.fill();
928
+ ctx.stroke();
929
+
930
+ // Antlers
931
+ ctx.strokeStyle = '#6e4a2e';
932
+ ctx.lineWidth = 1.5;
933
+ const antlerX = headX - dir * size * 0.1;
934
+ const antlerY = headY - size * 0.15;
935
+ ctx.beginPath();
936
+ ctx.moveTo(antlerX, antlerY);
937
+ ctx.lineTo(antlerX - dir * size * 0.2, antlerY - size * 0.3);
938
+ ctx.lineTo(antlerX - dir * size * 0.1, antlerY - size * 0.4);
939
+ ctx.moveTo(antlerX - dir * size * 0.2, antlerY - size * 0.3);
940
+ ctx.lineTo(antlerX - dir * size * 0.3, antlerY - size * 0.35);
941
+ ctx.stroke();
942
+
943
+ // Legs - galloping motion using half-sine wave
944
+ const legY = y + offsetY + size * 0.1;
945
+ ctx.lineWidth = 2.5;
946
+ ctx.strokeStyle = '#7b563a';
947
+
948
+ // Front leg extends forward during gallop
949
+ const frontLegExtension = runCycle * size * 0.45;
950
+ ctx.beginPath();
951
+ ctx.moveTo(reindeerX + dir * size * 0.3, legY);
952
+ ctx.lineTo(reindeerX + dir * (size * 0.3 + frontLegExtension), legY + size * 0.3);
953
+ ctx.stroke();
954
+
955
+ // Back leg extends backward during gallop (opposite phase)
956
+ const backLegExtension = (1 - runCycle) * size * 0.45;
957
+ ctx.beginPath();
958
+ ctx.moveTo(reindeerX - dir * size * 0.3, legY);
959
+ ctx.lineTo(reindeerX - dir * (size * 0.3 + backLegExtension), legY + size * 0.3);
960
+ ctx.stroke();
961
+
962
+ // Rudolph's glowing red nose (first reindeer only)
963
+ if (i === 0) {
964
+ const noseX = headX + dir * size * 0.25;
965
+ const noseY = headY;
966
+
967
+ // Nose
968
+ ctx.fillStyle = '#ff0000';
969
+ ctx.beginPath();
970
+ ctx.arc(noseX, noseY, size * 0.03, 0, Math.PI * 2);
971
+ ctx.fill();
972
+
973
+ // Glow
974
+ const gradient = ctx.createRadialGradient(noseX, noseY, 0, noseX, noseY, size * 0.10);
975
+ gradient.addColorStop(0, 'rgba(255,0,0,0.7)');
976
+ gradient.addColorStop(1, 'rgba(255,0,0,0)');
977
+ ctx.fillStyle = gradient;
978
+ ctx.beginPath();
979
+ ctx.arc(noseX, noseY, size * 0.10, 0, Math.PI * 2);
980
+ ctx.fill();
981
+ }
982
+ });
983
+
984
+ // Sleigh body
985
+ ctx.fillStyle = '#c00';
986
+ ctx.strokeStyle = '#FFD700';
987
+ ctx.lineWidth = 2;
988
+
989
+ const sleighTop = y - size * 0.7;
990
+ const sleighFront = x + dir * size * 1.5;
991
+ const sleighBack = x - dir * size * 0.3;
992
+ const sleighBottom = y + size * 0.3;
993
+
994
+ ctx.beginPath();
995
+ ctx.moveTo(sleighFront, sleighTop);
996
+ ctx.quadraticCurveTo(x + dir * size, y - size * 0.2, sleighFront - dir * size * 0.8, y);
997
+ ctx.lineTo(sleighBack, y);
998
+ ctx.lineTo(sleighBack, sleighBottom);
999
+ ctx.lineTo(sleighFront, sleighBottom);
1000
+ ctx.closePath();
1001
+ ctx.fill();
1002
+ ctx.stroke();
1003
+
1004
+ // Sleigh back
1005
+ ctx.beginPath();
1006
+ ctx.moveTo(sleighBack, y);
1007
+ ctx.quadraticCurveTo(sleighBack - dir * size * 0.4, y - size * 0.5, sleighBack, sleighTop);
1008
+ ctx.quadraticCurveTo(sleighBack + dir * size * 0.2, y - size * 0.4, sleighBack + dir * size * 0.2, y);
1009
+ ctx.closePath();
1010
+ ctx.fill();
1011
+ ctx.stroke();
1012
+
1013
+ // Runners/blades
1014
+ ctx.strokeStyle = '#a52a2a';
1015
+ ctx.lineWidth = 4;
1016
+ const runnerY = sleighBottom + size * 0.2;
1017
+
1018
+ function drawRunner(offset) {
1019
+ ctx.beginPath();
1020
+ ctx.moveTo(sleighFront + dir * size * 0.1, runnerY + offset);
1021
+ ctx.quadraticCurveTo(x, runnerY + offset + size * 0.2, sleighBack - dir * size * 0.2, runnerY + offset);
1022
+ ctx.stroke();
1023
+
1024
+ // Struts
1025
+ ctx.beginPath();
1026
+ ctx.moveTo(sleighFront - dir * size * 0.5, sleighBottom);
1027
+ ctx.lineTo(sleighFront - dir * size * 0.5, runnerY + offset);
1028
+ ctx.stroke();
1029
+ ctx.beginPath();
1030
+ ctx.moveTo(sleighBack + dir * size * 0.5, sleighBottom);
1031
+ ctx.lineTo(sleighBack + dir * size * 0.5, runnerY + offset);
1032
+ ctx.stroke();
1033
+ }
1034
+
1035
+ drawRunner(0);
1036
+ drawRunner(size * 0.1);
1037
+
1038
+ // Gift sack
1039
+ const sackX = x - dir * size * 0.05;
1040
+ ctx.fillStyle = '#5c4033';
1041
+ ctx.beginPath();
1042
+ ctx.moveTo(sackX - size * 0.3, y - size * 0.4);
1043
+ ctx.quadraticCurveTo(sackX - size * 0.6, y - size * 0.2, sackX - size * 0.4, y + size * 0.1);
1044
+ ctx.quadraticCurveTo(sackX, y + size * 0.3, sackX + size * 0.4, y + size * 0.1);
1045
+ ctx.quadraticCurveTo(sackX + size * 0.6, y - size * 0.2, sackX + size * 0.3, y - size * 0.4);
1046
+ ctx.closePath();
1047
+ ctx.fill();
1048
+
1049
+ // Highlight
1050
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.2)';
1051
+ ctx.beginPath();
1052
+ ctx.ellipse(sackX + size * 0.1, y - size * 0.3, size * 0.2, size * 0.1, -Math.PI / 4, 0, Math.PI * 2);
1053
+ ctx.fill();
1054
+
1055
+ // Rope tie
1056
+ ctx.strokeStyle = '#654321';
1057
+ ctx.lineWidth = 1;
1058
+ ctx.beginPath();
1059
+ ctx.arc(sackX, y - size * 0.4, size * 0.2, 0, Math.PI, true);
1060
+ ctx.stroke();
1061
+
1062
+ // Santa
1063
+ const santaX = x + dir * size * 0.6;
1064
+ const santaY = y - size * 0.55;
1065
+
1066
+ // Santa hat
1067
+ ctx.fillStyle = '#c00';
1068
+ ctx.beginPath();
1069
+ ctx.moveTo(santaX - size * 0.2, santaY);
1070
+ ctx.lineTo(santaX + size * 0.2, santaY);
1071
+ ctx.lineTo(santaX, santaY - size * 0.4);
1072
+ ctx.closePath();
1073
+ ctx.fill();
1074
+
1075
+ ctx.fillStyle = '#fff';
1076
+ ctx.fillRect(santaX - size * 0.22, santaY, size * 0.44, size * 0.08);
1077
+ ctx.beginPath();
1078
+ ctx.arc(santaX, santaY - size * 0.4, size * 0.07, 0, Math.PI * 2);
1079
+ ctx.fill();
1080
+
1081
+ // Santa face
1082
+ ctx.fillStyle = '#FFD7BA';
1083
+ ctx.beginPath();
1084
+ ctx.arc(santaX, santaY + size * 0.08, size * 0.18, 0, Math.PI * 2);
1085
+ ctx.fill();
1086
+
1087
+ // Santa beard
1088
+ ctx.fillStyle = '#fff';
1089
+ ctx.beginPath();
1090
+ ctx.ellipse(santaX, santaY + size * 0.2, size * 0.25, size * 0.2, 0, 0, Math.PI);
1091
+ ctx.fill();
1092
+
1093
+ // Reins connecting to reindeer
1094
+ ctx.strokeStyle = '#654321';
1095
+ ctx.lineWidth = 1;
1096
+ reindeerPositions.forEach(pos => {
1097
+ const reindeerX = x + dir * (size * pos.x);
1098
+ const offsetY = pos.y * size;
1099
+ const headX = reindeerX + dir * size * 0.5;
1100
+ ctx.beginPath();
1101
+ ctx.moveTo(santaX, santaY + size * 0.1);
1102
+ ctx.quadraticCurveTo((santaX + headX) / 2, y + offsetY - size * 0.5, headX - dir * size * 0.1, y + offsetY - size * 0.3);
1103
+ ctx.stroke();
1104
+ });
1105
+ },
1106
+
1107
+ /**
1108
+ * Draw walking elf
1109
+ */
1110
+ drawElf(ctx, particle) {
1111
+ const x = particle.x;
1112
+ const y = particle.y;
1113
+ const size = particle.size;
1114
+ const dir = particle.vx > 0 ? 1 : -1;
1115
+
1116
+ ctx.save();
1117
+ ctx.translate(x, y);
1118
+
1119
+ const legAngle = Math.sin(particle.time * 0.05) * (Math.PI / 3);
1120
+
1121
+ // Legs
1122
+ ctx.fillStyle = '#004d00';
1123
+ ctx.fillRect(dir * -size * 0.1, size * 0.2, size * 0.2, size * 0.5 + (Math.sin(legAngle + Math.PI) * size * 0.1));
1124
+ ctx.fillStyle = '#4a2c2a';
1125
+ ctx.beginPath();
1126
+ ctx.ellipse(dir * 0, size * 0.7 + (Math.sin(legAngle + Math.PI) * size * 0.1), size * 0.3, size * 0.15, 0, 0, Math.PI * 2);
1127
+ ctx.fill();
1128
+
1129
+ ctx.fillStyle = '#006400';
1130
+ ctx.fillRect(dir * size * 0.1, size * 0.2, size * 0.2, size * 0.5 + (Math.sin(legAngle) * size * 0.1));
1131
+ ctx.fillStyle = '#5d3836';
1132
+ ctx.beginPath();
1133
+ ctx.ellipse(dir * size * 0.2, size * 0.7 + (Math.sin(legAngle) * size * 0.1), size * 0.3, size * 0.15, 0, 0, Math.PI * 2);
1134
+ ctx.fill();
1135
+
1136
+ // Body (green tunic)
1137
+ ctx.fillStyle = '#008000';
1138
+ ctx.beginPath();
1139
+ ctx.moveTo(0, -size * 0.5);
1140
+ ctx.lineTo(dir * size * 0.4, size * 0.3);
1141
+ ctx.lineTo(dir * -size * 0.4, size * 0.3);
1142
+ ctx.closePath();
1143
+ ctx.fill();
1144
+
1145
+ // Head
1146
+ ctx.fillStyle = '#FFD7BA';
1147
+ ctx.beginPath();
1148
+ ctx.arc(0, -size * 0.6, size * 0.3, 0, Math.PI * 2);
1149
+ ctx.fill();
1150
+
1151
+ // Red elf hat
1152
+ ctx.fillStyle = '#c00';
1153
+ ctx.beginPath();
1154
+ ctx.moveTo(0, -size * 0.7);
1155
+ ctx.lineTo(dir * size * 0.35, -size * 0.6);
1156
+ ctx.lineTo(dir * -size * 0.35, -size * 0.6);
1157
+ ctx.closePath();
1158
+ ctx.fill();
1159
+
1160
+ // Hat tip
1161
+ ctx.beginPath();
1162
+ ctx.moveTo(0, -size * 0.7);
1163
+ ctx.quadraticCurveTo(dir * size * 0.2, -size * 1.1, dir * size * 0.4, -size * 1.3);
1164
+ ctx.stroke();
1165
+
1166
+ // Bell
1167
+ ctx.fillStyle = '#ffff00';
1168
+ ctx.beginPath();
1169
+ ctx.arc(dir * size * 0.4, -size * 1.3, size * 0.15, 0, Math.PI * 2);
1170
+ ctx.fill();
1171
+
1172
+ ctx.restore();
1173
+ },
1174
+
1175
+ /**
1176
+ * Draw Christmas steam train
1177
+ */
1178
+ drawTrain(ctx, particle) {
1179
+ const x = particle.x;
1180
+ const y = particle.y;
1181
+ const size = particle.size * 1.8;
1182
+ const dir = particle.vx > 0 ? 1 : -1;
1183
+ const time = particle.time;
1184
+
1185
+ // Draw smoke puffs FIRST (before translation) - they use absolute coordinates
1186
+ if (particle.smoke && particle.smoke.length > 0) {
1187
+ particle.smoke.forEach(smoke => {
1188
+ ctx.save();
1189
+ ctx.globalAlpha = smoke.opacity;
1190
+
1191
+ // Light gray smoke with subtle gradient
1192
+ const gradient = ctx.createRadialGradient(smoke.x, smoke.y, 0, smoke.x, smoke.y, smoke.size);
1193
+ gradient.addColorStop(0, '#CCCCCC');
1194
+ gradient.addColorStop(0.5, '#AAAAAA');
1195
+ gradient.addColorStop(1, '#888888');
1196
+ ctx.fillStyle = gradient;
1197
+
1198
+ ctx.beginPath();
1199
+ ctx.arc(smoke.x, smoke.y, smoke.size, 0, Math.PI * 2);
1200
+ ctx.fill();
1201
+ ctx.restore();
1202
+ });
1203
+ }
1204
+
1205
+ ctx.save();
1206
+ ctx.translate(x, y);
1207
+ if (dir === -1) {
1208
+ ctx.scale(-1, 1);
1209
+ }
1210
+
1211
+ const wheelRotation = time * 0.005;
1212
+ const baseUnit = size / 20;
1213
+ const baseY = 0;
1214
+ const wheelRadius = baseUnit * 8;
1215
+ const smallWheelRadius = baseUnit * 5;
1216
+ const chassisHeight = baseUnit * 7;
1217
+ const carHeight = baseUnit * 35;
1218
+ const carriageBodyBottomY = -smallWheelRadius - baseUnit;
1219
+ const engineChassisBottomY = -wheelRadius - baseUnit;
1220
+ const carBodyTopY = carriageBodyBottomY - carHeight;
1221
+ const cabHeight = baseUnit * 30;
1222
+ const boilerRadius = baseUnit * 10;
1223
+ const boilerTopY = engineChassisBottomY - chassisHeight - boilerRadius * 2;
1224
+ const engineLength = baseUnit * 70;
1225
+ const cabWidth = baseUnit * 25;
1226
+ const boilerWidth = engineLength - cabWidth;
1227
+ const carWidth = baseUnit * 60;
1228
+ const carGap = baseUnit * 15;
1229
+
1230
+ // Draw carriages
1231
+ for (let i = 1; i <= particle.carriages; i++) {
1232
+ const carX = -(cabWidth + (i * (carWidth + carGap)) - carGap);
1233
+
1234
+ // Wheels
1235
+ ctx.fillStyle = '#222';
1236
+ ctx.strokeStyle = '#444';
1237
+ ctx.lineWidth = 1;
1238
+ ctx.beginPath();
1239
+ ctx.arc(carX + carWidth * 0.25, -smallWheelRadius, smallWheelRadius, 0, Math.PI * 2);
1240
+ ctx.fill();
1241
+ ctx.stroke();
1242
+ ctx.beginPath();
1243
+ ctx.arc(carX + carWidth * 0.75, -smallWheelRadius, smallWheelRadius, 0, Math.PI * 2);
1244
+ ctx.fill();
1245
+ ctx.stroke();
1246
+
1247
+ // Body
1248
+ ctx.fillStyle = '#004d00';
1249
+ ctx.strokeStyle = '#DAA520';
1250
+ ctx.lineWidth = baseUnit * 0.5;
1251
+ ctx.fillRect(carX, carBodyTopY, carWidth, carHeight);
1252
+ ctx.strokeRect(carX, carBodyTopY, carWidth, carHeight);
1253
+
1254
+ // Windows with warm light
1255
+ const windowHeight = carHeight * 0.6;
1256
+ const windowY = carBodyTopY + carHeight * 0.2;
1257
+ ctx.shadowColor = '#F1C40F';
1258
+ ctx.shadowBlur = 15;
1259
+ const lightIntensity = 0.8 + Math.sin(time * 0.001 + i) * 0.2;
1260
+ ctx.fillStyle = `rgba(255, 235, 150, ${lightIntensity})`;
1261
+ const windowWidth = carWidth * 0.25;
1262
+ const window1X = carX + carWidth * 0.15;
1263
+ const window2X = carX + carWidth * 0.6;
1264
+ ctx.fillRect(window1X, windowY, windowWidth, windowHeight);
1265
+ ctx.fillRect(window2X, windowY, windowWidth, windowHeight);
1266
+ ctx.shadowBlur = 0;
1267
+
1268
+ // Passenger silhouettes
1269
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.4)';
1270
+ const headRadius1 = windowWidth * 0.2;
1271
+ const headX1 = window1X + windowWidth / 2;
1272
+ const headY1 = windowY + headRadius1 * 1.8;
1273
+ ctx.beginPath();
1274
+ ctx.arc(headX1, headY1, headRadius1, 0, Math.PI * 2);
1275
+ ctx.fill();
1276
+ ctx.fillRect(headX1 - headRadius1, headY1 + headRadius1, headRadius1 * 2, headRadius1 * 2);
1277
+
1278
+ const headRadius2 = windowWidth * 0.18;
1279
+ const headX2 = window2X + windowWidth / 2;
1280
+ const headY2 = windowY + headRadius2 * 1.6;
1281
+ ctx.beginPath();
1282
+ ctx.arc(headX2, headY2, headRadius2, 0, Math.PI * 2);
1283
+ ctx.fill();
1284
+ ctx.fillRect(headX2 - headRadius2 * 1.2, headY2, headRadius2 * 2.4, headRadius2 * 2.5);
1285
+
1286
+ // Wreath on carriage
1287
+ const wreathSize = baseUnit * 4;
1288
+ ctx.fillStyle = '#228B22';
1289
+ ctx.beginPath();
1290
+ ctx.arc(carX + carWidth / 2, carBodyTopY + carHeight / 2, wreathSize, 0, Math.PI * 2);
1291
+ ctx.arc(carX + carWidth / 2, carBodyTopY + carHeight / 2, wreathSize * 0.6, 0, Math.PI * 2, true);
1292
+ ctx.fill();
1293
+ ctx.fillStyle = '#c00';
1294
+ ctx.beginPath();
1295
+ ctx.arc(carX + carWidth / 2, carBodyTopY + carHeight / 2 + wreathSize, wreathSize * 0.3, 0, Math.PI * 2);
1296
+ ctx.fill();
1297
+
1298
+ // Coupling
1299
+ ctx.strokeStyle = '#111';
1300
+ ctx.lineWidth = baseUnit;
1301
+ ctx.beginPath();
1302
+ const couplingY = carriageBodyBottomY + chassisHeight / 2;
1303
+ const prevEnd = (i === 1) ? -cabWidth : carX + carWidth + carGap;
1304
+ ctx.moveTo(carX + carWidth, couplingY);
1305
+ ctx.lineTo(prevEnd, couplingY);
1306
+ ctx.stroke();
1307
+ }
1308
+
1309
+ // Draw engine
1310
+ ctx.fillStyle = '#2C3E50';
1311
+ ctx.strokeStyle = '#555';
1312
+ ctx.lineWidth = baseUnit;
1313
+ const wheelPositions = [baseUnit * 15, baseUnit * 35, baseUnit * 55];
1314
+ wheelPositions.forEach(wx => {
1315
+ ctx.beginPath();
1316
+ ctx.arc(wx, -wheelRadius, wheelRadius, 0, Math.PI * 2);
1317
+ ctx.fill();
1318
+ ctx.stroke();
1319
+ });
1320
+ const smallWheelX = -baseUnit * 10;
1321
+ ctx.beginPath();
1322
+ ctx.arc(smallWheelX, -smallWheelRadius, smallWheelRadius, 0, Math.PI * 2);
1323
+ ctx.fill();
1324
+ ctx.stroke();
1325
+
1326
+ // Chassis
1327
+ ctx.fillStyle = '#1C2833';
1328
+ ctx.fillRect(-cabWidth - baseUnit * 5, engineChassisBottomY - chassisHeight, engineLength + baseUnit * 5, chassisHeight);
1329
+
1330
+ // Cab
1331
+ ctx.fillStyle = '#B03A2E';
1332
+ ctx.strokeStyle = '#DAA520';
1333
+ ctx.lineWidth = baseUnit * 0.7;
1334
+ const cabTopY = engineChassisBottomY - chassisHeight - cabHeight;
1335
+ ctx.beginPath();
1336
+ ctx.moveTo(-cabWidth, cabTopY);
1337
+ ctx.lineTo(0, cabTopY);
1338
+ ctx.lineTo(0, engineChassisBottomY - chassisHeight);
1339
+ ctx.lineTo(-cabWidth - baseUnit * 5, engineChassisBottomY - chassisHeight);
1340
+ ctx.closePath();
1341
+ ctx.fill();
1342
+ ctx.stroke();
1343
+ ctx.fillStyle = `rgba(255, 235, 150, ${0.8 + Math.sin(time * 0.001) * 0.2})`;
1344
+ ctx.fillRect(-cabWidth + baseUnit * 4, cabTopY + baseUnit * 4, cabWidth - baseUnit * 12, baseUnit * 10);
1345
+
1346
+ // Boiler
1347
+ ctx.fillStyle = '#2C3E50';
1348
+ ctx.beginPath();
1349
+ ctx.moveTo(0, engineChassisBottomY - chassisHeight);
1350
+ ctx.lineTo(boilerWidth, engineChassisBottomY - chassisHeight);
1351
+ ctx.arc(boilerWidth, boilerTopY + boilerRadius, boilerRadius, Math.PI / 2, -Math.PI / 2);
1352
+ ctx.lineTo(0, boilerTopY);
1353
+ ctx.closePath();
1354
+ ctx.fill();
1355
+ ctx.stroke();
1356
+
1357
+ // Chimney
1358
+ const chimneyTopY = boilerTopY - baseUnit * 8;
1359
+ ctx.fillStyle = '#17202A';
1360
+ ctx.beginPath();
1361
+ ctx.moveTo(boilerWidth * 0.7, boilerTopY);
1362
+ ctx.lineTo(boilerWidth * 0.7 - baseUnit * 2, chimneyTopY);
1363
+ ctx.lineTo(boilerWidth * 0.7 + baseUnit * 10, chimneyTopY - baseUnit * 4);
1364
+ ctx.lineTo(boilerWidth * 0.7 + baseUnit * 8, boilerTopY);
1365
+ ctx.closePath();
1366
+ ctx.fill();
1367
+
1368
+ // Headlight
1369
+ const lightX = boilerWidth + boilerRadius;
1370
+ const lightY = boilerTopY + boilerRadius;
1371
+ const lightRadius = baseUnit * 4;
1372
+ const gradient = ctx.createRadialGradient(lightX, lightY, lightRadius * 0.2, lightX, lightY, lightRadius * 1.5);
1373
+ gradient.addColorStop(0, 'rgba(255, 255, 200, 1)');
1374
+ gradient.addColorStop(0.4, 'rgba(255, 220, 100, 0.8)');
1375
+ gradient.addColorStop(1, 'rgba(255, 200, 0, 0)');
1376
+ ctx.fillStyle = gradient;
1377
+ ctx.fillRect(lightX - lightRadius, lightY - lightRadius, lightRadius * 2, lightRadius * 2);
1378
+
1379
+ // Connecting rods
1380
+ ctx.strokeStyle = '#99A3A4';
1381
+ ctx.lineWidth = baseUnit * 2;
1382
+ const rodYOffset = Math.sin(wheelRotation) * wheelRadius * 0.6;
1383
+ const mainRodX = wheelPositions[1] + Math.cos(wheelRotation) * wheelRadius * 0.6;
1384
+ const pistonX = engineLength - baseUnit * 10;
1385
+ const pistonY = engineChassisBottomY - chassisHeight + baseUnit * 2 + Math.sin(time * 0.01) * baseUnit;
1386
+ ctx.beginPath();
1387
+ ctx.moveTo(pistonX, pistonY);
1388
+ ctx.lineTo(mainRodX, -wheelRadius + rodYOffset);
1389
+ ctx.stroke();
1390
+
1391
+ ctx.lineWidth = baseUnit * 1.5;
1392
+ [wheelPositions[0], wheelPositions[2]].forEach(wx => {
1393
+ const subRodX = wx + Math.cos(wheelRotation) * wheelRadius * 0.6;
1394
+ ctx.beginPath();
1395
+ ctx.moveTo(mainRodX, -wheelRadius + rodYOffset);
1396
+ ctx.lineTo(subRodX, -wheelRadius + rodYOffset);
1397
+ ctx.stroke();
1398
+ });
1399
+
1400
+ ctx.restore();
1401
+ },
1402
+
1403
+ /**
1404
+ * Draw firework particle or spark
1405
+ */
1406
+ drawFirework(ctx, particle) {
1407
+ ctx.fillStyle = particle.color || '#ffffff';
1408
+ ctx.beginPath();
1409
+ ctx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2);
1410
+ ctx.fill();
1411
+ },
1412
+
1413
+ /**
1414
+ * Explode firework into sparks
1415
+ */
1416
+ explodeFirework(particle, specialParticles) {
1417
+ const sparkCount = 50 + Math.random() * 50;
1418
+ const colors = ['#ff0000', '#ffff00', '#00ff00', '#0000ff', '#ffffff'];
1419
+ for (let i = 0; i < sparkCount; i++) {
1420
+ const angle = Math.random() * Math.PI * 2;
1421
+ const speed = Math.random() * 5 + 2;
1422
+ specialParticles.push({
1423
+ type: 'spark',
1424
+ x: particle.x,
1425
+ y: particle.y,
1426
+ vx: Math.cos(angle) * speed,
1427
+ vy: Math.sin(angle) * speed,
1428
+ size: 2 + Math.random() * 2,
1429
+ opacity: 1,
1430
+ active: true,
1431
+ static: false,
1432
+ color: colors[Math.floor(Math.random() * colors.length)]
1433
+ });
1434
+ }
1435
+ },
1436
+
1437
+ /**
1438
+ * Draw robin (British red-breasted robin with Santa hat)
1439
+ */
1440
+ drawRobin(ctx, particle, time) {
1441
+ const x = particle.x;
1442
+ const y = particle.y;
1443
+ const size = particle.size;
1444
+ const dir = particle.vx >= 0 ? 1 : -1;
1445
+
1446
+ // Determine animation state
1447
+ const isFlying = particle.state === 'flying_in' || particle.state === 'flying_away';
1448
+ const isSitting = particle.state === 'sitting';
1449
+
1450
+ // Initialize transition tracking if needed
1451
+ if (particle.wingIntensity === undefined) {
1452
+ particle.wingIntensity = isFlying ? 1 : 0;
1453
+ }
1454
+
1455
+ // Smooth exponential transition between flying and sitting
1456
+ const targetIntensity = isFlying ? 1 : 0;
1457
+ const transitionSpeed = 0.02; // Smooth transition
1458
+ particle.wingIntensity += (targetIntensity - particle.wingIntensity) * transitionSpeed;
1459
+
1460
+ // Wing flapping with sinusoidal easing for smooth, natural motion
1461
+ let wingAngle = 0;
1462
+ if (particle.wingIntensity > 0.01) {
1463
+ // Natural wing flap frequency (about 2-3 flaps per second)
1464
+ const flapFrequency = 0.012; // Frequency in radians per millisecond
1465
+
1466
+ // Get base sine wave (-1 to 1)
1467
+ const rawSine = Math.sin(time * flapFrequency + particle.waveOffset);
1468
+
1469
+ // Apply ease-in-out using sine for smooth acceleration/deceleration
1470
+ // This creates the characteristic "flap" motion: slow at extremes, fast in middle
1471
+ const easedSine = Math.sin(rawSine * Math.PI / 2);
1472
+
1473
+ // Amplitude: wings move from folded (down) to extended (up)
1474
+ const flapAmplitude = Math.PI / 6; // 30° range
1475
+
1476
+ // Apply intensity for smooth transitions
1477
+ wingAngle = easedSine * flapAmplitude * particle.wingIntensity;
1478
+ }
1479
+
1480
+ ctx.save();
1481
+ ctx.translate(x, y);
1482
+ if (dir === -1) {
1483
+ ctx.scale(-1, 1);
1484
+ }
1485
+
1486
+ // Body (red breast)
1487
+ ctx.fillStyle = '#A52A2A'; // Brown back
1488
+ ctx.beginPath();
1489
+ ctx.ellipse(0, 0, size * 0.6, size * 0.8, 0, 0, Math.PI * 2);
1490
+ ctx.fill();
1491
+
1492
+ // Red breast
1493
+ ctx.fillStyle = '#DC143C'; // Crimson red
1494
+ ctx.beginPath();
1495
+ ctx.ellipse(size * 0.15, size * 0.1, size * 0.45, size * 0.6, 0, 0, Math.PI * 2);
1496
+ ctx.fill();
1497
+
1498
+ // Tail (behind wings) - very subtle
1499
+ ctx.fillStyle = '#654321';
1500
+ ctx.strokeStyle = '#4a2c2a';
1501
+ ctx.lineWidth = 0.5;
1502
+ ctx.beginPath();
1503
+ ctx.moveTo(-size * 0.5, 0);
1504
+ ctx.quadraticCurveTo(-size * 0.7, -size * 0.08, -size * 0.85, 0);
1505
+ ctx.quadraticCurveTo(-size * 0.7, size * 0.08, -size * 0.5, 0);
1506
+ ctx.fill();
1507
+ ctx.stroke();
1508
+
1509
+ // Left wing (back wing)
1510
+ ctx.fillStyle = '#8B4513';
1511
+ ctx.strokeStyle = '#654321';
1512
+ ctx.lineWidth = 1;
1513
+ ctx.save();
1514
+ ctx.translate(-size * 0.3, -size * 0.1);
1515
+ ctx.rotate(wingAngle);
1516
+ ctx.beginPath();
1517
+ ctx.ellipse(0, 0, size * 0.45, size * 0.25, -Math.PI / 6, 0, Math.PI * 2);
1518
+ ctx.fill();
1519
+ ctx.stroke();
1520
+ ctx.restore();
1521
+
1522
+ // Right wing (front wing)
1523
+ ctx.fillStyle = '#A0692F';
1524
+ ctx.strokeStyle = '#654321';
1525
+ ctx.lineWidth = 1;
1526
+ ctx.save();
1527
+ ctx.translate(-size * 0.3, size * 0.1);
1528
+ ctx.rotate(-wingAngle);
1529
+ ctx.beginPath();
1530
+ ctx.ellipse(0, 0, size * 0.45, size * 0.25, Math.PI / 6, 0, Math.PI * 2);
1531
+ ctx.fill();
1532
+ ctx.stroke();
1533
+ ctx.restore();
1534
+
1535
+ // Head - simple, minimal animation
1536
+ ctx.fillStyle = '#A52A2A';
1537
+ ctx.beginPath();
1538
+ ctx.arc(size * 0.5, -size * 0.2, size * 0.35, 0, Math.PI * 2);
1539
+ ctx.fill();
1540
+
1541
+ // Beak
1542
+ ctx.fillStyle = '#FFD700';
1543
+ ctx.strokeStyle = '#DAA520';
1544
+ ctx.lineWidth = 0.5;
1545
+ ctx.beginPath();
1546
+ ctx.moveTo(size * 0.75, -size * 0.2);
1547
+ ctx.lineTo(size * 0.95, -size * 0.15);
1548
+ ctx.lineTo(size * 0.75, -size * 0.1);
1549
+ ctx.closePath();
1550
+ ctx.fill();
1551
+ ctx.stroke();
1552
+
1553
+ // Eye
1554
+ ctx.fillStyle = '#000000';
1555
+ ctx.beginPath();
1556
+ ctx.arc(size * 0.6, -size * 0.25, size * 0.08, 0, Math.PI * 2);
1557
+ ctx.fill();
1558
+
1559
+ // Eye highlight
1560
+ ctx.fillStyle = '#FFFFFF';
1561
+ ctx.beginPath();
1562
+ ctx.arc(size * 0.62, -size * 0.27, size * 0.03, 0, Math.PI * 2);
1563
+ ctx.fill();
1564
+
1565
+ // Legs (when sitting) - simple and clean
1566
+ if (isSitting) {
1567
+ ctx.strokeStyle = '#8B4513';
1568
+ ctx.lineWidth = size * 0.08;
1569
+ ctx.lineCap = 'round';
1570
+
1571
+ // Right leg
1572
+ ctx.beginPath();
1573
+ ctx.moveTo(size * 0.1, size * 0.7);
1574
+ ctx.lineTo(size * 0.1, size * 1.0);
1575
+ ctx.stroke();
1576
+
1577
+ // Left leg
1578
+ ctx.beginPath();
1579
+ ctx.moveTo(-size * 0.1, size * 0.7);
1580
+ ctx.lineTo(-size * 0.1, size * 1.0);
1581
+ ctx.stroke();
1582
+
1583
+ // Simple feet
1584
+ ctx.lineWidth = size * 0.06;
1585
+ ctx.beginPath();
1586
+ ctx.moveTo(size * 0.1, size * 1.0);
1587
+ ctx.lineTo(size * 0.25, size * 1.0);
1588
+ ctx.stroke();
1589
+ ctx.beginPath();
1590
+ ctx.moveTo(-size * 0.1, size * 1.0);
1591
+ ctx.lineTo(-size * 0.25, size * 1.0);
1592
+ ctx.stroke();
1593
+ }
1594
+
1595
+ // Santa hat
1596
+ const hatX = size * 0.5;
1597
+ const hatY = -size * 0.55;
1598
+
1599
+ // Hat body
1600
+ ctx.fillStyle = '#c00';
1601
+ ctx.beginPath();
1602
+ ctx.moveTo(hatX - size * 0.3, hatY);
1603
+ ctx.lineTo(hatX + size * 0.25, hatY);
1604
+ ctx.lineTo(hatX + size * 0.1, hatY - size * 0.5);
1605
+ ctx.closePath();
1606
+ ctx.fill();
1607
+
1608
+ // Hat brim
1609
+ ctx.fillStyle = '#FFFFFF';
1610
+ ctx.fillRect(hatX - size * 0.32, hatY, size * 0.58, size * 0.1);
1611
+
1612
+ // Hat pompom
1613
+ ctx.beginPath();
1614
+ ctx.arc(hatX + size * 0.1, hatY - size * 0.5, size * 0.1, 0, Math.PI * 2);
1615
+ ctx.fill();
1616
+
1617
+ ctx.restore();
1618
+ },
1619
+
1620
+ /**
1621
+ * Draw snowman with top hat, scarf, and coal features
1622
+ */
1623
+ drawSnowman(ctx, particle, time) {
1624
+ const x = particle.x;
1625
+ const y = particle.y;
1626
+ const size = particle.size;
1627
+
1628
+ // Subtle idle animation (gentle sway)
1629
+ const sway = Math.sin(time * 0.002 + particle.wavePhase) * 0.03;
1630
+
1631
+ ctx.save();
1632
+ ctx.translate(x, y);
1633
+ ctx.rotate(sway);
1634
+
1635
+ // Shadow
1636
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.2)';
1637
+ ctx.beginPath();
1638
+ ctx.ellipse(0, size * 2.0, size * 1.2, size * 0.3, 0, 0, Math.PI * 2);
1639
+ ctx.fill();
1640
+
1641
+ // Bottom snowball (largest)
1642
+ ctx.fillStyle = '#FFFFFF';
1643
+ ctx.strokeStyle = '#E0E0E0';
1644
+ ctx.lineWidth = 1;
1645
+ ctx.beginPath();
1646
+ ctx.arc(0, size * 1.3, size * 1.0, 0, Math.PI * 2);
1647
+ ctx.fill();
1648
+ ctx.stroke();
1649
+
1650
+ // Middle snowball
1651
+ ctx.beginPath();
1652
+ ctx.arc(0, size * 0.3, size * 0.7, 0, Math.PI * 2);
1653
+ ctx.fill();
1654
+ ctx.stroke();
1655
+
1656
+ // Head snowball (smallest)
1657
+ ctx.beginPath();
1658
+ ctx.arc(0, -size * 0.6, size * 0.5, 0, Math.PI * 2);
1659
+ ctx.fill();
1660
+ ctx.stroke();
1661
+
1662
+ // Coal buttons
1663
+ ctx.fillStyle = '#000000';
1664
+ ctx.beginPath();
1665
+ ctx.arc(0, size * 0.5, size * 0.08, 0, Math.PI * 2);
1666
+ ctx.fill();
1667
+ ctx.beginPath();
1668
+ ctx.arc(0, size * 0.1, size * 0.08, 0, Math.PI * 2);
1669
+ ctx.fill();
1670
+ ctx.beginPath();
1671
+ ctx.arc(0, -size * 0.2, size * 0.08, 0, Math.PI * 2);
1672
+ ctx.fill();
1673
+
1674
+ // Stick arms
1675
+ ctx.strokeStyle = '#654321';
1676
+ ctx.lineWidth = size * 0.1;
1677
+
1678
+ // Left arm
1679
+ ctx.beginPath();
1680
+ ctx.moveTo(-size * 0.6, size * 0.3);
1681
+ ctx.lineTo(-size * 1.2, size * 0.0);
1682
+ ctx.lineTo(-size * 1.5, -size * 0.1);
1683
+ ctx.stroke();
1684
+ // Fingers
1685
+ ctx.lineWidth = size * 0.05;
1686
+ ctx.beginPath();
1687
+ ctx.moveTo(-size * 1.5, -size * 0.1);
1688
+ ctx.lineTo(-size * 1.7, -size * 0.3);
1689
+ ctx.stroke();
1690
+ ctx.beginPath();
1691
+ ctx.moveTo(-size * 1.5, -size * 0.1);
1692
+ ctx.lineTo(-size * 1.8, -size * 0.05);
1693
+ ctx.stroke();
1694
+
1695
+ // Right arm
1696
+ ctx.lineWidth = size * 0.1;
1697
+ ctx.beginPath();
1698
+ ctx.moveTo(size * 0.6, size * 0.3);
1699
+ ctx.lineTo(size * 1.2, size * 0.0);
1700
+ ctx.lineTo(size * 1.5, -size * 0.1);
1701
+ ctx.stroke();
1702
+ // Fingers
1703
+ ctx.lineWidth = size * 0.05;
1704
+ ctx.beginPath();
1705
+ ctx.moveTo(size * 1.5, -size * 0.1);
1706
+ ctx.lineTo(size * 1.7, -size * 0.3);
1707
+ ctx.stroke();
1708
+ ctx.beginPath();
1709
+ ctx.moveTo(size * 1.5, -size * 0.1);
1710
+ ctx.lineTo(size * 1.8, -size * 0.05);
1711
+ ctx.stroke();
1712
+
1713
+ // Carrot nose
1714
+ ctx.fillStyle = '#FF6347'; // Tomato orange
1715
+ ctx.beginPath();
1716
+ ctx.moveTo(size * 0.15, -size * 0.6);
1717
+ ctx.lineTo(size * 0.6, -size * 0.65);
1718
+ ctx.lineTo(size * 0.15, -size * 0.55);
1719
+ ctx.closePath();
1720
+ ctx.fill();
1721
+
1722
+ // Eyes
1723
+ ctx.fillStyle = '#000000';
1724
+ ctx.beginPath();
1725
+ ctx.arc(-size * 0.15, -size * 0.7, size * 0.08, 0, Math.PI * 2);
1726
+ ctx.fill();
1727
+ ctx.beginPath();
1728
+ ctx.arc(size * 0.15, -size * 0.7, size * 0.08, 0, Math.PI * 2);
1729
+ ctx.fill();
1730
+
1731
+ // Smile (coal pieces)
1732
+ const smilePoints = [
1733
+ { x: -size * 0.2, y: -size * 0.4 },
1734
+ { x: -size * 0.1, y: -size * 0.35 },
1735
+ { x: 0, y: -size * 0.33 },
1736
+ { x: size * 0.1, y: -size * 0.35 },
1737
+ { x: size * 0.2, y: -size * 0.4 }
1738
+ ];
1739
+ smilePoints.forEach(point => {
1740
+ ctx.beginPath();
1741
+ ctx.arc(point.x, point.y, size * 0.05, 0, Math.PI * 2);
1742
+ ctx.fill();
1743
+ });
1744
+
1745
+ // Scarf
1746
+ ctx.fillStyle = '#c00';
1747
+ ctx.strokeStyle = '#8B0000';
1748
+ ctx.lineWidth = 1;
1749
+
1750
+ // Scarf around neck
1751
+ ctx.beginPath();
1752
+ ctx.ellipse(0, -size * 0.15, size * 0.55, size * 0.15, 0, 0, Math.PI * 2);
1753
+ ctx.fill();
1754
+ ctx.stroke();
1755
+
1756
+ // Scarf hanging end
1757
+ ctx.fillRect(size * 0.3, -size * 0.1, size * 0.2, size * 0.8);
1758
+ ctx.strokeRect(size * 0.3, -size * 0.1, size * 0.2, size * 0.8);
1759
+
1760
+ // Fringe at end of scarf
1761
+ ctx.strokeStyle = '#8B0000';
1762
+ ctx.lineWidth = size * 0.03;
1763
+ for (let i = 0; i < 4; i++) {
1764
+ ctx.beginPath();
1765
+ ctx.moveTo(size * 0.33 + i * size * 0.13, size * 0.7);
1766
+ ctx.lineTo(size * 0.33 + i * size * 0.13, size * 0.85);
1767
+ ctx.stroke();
1768
+ }
1769
+
1770
+ // Top hat
1771
+ ctx.fillStyle = '#000000';
1772
+ ctx.strokeStyle = '#333333';
1773
+ ctx.lineWidth = 2;
1774
+
1775
+ // Hat brim
1776
+ ctx.beginPath();
1777
+ ctx.ellipse(0, -size * 1.1, size * 0.7, size * 0.15, 0, 0, Math.PI * 2);
1778
+ ctx.fill();
1779
+ ctx.stroke();
1780
+
1781
+ // Hat top
1782
+ ctx.fillRect(-size * 0.45, -size * 1.8, size * 0.9, size * 0.7);
1783
+ ctx.strokeRect(-size * 0.45, -size * 1.8, size * 0.9, size * 0.7);
1784
+
1785
+ // Hat band (red)
1786
+ ctx.fillStyle = '#c00';
1787
+ ctx.fillRect(-size * 0.45, -size * 1.3, size * 0.9, size * 0.15);
1788
+
1789
+ // Holly on hat
1790
+ ctx.fillStyle = '#228B22';
1791
+ ctx.beginPath();
1792
+ ctx.arc(-size * 0.15, -size * 1.22, size * 0.08, 0, Math.PI * 2);
1793
+ ctx.fill();
1794
+ ctx.beginPath();
1795
+ ctx.arc(size * 0.15, -size * 1.22, size * 0.08, 0, Math.PI * 2);
1796
+ ctx.fill();
1797
+ // Berries
1798
+ ctx.fillStyle = '#FF0000';
1799
+ ctx.beginPath();
1800
+ ctx.arc(0, -size * 1.25, size * 0.06, 0, Math.PI * 2);
1801
+ ctx.fill();
1802
+
1803
+ ctx.restore();
1804
+ }
1805
+ };