| 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() |