JavaScript · 23653 bytes Raw Blame History
1 // Main canvas setup
2 const canvas = document.getElementById('board')
3 const ctx = canvas.getContext('2d')
4 const effectsCanvas = document.getElementById('effectsCanvas')
5 const effectsCtx = effectsCanvas.getContext('2d')
6
7 const chars = [
8 '@', '#', '$', '%', '&', '*', '+', '=',
9 '~', '?', 'Ω', '∞', '¶', '§', '★', '☆'
10 ]
11
12 let drawing = false
13 let currentColor = '#0f0'
14 let currentTool = 'draw'
15
16 // Store active characters for animations
17 const activeChars = []
18 const particles = []
19 const drips = []
20 const smoke = []
21
22 // Tool buttons
23 const drawBtn = document.getElementById('drawTool')
24 const eraseBtn = document.getElementById('eraseTool')
25
26 // Effect toggles - organized by category
27 const effects = {
28 // Basic
29 glow: document.getElementById('glowEffect'),
30 emboss: document.getElementById('embossEffect'),
31 outline: document.getElementById('outlineEffect'),
32 shadow: document.getElementById('shadowEffect'),
33 // Animation
34 pulse: document.getElementById('pulseEffect'),
35 fadeIn: document.getElementById('fadeInEffect'),
36 typewriter: document.getElementById('typewriterEffect'),
37 jitter: document.getElementById('jitterEffect'),
38 // Style
39 rainbow: document.getElementById('rainbowEffect'),
40 gradient: document.getElementById('gradientEffect'),
41 doubleVision: document.getElementById('doubleVisionEffect'),
42 mirror: document.getElementById('mirrorEffect'),
43 // Particle
44 sparkles: document.getElementById('sparklesEffect'),
45 drip: document.getElementById('dripEffect'),
46 explode: document.getElementById('explodeEffect'),
47 smoke: document.getElementById('smokeEffect'),
48 // Transform
49 rotation: document.getElementById('rotationEffect'),
50 wave: document.getElementById('waveEffect'),
51 scatter: document.getElementById('scatterEffect'),
52 sizeVariance: document.getElementById('sizeVarianceEffect'),
53 // Special
54 matrix: document.getElementById('matrixEffect'),
55 glitch: document.getElementById('glitchEffect'),
56 neonFlicker: document.getElementById('neonFlickerEffect'),
57 echo: document.getElementById('echoEffect'),
58 // Motion
59 spiral: document.getElementById('spiralEffect'),
60 orbit: document.getElementById('orbitEffect'),
61 magnetic: document.getElementById('magneticEffect'),
62 vortex: document.getElementById('vortexEffect'),
63 snow: document.getElementById('snowEffect'),
64 fireworks: document.getElementById('fireworksEffect'),
65 lightning: document.getElementById('lightningEffect'),
66 pixelDissolve: document.getElementById('pixelDissolveEffect')
67 }
68
69 // Add active class toggle for all effects
70 Object.values(effects).forEach(checkbox => {
71 checkbox.addEventListener('change', (e) => {
72 e.target.closest('.effect-toggle').classList.toggle('active', e.target.checked)
73 })
74 })
75
76 // Color wheel setup
77 const colorWheel = document.getElementById('colorWheel')
78 const colorCtx = colorWheel.getContext('2d')
79 const colorPreview = document.getElementById('colorPreview')
80 colorWheel.width = 150
81 colorWheel.height = 150
82
83 // Draw color wheel with white center and black ring
84 function drawColorWheel() {
85 const centerX = colorWheel.width / 2
86 const centerY = colorWheel.height / 2
87 const radius = Math.min(centerX, centerY) - 2
88
89 for (let angle = 0; angle < 360; angle++) {
90 const startAngle = (angle - 1) * Math.PI / 180
91 const endAngle = angle * Math.PI / 180
92
93 colorCtx.beginPath()
94 colorCtx.moveTo(centerX, centerY)
95 colorCtx.arc(centerX, centerY, radius, startAngle, endAngle)
96 colorCtx.closePath()
97
98 const gradient = colorCtx.createRadialGradient(centerX, centerY, 0, centerX, centerY, radius)
99 gradient.addColorStop(0, 'white')
100 gradient.addColorStop(0.7, `hsl(${angle}, 100%, 50%)`)
101 gradient.addColorStop(0.95, `hsl(${angle}, 100%, 30%)`)
102 gradient.addColorStop(1, 'black')
103
104 colorCtx.fillStyle = gradient
105 colorCtx.fill()
106 }
107
108 colorCtx.beginPath()
109 colorCtx.arc(centerX, centerY, 15, 0, Math.PI * 2)
110 colorCtx.fillStyle = 'white'
111 colorCtx.fill()
112 colorCtx.strokeStyle = '#666'
113 colorCtx.lineWidth = 1
114 colorCtx.stroke()
115 }
116
117 // Get color from wheel position
118 function getColorFromWheel(x, y) {
119 const centerX = colorWheel.width / 2
120 const centerY = colorWheel.height / 2
121 const dx = x - centerX
122 const dy = y - centerY
123 const distance = Math.sqrt(dx * dx + dy * dy)
124 const maxRadius = Math.min(centerX, centerY) - 2
125
126 if (distance > maxRadius) return null
127 if (distance < 15) return 'white'
128 if (distance > maxRadius * 0.95) return 'black'
129
130 let angle = Math.atan2(dy, dx) * 180 / Math.PI
131 angle = (angle + 360) % 360
132
133 const normalizedDistance = (distance - 15) / (maxRadius * 0.95 - 15)
134 const saturation = Math.min(100, normalizedDistance * 100)
135 const lightness = 50 - (normalizedDistance * 20)
136
137 return `hsl(${angle}, ${saturation}%, ${lightness}%)`
138 }
139
140 // Color wheel interaction
141 colorWheel.addEventListener('click', (e) => {
142 const rect = colorWheel.getBoundingClientRect()
143 const x = e.clientX - rect.left
144 const y = e.clientY - rect.top
145 const color = getColorFromWheel(x, y)
146
147 if (color) {
148 currentColor = color
149 colorPreview.style.backgroundColor = color
150 }
151 })
152
153 // Tool switching
154 function setTool(tool) {
155 currentTool = tool
156 if (tool === 'draw') {
157 drawBtn.classList.add('active')
158 eraseBtn.classList.remove('active')
159 canvas.classList.remove('erasing')
160 } else {
161 eraseBtn.classList.add('active')
162 drawBtn.classList.remove('active')
163 canvas.classList.add('erasing')
164 }
165 }
166
167 drawBtn.addEventListener('click', () => setTool('draw'))
168 eraseBtn.addEventListener('click', () => setTool('erase'))
169
170 // Drawing functions
171 function randomChar() {
172 return chars[Math.floor(Math.random() * chars.length)]
173 }
174
175 function getFont(size = null) {
176 const baseSize = size || sizeInput.value
177 return `${baseSize}px monospace`
178 }
179
180 // Character class for animations
181 class Character {
182 constructor(x, y, char, color, size) {
183 this.x = x
184 this.y = y
185 this.char = char
186 this.color = color
187 this.size = size
188 this.originalSize = size
189 this.opacity = effects.fadeIn.checked ? 0 : 1
190 this.rotation = 0
191 this.offset = { x: 0, y: 0 }
192 this.time = Date.now()
193 this.delay = effects.typewriter.checked ? activeChars.length * 50 : 0
194 this.hue = 0
195 this.vy = 0 // Add this for matrix rain
196 }
197
198 update() {
199 const elapsed = Date.now() - this.time
200
201 // Fade in
202 if (effects.fadeIn.checked && this.opacity < 1) {
203 this.opacity = Math.min(1, (elapsed - this.delay) / 500)
204 }
205
206 // Pulse
207 if (effects.pulse.checked) {
208 this.size = this.originalSize + Math.sin(elapsed / 300) * 3
209 }
210
211 // Jitter
212 if (effects.jitter.checked) {
213 this.offset.x = (Math.random() - 0.5) * 2
214 this.offset.y = (Math.random() - 0.5) * 2
215 } else {
216 this.offset.x = 0
217 this.offset.y = 0
218 }
219
220 // Rainbow
221 if (effects.rainbow.checked) {
222 this.hue = (this.hue + 2) % 360
223 this.color = `hsl(${this.hue}, 100%, 50%)`
224 }
225
226 // Rotation
227 if (effects.rotation.checked) {
228 this.rotation += 0.05
229 }
230
231 // Wave
232 if (effects.wave.checked) {
233 this.offset.y = Math.sin((elapsed + this.x * 10) / 200) * 5
234 }
235
236 // Neon flicker
237 if (effects.neonFlicker.checked) {
238 this.opacity = Math.random() > 0.1 ? 1 : 0.3
239 }
240
241 // Spiral
242 if (effects.spiral.checked) {
243 const angle = elapsed / 500
244 const radius = elapsed / 50
245 this.offset.x = Math.cos(angle) * radius
246 this.offset.y = Math.sin(angle) * radius
247 }
248
249 // Orbit
250 if (effects.orbit.checked) {
251 const angle = elapsed / 300
252 const radius = 30
253 this.offset.x = Math.cos(angle) * radius
254 this.offset.y = Math.sin(angle) * radius
255 }
256
257 // Magnetic (attract to mouse)
258 if (effects.magnetic.checked && window.mouseX && window.mouseY) {
259 const dx = window.mouseX - this.x
260 const dy = window.mouseY - this.y
261 const distance = Math.sqrt(dx * dx + dy * dy)
262 if (distance < 200) {
263 const force = (200 - distance) / 200
264 this.offset.x += (dx / distance) * force * 2
265 this.offset.y += (dy / distance) * force * 2
266 }
267 }
268
269 // Vortex
270 if (effects.vortex.checked) {
271 const centerX = canvas.width / 2
272 const centerY = canvas.height / 2
273 const dx = this.x - centerX
274 const dy = this.y - centerY
275 const angle = Math.atan2(dy, dx) + elapsed / 1000
276 const distance = Math.sqrt(dx * dx + dy * dy)
277 this.x = centerX + Math.cos(angle) * distance
278 this.y = centerY + Math.sin(angle) * distance
279 }
280
281 // Snow fall
282 if (effects.snow.checked) {
283 this.y += 1
284 this.x += Math.sin(elapsed / 300) * 0.5
285 if (this.y > canvas.height) {
286 this.y = 0
287 }
288 }
289
290 // Fireworks
291 if (effects.fireworks.checked) {
292 if (!this.fireworkPhase) {
293 this.fireworkPhase = 'rise'
294 this.vy = -10
295 }
296 if (this.fireworkPhase === 'rise') {
297 this.y += this.vy
298 this.vy += 0.3
299 if (this.vy > 0) {
300 this.fireworkPhase = 'explode'
301 // Create explosion particles
302 for (let i = 0; i < 10; i++) {
303 particles.push(new Particle(this.x, this.y, this.color, 'explode'))
304 }
305 this.opacity = 0
306 }
307 }
308 }
309
310 // Pixel dissolve
311 if (effects.pixelDissolve.checked) {
312 const dissolveTime = elapsed - 1000
313 if (dissolveTime > 0) {
314 this.opacity = Math.max(0, 1 - dissolveTime / 1000)
315 this.offset.x = (Math.random() - 0.5) * dissolveTime / 50
316 this.offset.y = (Math.random() - 0.5) * dissolveTime / 50
317 }
318 }
319 }
320
321 draw(context) {
322 if (effects.typewriter.checked && Date.now() - this.time < this.delay) return
323
324 context.save()
325 context.globalAlpha = this.opacity
326
327 // Apply basic effects for animated characters
328 if (effects.glow.checked) {
329 context.shadowColor = this.color
330 context.shadowBlur = 10
331 context.shadowOffsetX = 0
332 context.shadowOffsetY = 0
333 }
334
335 if (effects.emboss.checked) {
336 context.shadowColor = 'rgba(0, 0, 0, 0.8)'
337 context.shadowBlur = 2
338 context.shadowOffsetX = 2
339 context.shadowOffsetY = 2
340 }
341
342 if (effects.shadow.checked) {
343 context.shadowColor = 'rgba(0, 0, 0, 0.5)'
344 context.shadowBlur = 5
345 context.shadowOffsetX = 3
346 context.shadowOffsetY = 3
347 }
348
349 if (this.rotation !== 0) {
350 context.translate(this.x + this.offset.x, this.y + this.offset.y)
351 context.rotate(this.rotation)
352 context.font = getFont(this.size)
353 context.fillStyle = this.color
354 context.textAlign = 'center'
355 context.textBaseline = 'middle'
356 context.fillText(this.char, 0, 0)
357 } else {
358 // For non-rotating text, use the same positioning as main draw
359 context.font = getFont(this.size)
360 context.fillStyle = this.color
361 context.fillText(this.char, this.x + this.offset.x, this.y + this.offset.y)
362 }
363
364 context.restore()
365 }
366 }
367
368 // Particle classes
369 class Particle {
370 constructor(x, y, color, type = 'sparkle') {
371 this.x = x
372 this.y = y
373 this.vx = (Math.random() - 0.5) * 4
374 this.vy = (Math.random() - 0.5) * 4
375 this.color = color
376 this.life = 1
377 this.type = type
378 this.size = type === 'sparkle' ? Math.random() * 3 + 1 : Math.random() * 5 + 2
379 }
380
381 update() {
382 this.x += this.vx
383 this.y += this.vy
384 this.life -= 0.02
385
386 if (this.type === 'explode') {
387 this.vy += 0.2 // gravity
388 }
389 }
390
391 draw(context) {
392 context.save()
393 context.globalAlpha = this.life
394 context.fillStyle = this.color
395
396 if (this.type === 'sparkle') {
397 context.beginPath()
398 context.arc(this.x, this.y, this.size, 0, Math.PI * 2)
399 context.fill()
400 } else {
401 context.font = '12px monospace'
402 context.fillText(['*', '+', '•'][Math.floor(Math.random() * 3)], this.x, this.y)
403 }
404
405 context.restore()
406 }
407 }
408
409 class Drip {
410 constructor(x, y, char, color, size) {
411 this.x = x
412 this.y = y
413 this.char = char
414 this.color = color
415 this.size = size
416 this.vy = 0
417 this.life = 1
418 }
419
420 update() {
421 this.vy += 0.3
422 this.y += this.vy
423 this.life -= 0.005
424 }
425
426 draw(context) {
427 context.save()
428 context.globalAlpha = this.life
429 context.font = getFont(this.size)
430 context.fillStyle = this.color
431 context.fillText(this.char, this.x, this.y)
432 context.restore()
433 }
434 }
435
436 class SmokeParticle {
437 constructor(x, y) {
438 this.x = x
439 this.y = y
440 this.vx = (Math.random() - 0.5) * 2
441 this.vy = -Math.random() * 2 - 1
442 this.size = Math.random() * 20 + 10
443 this.life = 1
444 }
445
446 update() {
447 this.x += this.vx
448 this.y += this.vy
449 this.size += 0.5
450 this.life -= 0.02
451 }
452
453 draw(context) {
454 context.save()
455 context.globalAlpha = this.life * 0.2
456 context.fillStyle = 'white'
457 context.beginPath()
458 context.arc(this.x, this.y, this.size, 0, Math.PI * 2)
459 context.fill()
460 context.restore()
461 }
462 }
463
464 // Apply basic effects
465 function applyBasicEffects() {
466 if (effects.glow.checked) {
467 ctx.shadowColor = currentColor
468 ctx.shadowBlur = 10
469 ctx.shadowOffsetX = 0
470 ctx.shadowOffsetY = 0
471 }
472
473 if (effects.emboss.checked) {
474 ctx.shadowColor = 'rgba(0, 0, 0, 0.8)'
475 ctx.shadowBlur = 2
476 ctx.shadowOffsetX = 2
477 ctx.shadowOffsetY = 2
478 }
479
480 if (effects.shadow.checked) {
481 ctx.shadowColor = 'rgba(0, 0, 0, 0.5)'
482 ctx.shadowBlur = 5
483 ctx.shadowOffsetX = 3
484 ctx.shadowOffsetY = 3
485 }
486
487 if (effects.glow.checked && effects.emboss.checked) {
488 ctx.shadowColor = currentColor
489 ctx.shadowBlur = 8
490 ctx.shadowOffsetX = 1
491 ctx.shadowOffsetY = 1
492 }
493 }
494
495 // Main draw function
496 function draw(e) {
497 if (!drawing) return
498 const rect = canvas.getBoundingClientRect()
499 const x = e.clientX - rect.left
500 const y = e.clientY - rect.top
501
502 if (currentTool === 'erase') {
503 const eraseRadius = parseInt(sizeInput.value) / 2
504 ctx.save()
505 ctx.globalCompositeOperation = 'destination-out'
506 ctx.beginPath()
507 ctx.arc(x, y, eraseRadius, 0, Math.PI * 2)
508 ctx.fill()
509 ctx.restore()
510
511 // Also remove any animated characters in the erase area
512 for (let i = activeChars.length - 1; i >= 0; i--) {
513 const char = activeChars[i]
514 const dx = char.x - x
515 const dy = char.y - y
516 const distance = Math.sqrt(dx * dx + dy * dy)
517 if (distance <= eraseRadius) {
518 activeChars.splice(i, 1)
519 }
520 }
521 } else {
522 // Get character and apply effects
523 const char = randomChar()
524 let size = parseInt(sizeInput.value)
525 let drawX = x
526 let drawY = y
527 let color = currentColor
528
529 // Size variance
530 if (effects.sizeVariance.checked) {
531 size = size * (0.5 + Math.random())
532 }
533
534 // Scatter
535 if (effects.scatter.checked) {
536 drawX += (Math.random() - 0.5) * 20
537 drawY += (Math.random() - 0.5) * 20
538 }
539
540 // Gradient effect
541 if (effects.gradient.checked) {
542 const gradient = ctx.createLinearGradient(drawX - size/2, drawY - size/2, drawX + size/2, drawY + size/2)
543 gradient.addColorStop(0, color)
544 gradient.addColorStop(1, adjustBrightness(color, -30))
545 color = gradient
546 }
547
548 // Apply basic effects
549 ctx.save()
550 applyBasicEffects()
551
552 // Draw outline if enabled
553 if (effects.outline.checked) {
554 ctx.font = getFont(size)
555 ctx.strokeStyle = adjustBrightness(currentColor, -50)
556 ctx.lineWidth = 2
557 ctx.strokeText(char, drawX, drawY)
558 }
559
560 // Draw emboss highlight
561 if (effects.emboss.checked && !effects.glow.checked) {
562 ctx.font = getFont(size)
563 ctx.fillStyle = 'rgba(255, 255, 255, 0.5)'
564 ctx.fillText(char, drawX - 1, drawY - 1)
565 }
566
567 // Draw main character (only if not using animation effects)
568 const hasAnimationEffects = effects.pulse.checked || effects.fadeIn.checked ||
569 effects.typewriter.checked || effects.jitter.checked || effects.rainbow.checked ||
570 effects.rotation.checked || effects.wave.checked || effects.neonFlicker.checked ||
571 effects.spiral.checked || effects.orbit.checked || effects.magnetic.checked ||
572 effects.vortex.checked || effects.snow.checked || effects.fireworks.checked ||
573 effects.pixelDissolve.checked
574
575 if (!hasAnimationEffects) {
576 ctx.font = getFont(size)
577 ctx.fillStyle = color
578 ctx.fillText(char, drawX, drawY)
579
580 // Double vision (only if not animated)
581 if (effects.doubleVision.checked) {
582 ctx.globalAlpha = 0.5
583 ctx.fillStyle = adjustBrightness(color, 20)
584 ctx.fillText(char, drawX + 5, drawY + 5)
585 }
586
587 // Mirror (only if not animated)
588 if (effects.mirror.checked) {
589 const mirrorX = canvas.width - drawX
590 ctx.fillStyle = color
591 ctx.fillText(char, mirrorX, drawY)
592 }
593
594 // Echo effect (only if not animated)
595 if (effects.echo.checked) {
596 for (let i = 1; i <= 3; i++) {
597 ctx.globalAlpha = 0.3 / i
598 ctx.fillText(char, drawX - i * 5, drawY - i * 5)
599 }
600 }
601 }
602
603 ctx.restore()
604
605 // Add to active chars for animation
606 if (hasAnimationEffects) {
607 activeChars.push(new Character(drawX, drawY, char, color instanceof CanvasGradient ? currentColor : color, size))
608 }
609
610 // Particle effects
611 if (effects.sparkles.checked) {
612 for (let i = 0; i < 5; i++) {
613 particles.push(new Particle(drawX + Math.random() * 20 - 10, drawY + Math.random() * 20 - 10, color instanceof CanvasGradient ? currentColor : color, 'sparkle'))
614 }
615 }
616
617 if (effects.explode.checked) {
618 for (let i = 0; i < 8; i++) {
619 particles.push(new Particle(drawX, drawY, color instanceof CanvasGradient ? currentColor : color, 'explode'))
620 }
621 }
622
623 if (effects.drip.checked) {
624 drips.push(new Drip(drawX, drawY, char, color instanceof CanvasGradient ? currentColor : color, size))
625 }
626
627 if (effects.smoke.checked) {
628 smoke.push(new SmokeParticle(drawX, drawY))
629 }
630
631 // Matrix rain
632 if (effects.matrix.checked) {
633 const matrixChar = new Character(drawX, 0, char, '#0f0', size)
634 matrixChar.vy = Math.random() * 3 + 2
635 activeChars.push(matrixChar)
636 }
637
638 // Glitch effect
639 if (effects.glitch.checked && Math.random() < 0.1) {
640 ctx.save()
641 ctx.globalCompositeOperation = 'difference'
642 ctx.fillStyle = '#ff00ff'
643 ctx.fillRect(drawX - size/2, drawY - size/2, size, size)
644 ctx.restore()
645 }
646 }
647 }
648
649 // Animation loop
650 function animate() {
651 effectsCtx.clearRect(0, 0, effectsCanvas.width, effectsCanvas.height)
652
653 // Update and draw animated characters on the effects canvas
654 activeChars.forEach((char, index) => {
655 char.update()
656
657 // Matrix rain movement
658 if (effects.matrix.checked && char.vy) {
659 char.y += char.vy
660 if (char.y > canvas.height) {
661 activeChars.splice(index, 1)
662 return
663 }
664 }
665
666 // Draw on effects canvas
667 if (char.opacity > 0) {
668 char.draw(effectsCtx)
669 }
670 })
671
672 // Update and draw particles
673 particles.forEach((particle, index) => {
674 particle.update()
675 particle.draw(effectsCtx)
676 if (particle.life <= 0) {
677 particles.splice(index, 1)
678 }
679 })
680
681 // Draw lightning between nearby characters
682 if (effects.lightning.checked && activeChars.length > 1) {
683 effectsCtx.strokeStyle = 'cyan'
684 effectsCtx.lineWidth = 1
685 effectsCtx.globalAlpha = 0.5
686
687 activeChars.forEach((char1, i) => {
688 activeChars.forEach((char2, j) => {
689 if (i < j) {
690 const dx = char2.x - char1.x
691 const dy = char2.y - char1.y
692 const distance = Math.sqrt(dx * dx + dy * dy)
693
694 if (distance < 100 && Math.random() > 0.9) {
695 effectsCtx.beginPath()
696 effectsCtx.moveTo(char1.x + char1.offset.x, char1.y + char1.offset.y)
697
698 // Create lightning path
699 const steps = 5
700 for (let k = 1; k < steps; k++) {
701 const t = k / steps
702 const x = char1.x + dx * t + (Math.random() - 0.5) * 10
703 const y = char1.y + dy * t + (Math.random() - 0.5) * 10
704 effectsCtx.lineTo(x, y)
705 }
706
707 effectsCtx.lineTo(char2.x + char2.offset.x, char2.y + char2.offset.y)
708 effectsCtx.stroke()
709 }
710 }
711 })
712 })
713 effectsCtx.globalAlpha = 1
714 }
715
716 // Update and draw drips
717 drips.forEach((drip, index) => {
718 drip.update()
719 drip.draw(effectsCtx)
720 if (drip.life <= 0 || drip.y > canvas.height) {
721 drips.splice(index, 1)
722 }
723 })
724
725 // Update and draw smoke
726 smoke.forEach((s, index) => {
727 s.update()
728 s.draw(effectsCtx)
729 if (s.life <= 0) {
730 smoke.splice(index, 1)
731 }
732 })
733
734 // Debug: show particle count
735 if (particles.length > 0 || activeChars.length > 0) {
736 effectsCtx.fillStyle = 'white'
737 effectsCtx.font = '12px monospace'
738 effectsCtx.fillText(`Particles: ${particles.length}, Chars: ${activeChars.length}`, 10, 20)
739 }
740
741 requestAnimationFrame(animate)
742 }
743
744 // Helper functions
745 function adjustBrightness(color, amount) {
746 // Simple brightness adjustment for hex colors
747 if (color.startsWith('#')) {
748 const num = parseInt(color.slice(1), 16)
749 const r = Math.max(0, Math.min(255, (num >> 16) + amount))
750 const g = Math.max(0, Math.min(255, ((num >> 8) & 0x00FF) + amount))
751 const b = Math.max(0, Math.min(255, (num & 0x0000FF) + amount))
752 return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}`
753 }
754 return color
755 }
756
757 // Mouse tracking for magnetic effect
758 window.mouseX = 0
759 window.mouseY = 0
760 canvas.addEventListener('mousemove', (e) => {
761 const rect = canvas.getBoundingClientRect()
762 window.mouseX = e.clientX - rect.left
763 window.mouseY = e.clientY - rect.top
764 })
765
766 // Mouse events
767 canvas.addEventListener('mousedown', () => (drawing = true))
768 canvas.addEventListener('mouseup', () => (drawing = false))
769 canvas.addEventListener('mouseleave', () => {
770 drawing = false
771 window.mouseX = null
772 window.mouseY = null
773 })
774 canvas.addEventListener('mousemove', draw)
775
776 // Touch support
777 canvas.addEventListener('touchstart', e => {
778 drawing = true
779 draw(e.touches[0])
780 })
781 canvas.addEventListener('touchmove', e => {
782 draw(e.touches[0])
783 e.preventDefault()
784 })
785 canvas.addEventListener('touchend', () => (drawing = false))
786
787 // Controls
788 const clearBtn = document.getElementById('clear')
789 clearBtn.onclick = () => {
790 ctx.clearRect(0, 0, canvas.width, canvas.height)
791 effectsCtx.clearRect(0, 0, effectsCanvas.width, effectsCanvas.height)
792 activeChars.length = 0
793 particles.length = 0
794 drips.length = 0
795 smoke.length = 0
796 }
797
798 const downloadBtn = document.getElementById('download')
799 downloadBtn.onclick = () => {
800 // Create temporary canvas to merge both layers
801 const tempCanvas = document.createElement('canvas')
802 tempCanvas.width = canvas.width
803 tempCanvas.height = canvas.height
804 const tempCtx = tempCanvas.getContext('2d')
805
806 // Draw background
807 tempCtx.fillStyle = '#111'
808 tempCtx.fillRect(0, 0, tempCanvas.width, tempCanvas.height)
809
810 // Draw main canvas
811 tempCtx.drawImage(canvas, 0, 0)
812
813 // Draw effects canvas
814 tempCtx.drawImage(effectsCanvas, 0, 0)
815
816 // Create download link
817 const link = document.createElement('a')
818 link.download = `ascii-art-${Date.now()}.png`
819 link.href = tempCanvas.toDataURL()
820 link.click()
821 }
822
823 const sizeInput = document.getElementById('size')
824
825 // Initialize
826 drawColorWheel()
827 colorPreview.style.backgroundColor = currentColor
828 animate()