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.
- package/LICENSE +21 -0
- package/README.md +469 -0
- package/admin/css/admin.css +1123 -0
- package/admin/index.html +72 -0
- package/admin/js/api.js +210 -0
- package/admin/js/app.js +270 -0
- package/admin/js/config/sidebar-config.js +107 -0
- package/admin/js/lib/card.js +63 -0
- package/admin/js/lib/image-editor.js +869 -0
- package/admin/js/lib/markdown-toolbar.js +421 -0
- package/admin/js/templates/dashboard.html +50 -0
- package/admin/js/templates/documentation.html +237 -0
- package/admin/js/templates/layouts.html +11 -0
- package/admin/js/templates/login.html +58 -0
- package/admin/js/templates/media.html +16 -0
- package/admin/js/templates/navigation.html +50 -0
- package/admin/js/templates/page-editor.html +126 -0
- package/admin/js/templates/pages.html +18 -0
- package/admin/js/templates/plugins.html +12 -0
- package/admin/js/templates/settings.html +190 -0
- package/admin/js/templates/tutorials.html +233 -0
- package/admin/js/templates/user-editor.html +12 -0
- package/admin/js/templates/users.html +10 -0
- package/admin/js/views/dashboard.js +48 -0
- package/admin/js/views/documentation.js +12 -0
- package/admin/js/views/index.js +33 -0
- package/admin/js/views/layouts.js +49 -0
- package/admin/js/views/login.js +254 -0
- package/admin/js/views/media.js +240 -0
- package/admin/js/views/navigation.js +152 -0
- package/admin/js/views/page-editor.js +479 -0
- package/admin/js/views/pages.js +64 -0
- package/admin/js/views/plugins.js +100 -0
- package/admin/js/views/settings.js +64 -0
- package/admin/js/views/tutorials.js +12 -0
- package/admin/js/views/user-editor.js +88 -0
- package/admin/js/views/users.js +73 -0
- package/bin/cli.js +334 -0
- package/config/auth.json +20 -0
- package/config/content.json +10 -0
- package/config/navigation.json +63 -0
- package/config/plugins.json +47 -0
- package/config/presets.json +34 -0
- package/config/server.json +6 -0
- package/config/site.json +33 -0
- package/package.json +67 -0
- package/plugins/back-to-top/admin/templates/back-to-top-settings.html +55 -0
- package/plugins/back-to-top/admin/views/back-to-top-settings.js +44 -0
- package/plugins/back-to-top/config.js +10 -0
- package/plugins/back-to-top/plugin.js +24 -0
- package/plugins/back-to-top/plugin.json +36 -0
- package/plugins/back-to-top/public/inject-body.html +105 -0
- package/plugins/cookie-consent/admin/templates/cookie-consent-settings.html +113 -0
- package/plugins/cookie-consent/admin/views/cookie-consent-settings.js +73 -0
- package/plugins/cookie-consent/config.js +30 -0
- package/plugins/cookie-consent/plugin.js +24 -0
- package/plugins/cookie-consent/plugin.json +36 -0
- package/plugins/cookie-consent/public/inject-body.html +69 -0
- package/plugins/custom-css/admin/templates/custom-css.html +17 -0
- package/plugins/custom-css/admin/views/custom-css.js +35 -0
- package/plugins/custom-css/config.js +1 -0
- package/plugins/custom-css/data/custom.css +0 -0
- package/plugins/custom-css/plugin.js +63 -0
- package/plugins/custom-css/plugin.json +32 -0
- package/plugins/custom-css/public/inject-head.html +1 -0
- package/plugins/domma-effects/admin/templates/domma-effects.html +488 -0
- package/plugins/domma-effects/admin/views/domma-effects.js +56 -0
- package/plugins/domma-effects/config.js +9 -0
- package/plugins/domma-effects/plugin.js +22 -0
- package/plugins/domma-effects/plugin.json +36 -0
- package/plugins/domma-effects/public/celebrations/core/canvas.js +111 -0
- package/plugins/domma-effects/public/celebrations/core/particles.js +144 -0
- package/plugins/domma-effects/public/celebrations/core/physics.js +166 -0
- package/plugins/domma-effects/public/celebrations/index.js +535 -0
- package/plugins/domma-effects/public/celebrations/themes/christmas.js +1805 -0
- package/plugins/domma-effects/public/celebrations/themes/guy-fawkes.js +1477 -0
- package/plugins/domma-effects/public/celebrations/themes/halloween.js +1837 -0
- package/plugins/domma-effects/public/celebrations/themes/st-andrews.js +1175 -0
- package/plugins/domma-effects/public/celebrations/themes/st-davids.js +1258 -0
- package/plugins/domma-effects/public/celebrations/themes/st-georges.js +1754 -0
- package/plugins/domma-effects/public/celebrations/themes/st-patricks.js +1290 -0
- package/plugins/domma-effects/public/celebrations/themes/valentines.js +1361 -0
- package/plugins/domma-effects/public/inject-body.html +268 -0
- package/plugins/example-analytics/admin/templates/analytics.html +10 -0
- package/plugins/example-analytics/admin/views/analytics.js +51 -0
- package/plugins/example-analytics/config.js +6 -0
- package/plugins/example-analytics/plugin.js +58 -0
- package/plugins/example-analytics/plugin.json +27 -0
- package/plugins/example-analytics/public/inject-body.html +13 -0
- package/plugins/example-analytics/public/inject-head.html +1 -0
- package/plugins/example-analytics/stats.json +1 -0
- package/plugins/form-builder/admin/templates/form-editor.html +158 -0
- package/plugins/form-builder/admin/templates/form-settings.html +29 -0
- package/plugins/form-builder/admin/templates/form-submissions.html +30 -0
- package/plugins/form-builder/admin/templates/forms-list.html +17 -0
- package/plugins/form-builder/admin/views/form-editor.js +817 -0
- package/plugins/form-builder/admin/views/form-settings.js +38 -0
- package/plugins/form-builder/admin/views/form-submissions.js +295 -0
- package/plugins/form-builder/admin/views/forms-list.js +164 -0
- package/plugins/form-builder/config.js +9 -0
- package/plugins/form-builder/data/forms/contact-details.json +63 -0
- package/plugins/form-builder/data/forms/contact.json +52 -0
- package/plugins/form-builder/data/submissions/contact-details.json +1 -0
- package/plugins/form-builder/data/submissions/contact.json +14 -0
- package/plugins/form-builder/email.js +103 -0
- package/plugins/form-builder/plugin.js +454 -0
- package/plugins/form-builder/plugin.json +56 -0
- package/plugins/form-builder/public/inject-body.html +270 -0
- package/plugins/form-builder/public/inject-head.html +42 -0
- package/public/css/site.css +189 -0
- package/public/js/site.js +109 -0
- package/scripts/copy-domma.js +48 -0
- package/scripts/fresh.js +41 -0
- package/scripts/reset.js +124 -0
- package/scripts/seed.js +666 -0
- package/scripts/setup.js +263 -0
- package/server/config.js +56 -0
- package/server/middleware/auth.js +97 -0
- package/server/routes/api/auth.js +116 -0
- package/server/routes/api/layouts.js +25 -0
- package/server/routes/api/media.js +93 -0
- package/server/routes/api/navigation.js +37 -0
- package/server/routes/api/pages.js +118 -0
- package/server/routes/api/plugins.js +46 -0
- package/server/routes/api/settings.js +25 -0
- package/server/routes/api/users.js +110 -0
- package/server/routes/public.js +108 -0
- package/server/server.js +169 -0
- package/server/services/content.js +298 -0
- package/server/services/images.js +334 -0
- package/server/services/markdown.js +297 -0
- package/server/services/plugins.js +246 -0
- package/server/services/renderer.js +80 -0
- package/server/services/users.js +212 -0
- 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
|
+
};
|