diff --git a/assets/sass/pages/button-generator.scss b/assets/sass/pages/button-generator.scss index e4ad091..e5ac82d 100644 --- a/assets/sass/pages/button-generator.scss +++ b/assets/sass/pages/button-generator.scss @@ -1,4 +1,4 @@ -@import url(https://fonts.bunny.net/css?family=bebas-neue:400|lato:400,400i,700,700i|montserrat:400,400i,700,700i|open-sans:400,400i,700,700i|oswald:400,700|press-start-2p:400|roboto:400,400i,700,700i|roboto-mono:400,400i,700,700i|vt323:400); +@import url(https://fonts.bunny.net/css?family=barrio:400|bungee-spice:400|creepster:400|pixelify-sans:400,700|bebas-neue:400|lato:400,400i,700,700i|montserrat:400,400i,700,700i|open-sans:400,400i,700,700i|oswald:400,700|press-start-2p:400|roboto:400,400i,700,700i|roboto-mono:400,400i,700,700i|vt323:400); #button-generator-app { margin: 2rem 0; @@ -324,7 +324,6 @@ } } - // Custom file input styling input[type="file"] { width: 100%; diff --git a/content/resources/button-generator/index.md b/content/resources/button-generator/index.md index ccb5bee..6fde7e5 100644 --- a/content/resources/button-generator/index.md +++ b/content/resources/button-generator/index.md @@ -26,6 +26,7 @@ Big thanks to [neonaut's 88x31 archive](https://neonaut.neocities.org/cyber/88x3 - 08/01/2025 - Total refactor to be modular, added many more effects. - 09/01/2025 - Rewrote the bevel inset and outset border code so they look a lot nicer at the corners. Updates throughout so that multibyte (emoji!) characters should work. - 13/01/2025 - Added ticker scrolling and flashing text options. Added a background image selector. +- 22/01/2025 - Added a few new effects and multiple new fonts. Send font requests if you want something specific! --- diff --git a/content/updates/2026-01-22-button-generator-update.md b/content/updates/2026-01-22-button-generator-update.md new file mode 100644 index 0000000..53eabef --- /dev/null +++ b/content/updates/2026-01-22-button-generator-update.md @@ -0,0 +1,10 @@ +--- +title: "2026 01 22 Button Generator Update" +date: 2026-01-22T09:59:07Z +tags: [] +description: "" +build: + render: never +--- + +Added new effects and fonts to the 88x31 button generator diff --git a/static/js/button-generator/button-generator-core.js b/static/js/button-generator/button-generator-core.js index 2c5ba45..94a20ff 100644 --- a/static/js/button-generator/button-generator-core.js +++ b/static/js/button-generator/button-generator-core.js @@ -1,6 +1,6 @@ -import { ButtonEffect } from './effect-base.js'; +import { ButtonEffect } from "./effect-base.js"; -import { ColorQuantizer } from './color-quantizer.js'; +import { ColorQuantizer } from "./color-quantizer.js"; /** * Animation state class - passed to effects for frame-based rendering @@ -30,7 +30,7 @@ export class AnimationState { export class ButtonGenerator { constructor(canvas, config = {}) { this.canvas = canvas; - this.ctx = canvas.getContext('2d'); + this.ctx = canvas.getContext("2d"); // Animation configuration this.animConfig = { @@ -38,7 +38,7 @@ export class ButtonGenerator { duration: config.duration || 2, // seconds get totalFrames() { return this.fps * this.duration; - } + }, }; // GIF export configuration @@ -55,7 +55,7 @@ export class ButtonGenerator { border: [], text: [], text2: [], - general: [] + general: [], }; // Registered effects by ID for quick lookup @@ -69,8 +69,20 @@ export class ButtonGenerator { // Font list for preloading this.fonts = config.fonts || [ - 'Lato', 'Roboto', 'Open Sans', 'Montserrat', 'Oswald', - 'Bebas Neue', 'Roboto Mono', 'VT323', 'Press Start 2P', 'DSEG7-Classic' + "Lato", + "Roboto", + "Open Sans", + "Montserrat", + "Oswald", + "Bebas Neue", + "Roboto Mono", + "VT323", + "Press Start 2P", + "DSEG7-Classic", + "Pixelify Sans", + "Bungee Spice", + "Creepster", + "Barrio", ]; } @@ -80,11 +92,13 @@ export class ButtonGenerator { */ registerEffect(effect) { if (!(effect instanceof ButtonEffect)) { - throw new Error('Effect must extend ButtonEffect class'); + throw new Error("Effect must extend ButtonEffect class"); } if (this.effectsById.has(effect.id)) { - console.warn(`Effect with ID "${effect.id}" is already registered. Skipping.`); + console.warn( + `Effect with ID "${effect.id}" is already registered. Skipping.`, + ); return; } @@ -125,14 +139,14 @@ export class ButtonGenerator { * @returns {Promise} */ async preloadFonts() { - const fontPromises = this.fonts.flatMap(font => [ + const fontPromises = this.fonts.flatMap((font) => [ document.fonts.load(`400 12px "${font}"`), document.fonts.load(`700 12px "${font}"`), - document.fonts.load(`italic 400 12px "${font}"`) + document.fonts.load(`italic 400 12px "${font}"`), ]); await Promise.all(fontPromises); - console.log('All fonts loaded for canvas'); + console.log("All fonts loaded for canvas"); } /** @@ -144,28 +158,28 @@ export class ButtonGenerator { // Get all registered control IDs from effects const allControls = new Set(); - this.getAllEffects().forEach(effect => { - effect.controls.forEach(control => { + this.getAllEffects().forEach((effect) => { + effect.controls.forEach((control) => { allControls.add(control.id); }); }); // Read values from DOM - allControls.forEach(id => { + allControls.forEach((id) => { const element = document.getElementById(id); if (element) { - if (element.type === 'checkbox') { + if (element.type === "checkbox") { values[id] = element.checked; - } else if (element.type === 'range' || element.type === 'number') { + } else if (element.type === "range" || element.type === "number") { values[id] = parseFloat(element.value); - } else if (element.type === 'file') { + } else if (element.type === "file") { // For file inputs, return an object with file metadata and blob URL if (element.dataset.blobUrl) { values[id] = { fileName: element.dataset.fileName, blobUrl: element.dataset.blobUrl, fileSize: parseInt(element.dataset.fileSize), - fileType: element.dataset.fileType + fileType: element.dataset.fileType, }; } else { values[id] = null; @@ -200,21 +214,29 @@ export class ButtonGenerator { width: this.canvas.width, height: this.canvas.height, centerX: this.canvas.width / 2, - centerY: this.canvas.height / 2 + centerY: this.canvas.height / 2, }; // Apply effects in order: transform -> background -> background-animation -> text/text2 -> border -> general - const renderOrder = ['transform', 'background', 'background-animation', 'text', 'text2', 'border', 'general']; + const renderOrder = [ + "transform", + "background", + "background-animation", + "text", + "text2", + "border", + "general", + ]; // Save context once before transforms this.ctx.save(); - renderOrder.forEach(type => { - this.effects[type]?.forEach(effect => { + renderOrder.forEach((type) => { + this.effects[type]?.forEach((effect) => { if (effect.canApply(controlValues)) { // Transform effects should NOT be wrapped in save/restore // They need to persist for all subsequent drawing operations - if (type !== 'transform') { + if (type !== "transform") { this.ctx.save(); } @@ -224,7 +246,7 @@ export class ButtonGenerator { console.error(`Error applying effect ${effect.id}:`, error); } - if (type !== 'transform') { + if (type !== "transform") { this.ctx.restore(); } } @@ -241,9 +263,10 @@ export class ButtonGenerator { */ hasAnimationsEnabled() { const controlValues = this.getControlValues(); - return this.getAllEffects().some(effect => - effect.type !== 'background' && // Background effects can be static - effect.isEnabled(controlValues) + return this.getAllEffects().some( + (effect) => + effect.type !== "background" && // Background effects can be static + effect.isEnabled(controlValues), ); } @@ -264,7 +287,7 @@ export class ButtonGenerator { const animState = new AnimationState( frameNum, this.animConfig.totalFrames, - this.animConfig.fps + this.animConfig.fps, ); this.draw(animState); this.applyPreviewQuantization(); @@ -308,7 +331,11 @@ export class ButtonGenerator { applyPreviewQuantization() { const colorCount = this.gifConfig.colorCount; if (colorCount < 256) { - const quantizedData = ColorQuantizer.quantize(this.canvas, colorCount, 'floyd-steinberg'); + const quantizedData = ColorQuantizer.quantize( + this.canvas, + colorCount, + "floyd-steinberg", + ); this.ctx.putImageData(quantizedData, 0, 0); } } @@ -326,27 +353,34 @@ export class ButtonGenerator { return new Promise((resolve, reject) => { try { // Create temporary canvas for frame generation - const frameCanvas = document.createElement('canvas'); + const frameCanvas = document.createElement("canvas"); frameCanvas.width = this.canvas.width; frameCanvas.height = this.canvas.height; - const frameCtx = frameCanvas.getContext('2d'); + const frameCtx = frameCanvas.getContext("2d"); // Merge options with defaults - const quality = options.quality !== undefined ? options.quality : this.gifConfig.quality; - const gifDither = options.dither !== undefined ? options.dither : this.gifConfig.dither; - const colorCount = options.colorCount !== undefined ? options.colorCount : this.gifConfig.colorCount; + const quality = + options.quality !== undefined + ? options.quality + : this.gifConfig.quality; + const gifDither = + options.dither !== undefined ? options.dither : this.gifConfig.dither; + const colorCount = + options.colorCount !== undefined + ? options.colorCount + : this.gifConfig.colorCount; // Determine if we need custom quantization const useCustomQuantization = colorCount < 256; - const customDither = useCustomQuantization ? 'floyd-steinberg' : false; + const customDither = useCustomQuantization ? "floyd-steinberg" : false; // Initialize gif.js const gifOptions = { workers: 2, quality: quality, - workerScript: '/js/gif.worker.js', + workerScript: "/js/gif.worker.js", width: this.canvas.width, - height: this.canvas.height + height: this.canvas.height, }; // Add gif.js dither option if specified (only when not using custom quantization) @@ -361,18 +395,22 @@ export class ButtonGenerator { const generateFrames = async () => { for (let i = 0; i < totalFrames; i++) { - const animState = new AnimationState(i, totalFrames, this.animConfig.fps); + const animState = new AnimationState( + i, + totalFrames, + this.animConfig.fps, + ); // Draw to temporary canvas frameCtx.clearRect(0, 0, frameCanvas.width, frameCanvas.height); const tempGenerator = new ButtonGenerator(frameCanvas, { fps: this.animConfig.fps, duration: this.animConfig.duration, - fonts: this.fonts + fonts: this.fonts, }); // Copy effects - this.getAllEffects().forEach(effect => { + this.getAllEffects().forEach((effect) => { tempGenerator.registerEffect(effect); }); @@ -380,40 +418,43 @@ export class ButtonGenerator { // Apply custom color quantization if needed if (useCustomQuantization) { - const quantizedData = ColorQuantizer.quantize(frameCanvas, colorCount, customDither); + const quantizedData = ColorQuantizer.quantize( + frameCanvas, + colorCount, + customDither, + ); frameCtx.putImageData(quantizedData, 0, 0); } gif.addFrame(frameCtx, { delay: 1000 / this.animConfig.fps, - copy: true + copy: true, }); if (progressCallback) { - progressCallback(i / totalFrames, 'generating'); + progressCallback(i / totalFrames, "generating"); } // Yield to browser every 5 frames if (i % 5 === 0) { - await new Promise(resolve => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); } } }; generateFrames().then(() => { - gif.on('finished', (blob) => { + gif.on("finished", (blob) => { resolve(blob); }); - gif.on('progress', (progress) => { + gif.on("progress", (progress) => { if (progressCallback) { - progressCallback(progress, 'encoding'); + progressCallback(progress, "encoding"); } }); gif.render(); }); - } catch (error) { reject(error); } @@ -425,17 +466,17 @@ export class ButtonGenerator { */ bindControls() { const allControls = new Set(); - this.getAllEffects().forEach(effect => { - effect.controls.forEach(control => { + this.getAllEffects().forEach((effect) => { + effect.controls.forEach((control) => { allControls.add(control.id); }); }); - allControls.forEach(id => { + allControls.forEach((id) => { const element = document.getElementById(id); if (element) { - element.addEventListener('input', () => this.updatePreview()); - element.addEventListener('change', () => this.updatePreview()); + element.addEventListener("input", () => this.updatePreview()); + element.addEventListener("change", () => this.updatePreview()); } }); } diff --git a/static/js/button-generator/effects/background-plasma.js b/static/js/button-generator/effects/background-plasma.js new file mode 100644 index 0000000..f64767d --- /dev/null +++ b/static/js/button-generator/effects/background-plasma.js @@ -0,0 +1,208 @@ +import { ButtonEffect } from "../effect-base.js"; + +/** + * Plasma background effect + * Classic demoscene sine wave plasma - iconic 90s visual + */ +export class PlasmaEffect extends ButtonEffect { + constructor() { + super({ + id: "bg-plasma", + name: "Plasma", + type: "background-animation", + category: "Background Animations", + renderOrder: 5, + }); + } + + defineControls() { + return [ + { + id: "animate-plasma", + type: "checkbox", + label: "Plasma Effect", + defaultValue: false, + }, + { + id: "plasma-scale", + type: "range", + label: "Pattern Scale", + defaultValue: 1.5, + min: 0.5, + max: 3, + step: 0.1, + showWhen: "animate-plasma", + description: "Size of plasma patterns", + }, + { + id: "plasma-speed", + type: "range", + label: "Animation Speed", + defaultValue: 1, + min: 0.5, + max: 3, + step: 0.1, + showWhen: "animate-plasma", + description: "Speed of plasma movement", + }, + { + id: "plasma-color-scheme", + type: "select", + label: "Color Scheme", + defaultValue: "classic", + options: [ + { value: "classic", label: "Classic (Purple/Cyan)" }, + { value: "fire", label: "Fire (Red/Orange)" }, + { value: "ocean", label: "Ocean (Blue/Green)" }, + { value: "psychedelic", label: "Psychedelic (Rainbow)" }, + { value: "matrix", label: "Matrix (Green)" }, + ], + showWhen: "animate-plasma", + description: "Color palette for plasma", + }, + ]; + } + + isEnabled(controlValues) { + return controlValues["animate-plasma"] === true; + } + + /** + * Get color based on scheme and value + */ + getColor(value, scheme) { + // Normalize value from -4..4 to 0..1 + const normalized = (value + 4) / 8; + + switch (scheme) { + case "fire": { + // Black -> Red -> Orange -> Yellow -> White + const r = Math.min(255, normalized * 400); + const g = Math.max(0, Math.min(255, (normalized - 0.3) * 400)); + const b = Math.max(0, Math.min(255, (normalized - 0.7) * 400)); + return { r, g, b }; + } + + case "ocean": { + // Deep blue -> Cyan -> Light green + const r = Math.max(0, Math.min(100, normalized * 100)); + const g = Math.min(255, normalized * 300); + const b = Math.min(255, 100 + normalized * 155); + return { r, g, b }; + } + + case "psychedelic": { + // Full rainbow cycling + const hue = normalized * 360; + return this.hslToRgb(hue, 100, 50); + } + + case "matrix": { + // Black -> Dark green -> Bright green -> White + const intensity = normalized; + const r = Math.max(0, Math.min(255, (intensity - 0.8) * 1000)); + const g = Math.min(255, intensity * 300); + const b = Math.max(0, Math.min(255, (intensity - 0.9) * 500)); + return { r, g, b }; + } + + case "classic": + default: { + // Classic demoscene: purple/magenta/cyan + const r = Math.min(255, 128 + Math.sin(normalized * Math.PI * 2) * 127); + const g = Math.min(255, 64 + Math.sin(normalized * Math.PI * 2 + 2) * 64); + const b = Math.min(255, 128 + Math.sin(normalized * Math.PI * 2 + 4) * 127); + return { r, g, b }; + } + } + } + + /** + * Convert HSL to RGB + */ + hslToRgb(h, s, l) { + s /= 100; + l /= 100; + const c = (1 - Math.abs(2 * l - 1)) * s; + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); + const m = l - c / 2; + + let r, g, b; + if (h < 60) { + r = c; g = x; b = 0; + } else if (h < 120) { + r = x; g = c; b = 0; + } else if (h < 180) { + r = 0; g = c; b = x; + } else if (h < 240) { + r = 0; g = x; b = c; + } else if (h < 300) { + r = x; g = 0; b = c; + } else { + r = c; g = 0; b = x; + } + + return { + r: Math.round((r + m) * 255), + g: Math.round((g + m) * 255), + b: Math.round((b + m) * 255), + }; + } + + apply(context, controlValues, animState, renderData) { + if (!animState) return; + + const scale = controlValues["plasma-scale"] || 1.5; + const speed = controlValues["plasma-speed"] || 1; + const scheme = controlValues["plasma-color-scheme"] || "classic"; + + const { width, height } = renderData; + + // Perfect looping via ping-pong: animate forward first half, reverse second half + // progress goes 0->1, we convert to 0->1->0 (triangle wave) + const progress = animState.progress; + let pingPong; + if (progress < 0.5) { + // First half: 0->1 + pingPong = progress * 2; + } else { + // Second half: 1->0 + pingPong = (1 - progress) * 2; + } + + // Scale the time value for visible animation + const time = pingPong * Math.PI * 2 * speed; + + // Get image data for pixel manipulation + const imageData = context.getImageData(0, 0, width, height); + const data = imageData.data; + + // Scale factor for the plasma pattern (smaller = larger patterns) + const scaleFactor = 10 / scale; + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + // Classic plasma formula + const value = + Math.sin(x / scaleFactor + time) + + Math.sin(y / scaleFactor + time * 1.3) + + Math.sin((x + y) / scaleFactor + time * 0.7) + + Math.sin(Math.sqrt(x * x + y * y) / scaleFactor + time); + + const color = this.getColor(value, scheme); + + const idx = (y * width + x) * 4; + data[idx] = color.r; + data[idx + 1] = color.g; + data[idx + 2] = color.b; + data[idx + 3] = 255; + } + } + + context.putImageData(imageData, 0, 0); + } +} + +export function register(generator) { + generator.registerEffect(new PlasmaEffect()); +} diff --git a/static/js/button-generator/effects/background-sparkle.js b/static/js/button-generator/effects/background-sparkle.js new file mode 100644 index 0000000..fc580ee --- /dev/null +++ b/static/js/button-generator/effects/background-sparkle.js @@ -0,0 +1,168 @@ +import { ButtonEffect } from "../effect-base.js"; + +/** + * Sparkle/Twinkle background effect + * Random twinkling stars overlay - classic 88x31 button aesthetic + */ +export class SparkleEffect extends ButtonEffect { + constructor() { + super({ + id: "bg-sparkle", + name: "Sparkle", + type: "background-animation", + category: "Background Animations", + renderOrder: 15, + }); + + this.sparkles = []; + this.initialized = false; + } + + defineControls() { + return [ + { + id: "animate-sparkle", + type: "checkbox", + label: "Sparkle Effect", + defaultValue: false, + }, + { + id: "sparkle-density", + type: "range", + label: "Sparkle Count", + defaultValue: 20, + min: 5, + max: 50, + step: 1, + showWhen: "animate-sparkle", + description: "Number of sparkles", + }, + { + id: "sparkle-size", + type: "range", + label: "Max Size", + defaultValue: 3, + min: 1, + max: 5, + step: 0.5, + showWhen: "animate-sparkle", + description: "Maximum sparkle size", + }, + { + id: "sparkle-speed", + type: "range", + label: "Twinkle Speed", + defaultValue: 1.5, + min: 0.5, + max: 3, + step: 0.1, + showWhen: "animate-sparkle", + description: "How fast sparkles twinkle", + }, + { + id: "sparkle-color", + type: "color", + label: "Sparkle Color", + defaultValue: "#ffffff", + showWhen: "animate-sparkle", + description: "Color of sparkles", + }, + ]; + } + + isEnabled(controlValues) { + return controlValues["animate-sparkle"] === true; + } + + apply(context, controlValues, animState, renderData) { + if (!animState) return; + + const density = controlValues["sparkle-density"] || 20; + const maxSize = controlValues["sparkle-size"] || 3; + const speed = controlValues["sparkle-speed"] || 1.5; + const sparkleColor = controlValues["sparkle-color"] || "#ffffff"; + + // Initialize sparkles on first frame or density change + if (!this.initialized || this.sparkles.length !== density) { + this.sparkles = []; + for (let i = 0; i < density; i++) { + this.sparkles.push({ + x: Math.random() * renderData.width, + y: Math.random() * renderData.height, + phase: Math.random() * Math.PI * 2, + size: 1 + Math.random() * (maxSize - 1), + speedMult: 0.7 + Math.random() * 0.6, + }); + } + this.initialized = true; + } + + // Parse color + const hexToRgb = (hex) => { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return result + ? { + r: parseInt(result[1], 16), + g: parseInt(result[2], 16), + b: parseInt(result[3], 16), + } + : { r: 255, g: 255, b: 255 }; + }; + + const rgb = hexToRgb(sparkleColor); + + // Draw sparkles + this.sparkles.forEach((sparkle) => { + // Calculate twinkle phase - each sparkle has its own timing + const phase = animState.getPhase(speed * sparkle.speedMult) + sparkle.phase; + + // Use sin² for smooth fade in/out, with some sparkles fully off + const rawAlpha = Math.sin(phase); + const alpha = rawAlpha > 0 ? rawAlpha * rawAlpha : 0; + + if (alpha < 0.05) return; // Skip nearly invisible sparkles + + const size = sparkle.size * (0.5 + alpha * 0.5); + + // Draw 4-point star shape + context.save(); + context.translate(sparkle.x, sparkle.y); + context.fillStyle = `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`; + + // Draw diamond/star shape + context.beginPath(); + + // Horizontal points + context.moveTo(-size, 0); + context.lineTo(0, -size * 0.3); + context.lineTo(size, 0); + context.lineTo(0, size * 0.3); + context.closePath(); + context.fill(); + + // Vertical points + context.beginPath(); + context.moveTo(0, -size); + context.lineTo(size * 0.3, 0); + context.lineTo(0, size); + context.lineTo(-size * 0.3, 0); + context.closePath(); + context.fill(); + + // Center glow + const gradient = context.createRadialGradient(0, 0, 0, 0, 0, size * 0.5); + gradient.addColorStop(0, `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`); + gradient.addColorStop(1, `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0)`); + context.fillStyle = gradient; + context.beginPath(); + context.arc(0, 0, size * 0.5, 0, Math.PI * 2); + context.fill(); + + context.restore(); + }); + } +} + +export function register(generator) { + generator.registerEffect(new SparkleEffect()); +} diff --git a/static/js/button-generator/effects/emboss.js b/static/js/button-generator/effects/emboss.js new file mode 100644 index 0000000..f01ca07 --- /dev/null +++ b/static/js/button-generator/effects/emboss.js @@ -0,0 +1,245 @@ +import { ButtonEffect } from "../effect-base.js"; + +/** + * Emboss/Bevel border effect + * Classic Windows 95/98 raised button appearance + */ +export class EmbossEffect extends ButtonEffect { + constructor() { + super({ + id: "emboss", + name: "Emboss/Bevel", + type: "general", + category: "Visual Effects", + renderOrder: 95, // After everything, draws on top + }); + } + + defineControls() { + return [ + { + id: "enable-emboss", + type: "checkbox", + label: "Emboss/Bevel Effect", + defaultValue: false, + }, + { + id: "emboss-style", + type: "select", + label: "Style", + defaultValue: "raised", + options: [ + { value: "raised", label: "Raised (Outset)" }, + { value: "sunken", label: "Sunken (Inset)" }, + { value: "ridge", label: "Ridge" }, + { value: "groove", label: "Groove" }, + ], + showWhen: "enable-emboss", + description: "Type of bevel effect", + }, + { + id: "emboss-depth", + type: "range", + label: "Depth", + defaultValue: 2, + min: 1, + max: 4, + step: 1, + showWhen: "enable-emboss", + description: "Thickness of bevel in pixels", + }, + { + id: "emboss-light-color", + type: "color", + label: "Highlight Color", + defaultValue: "#ffffff", + showWhen: "enable-emboss", + description: "Color for lit edges", + }, + { + id: "emboss-shadow-color", + type: "color", + label: "Shadow Color", + defaultValue: "#000000", + showWhen: "enable-emboss", + description: "Color for shadowed edges", + }, + ]; + } + + isEnabled(controlValues) { + return controlValues["enable-emboss"] === true; + } + + /** + * Parse hex color to rgba string with alpha + */ + colorWithAlpha(hex, alpha) { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + if (result) { + const r = parseInt(result[1], 16); + const g = parseInt(result[2], 16); + const b = parseInt(result[3], 16); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; + } + return `rgba(255, 255, 255, ${alpha})`; + } + + /** + * Draw a single bevel layer + */ + drawBevel( + context, + width, + height, + offset, + lightColor, + shadowColor, + lightAlpha, + shadowAlpha, + ) { + // Top edge (light) + context.fillStyle = this.colorWithAlpha(lightColor, lightAlpha); + context.fillRect(offset, offset, width - offset * 2, 1); + + // Left edge (light) + context.fillRect(offset, offset, 1, height - offset * 2); + + // Bottom edge (shadow) + context.fillStyle = this.colorWithAlpha(shadowColor, shadowAlpha); + context.fillRect(offset, height - 1 - offset, width - offset * 2, 1); + + // Right edge (shadow) + context.fillRect(width - 1 - offset, offset, 1, height - offset * 2); + } + + apply(context, controlValues, animState, renderData) { + const style = controlValues["emboss-style"] || "raised"; + const depth = controlValues["emboss-depth"] || 2; + const lightColor = controlValues["emboss-light-color"] || "#ffffff"; + const shadowColor = controlValues["emboss-shadow-color"] || "#000000"; + + const { width, height } = renderData; + + context.save(); + + // Calculate alpha falloff for each layer (outer layers more opaque) + const getAlpha = (layer, totalLayers) => { + return 0.3 + (0.5 * (totalLayers - layer)) / totalLayers; + }; + + switch (style) { + case "raised": + // Light on top/left, shadow on bottom/right + for (let i = 0; i < depth; i++) { + const alpha = getAlpha(i, depth); + this.drawBevel( + context, + width, + height, + i, + lightColor, + shadowColor, + alpha, + alpha, + ); + } + break; + + case "sunken": + // Shadow on top/left, light on bottom/right (swap colors) + for (let i = 0; i < depth; i++) { + const alpha = getAlpha(i, depth); + this.drawBevel( + context, + width, + height, + i, + shadowColor, + lightColor, + alpha, + alpha, + ); + } + break; + + case "ridge": + // Raised outer, sunken inner + const ridgeOuter = Math.ceil(depth / 2); + const ridgeInner = Math.floor(depth / 2); + + // Outer raised bevel + for (let i = 0; i < ridgeOuter; i++) { + const alpha = getAlpha(i, ridgeOuter); + this.drawBevel( + context, + width, + height, + i, + lightColor, + shadowColor, + alpha, + alpha, + ); + } + + // Inner sunken bevel + for (let i = 0; i < ridgeInner; i++) { + const alpha = getAlpha(i, ridgeInner); + this.drawBevel( + context, + width, + height, + ridgeOuter + i, + shadowColor, + lightColor, + alpha, + alpha, + ); + } + break; + + case "groove": + // Sunken outer, raised inner (opposite of ridge) + const grooveOuter = Math.ceil(depth / 2); + const grooveInner = Math.floor(depth / 2); + + // Outer sunken bevel + for (let i = 0; i < grooveOuter; i++) { + const alpha = getAlpha(i, grooveOuter); + this.drawBevel( + context, + width, + height, + i, + shadowColor, + lightColor, + alpha, + alpha, + ); + } + + // Inner raised bevel + for (let i = 0; i < grooveInner; i++) { + const alpha = getAlpha(i, grooveInner); + this.drawBevel( + context, + width, + height, + grooveOuter + i, + lightColor, + shadowColor, + alpha, + alpha, + ); + } + break; + } + + context.restore(); + } +} + +export function register(generator) { + generator.registerEffect(new EmbossEffect()); +} diff --git a/static/js/button-generator/effects/text-bounce.js b/static/js/button-generator/effects/text-bounce.js new file mode 100644 index 0000000..703d34d --- /dev/null +++ b/static/js/button-generator/effects/text-bounce.js @@ -0,0 +1,187 @@ +import { ButtonEffect } from "../effect-base.js"; + +/** + * Bouncing text animation effect + * Characters bounce individually with staggered timing + */ +export class BounceTextEffect extends ButtonEffect { + constructor(textLineNumber = 1) { + const suffix = textLineNumber === 1 ? "" : "2"; + super({ + id: `text-bounce${suffix}`, + name: `Bouncing Text ${textLineNumber}`, + type: textLineNumber === 1 ? "text" : "text2", + category: textLineNumber === 1 ? "Text Line 1" : "Text Line 2", + renderOrder: 9, // Between spin (8) and wave (10) + textLineNumber: textLineNumber, + }); + this.textLineNumber = textLineNumber; + } + + defineControls() { + const textLineNumber = + this.textLineNumber || this.config?.textLineNumber || 1; + const suffix = textLineNumber === 1 ? "" : "2"; + return [ + { + id: `animate-text-bounce${suffix}`, + type: "checkbox", + label: "Bounce Animation", + defaultValue: false, + }, + { + id: `bounce-height${suffix}`, + type: "range", + label: "Bounce Height", + defaultValue: 5, + min: 2, + max: 15, + step: 1, + showWhen: `animate-text-bounce${suffix}`, + description: "How high characters bounce", + }, + { + id: `bounce-speed${suffix}`, + type: "range", + label: "Bounce Speed", + defaultValue: 1.5, + min: 0.5, + max: 3, + step: 0.1, + showWhen: `animate-text-bounce${suffix}`, + description: "Speed of bounce animation", + }, + { + id: `bounce-stagger${suffix}`, + type: "range", + label: "Stagger", + defaultValue: 0.15, + min: 0, + max: 0.5, + step: 0.05, + showWhen: `animate-text-bounce${suffix}`, + description: "Delay between character bounces", + }, + ]; + } + + isEnabled(controlValues) { + const suffix = this.textLineNumber === 1 ? "" : "2"; + return controlValues[`animate-text-bounce${suffix}`] === true; + } + + apply(context, controlValues, animState, renderData) { + const suffix = this.textLineNumber === 1 ? "" : "2"; + + // Check if ticker is active - if so, ticker handles rendering + const tickerActive = controlValues[`animate-text-ticker${suffix}`]; + if (tickerActive) { + return; + } + + // Check flash visibility + const flashActive = controlValues[`animate-text-flash${suffix}`]; + if (flashActive && renderData[`textFlashVisible${suffix}`] === false) { + return; + } + + const text = controlValues[`button-text${suffix}`] || ""; + if (!text || text.trim() === "") return; + if (!animState) return; + + const bounceHeight = controlValues[`bounce-height${suffix}`] || 5; + const speed = controlValues[`bounce-speed${suffix}`] || 1.5; + const stagger = controlValues[`bounce-stagger${suffix}`] || 0.15; + const fontSize = controlValues[`font-size${suffix}`] || 14; + const fontFamily = controlValues[`font-family${suffix}`] || "Arial"; + const fontWeight = controlValues[`text${suffix}-bold`] ? "bold" : "normal"; + const fontStyle = controlValues[`text${suffix}-italic`] ? "italic" : "normal"; + + context.font = `${fontStyle} ${fontWeight} ${fontSize}px "${fontFamily}"`; + context.textAlign = "center"; + context.textBaseline = "middle"; + + // Get text color + let fillStyle; + const colorType = controlValues[`text${suffix}-color-type`] || "solid"; + if (colorType === "gradient") { + const color1 = controlValues[`text${suffix}-gradient-color1`] || "#ffffff"; + const color2 = controlValues[`text${suffix}-gradient-color2`] || "#00ffff"; + const angle = (controlValues[`text${suffix}-gradient-angle`] || 90) * (Math.PI / 180); + const centerX = renderData.centerX; + const centerY = renderData.centerY; + const x1 = centerX + Math.cos(angle) * 20; + const y1 = centerY + Math.sin(angle) * 20; + const x2 = centerX - Math.cos(angle) * 20; + const y2 = centerY - Math.sin(angle) * 20; + const gradient = context.createLinearGradient(x1, y1, x2, y2); + gradient.addColorStop(0, color1); + gradient.addColorStop(1, color2); + fillStyle = gradient; + } else { + fillStyle = controlValues[`text${suffix}-color`] || "#ffffff"; + } + + // Check for rainbow text effect + const rainbowActive = controlValues[`animate-text-rainbow${suffix}`]; + + // Calculate base position + const x = controlValues[`text${suffix}-x`] || 50; + const y = controlValues[`text${suffix}-y`] || 50; + const baseX = (x / 100) * renderData.width; + const baseY = (y / 100) * renderData.height; + + // Split text into grapheme clusters (handles emojis properly) + let chars; + if (typeof Intl !== "undefined" && Intl.Segmenter) { + const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" }); + chars = Array.from(segmenter.segment(text), (s) => s.segment); + } else { + chars = [...text]; + } + + // Measure total text width for centering + const totalWidth = context.measureText(text).width; + let currentX = baseX - totalWidth / 2; + + // Draw each character with bounce + for (let i = 0; i < chars.length; i++) { + const char = chars[i]; + const charWidth = context.measureText(char).width; + const charCenterX = currentX + charWidth / 2; + + // Calculate bounce offset for this character + // Use abs(sin) for bounce motion (always goes up, sharp at bottom) + const phase = animState.getPhase(speed) + i * stagger * Math.PI * 2; + const bounceOffset = -Math.abs(Math.sin(phase)) * bounceHeight; + + context.save(); + + // Apply outline if enabled + if (controlValues[`text${suffix}-outline`]) { + context.strokeStyle = controlValues[`text${suffix}-outline-color`] || "#000000"; + context.lineWidth = 2; + context.strokeText(char, charCenterX, baseY + bounceOffset); + } + + // Handle rainbow color per character + if (rainbowActive) { + const rainbowSpeed = controlValues[`text-rainbow-speed${suffix}`] || 1; + const hue = ((animState.progress * rainbowSpeed * 360) + (i * 30)) % 360; + context.fillStyle = `hsl(${hue}, 80%, 60%)`; + } else { + context.fillStyle = fillStyle; + } + + context.fillText(char, charCenterX, baseY + bounceOffset); + context.restore(); + + currentX += charWidth; + } + } +} + +export function register(generator) { + generator.registerEffect(new BounceTextEffect(1)); + generator.registerEffect(new BounceTextEffect(2)); +} diff --git a/static/js/button-generator/effects/text-glow.js b/static/js/button-generator/effects/text-glow.js new file mode 100644 index 0000000..960318e --- /dev/null +++ b/static/js/button-generator/effects/text-glow.js @@ -0,0 +1,202 @@ +import { ButtonEffect } from "../effect-base.js"; + +/** + * Neon glow text effect + * Pulsing outer glow - cyberpunk/cool site aesthetic + */ +export class TextGlowEffect extends ButtonEffect { + constructor(textLineNumber = 1) { + const suffix = textLineNumber === 1 ? "" : "2"; + super({ + id: `text-glow${suffix}`, + name: `Glow Text ${textLineNumber}`, + type: textLineNumber === 1 ? "text" : "text2", + category: textLineNumber === 1 ? "Text Line 1" : "Text Line 2", + renderOrder: 18, // Before standard text at 20 + textLineNumber: textLineNumber, + }); + this.textLineNumber = textLineNumber; + } + + defineControls() { + const textLineNumber = + this.textLineNumber || this.config?.textLineNumber || 1; + const suffix = textLineNumber === 1 ? "" : "2"; + return [ + { + id: `animate-text-glow${suffix}`, + type: "checkbox", + label: "Neon Glow", + defaultValue: false, + }, + { + id: `glow-color${suffix}`, + type: "color", + label: "Glow Color", + defaultValue: "#00ffff", + showWhen: `animate-text-glow${suffix}`, + description: "Color of the glow effect", + }, + { + id: `glow-blur${suffix}`, + type: "range", + label: "Glow Size", + defaultValue: 8, + min: 2, + max: 20, + step: 1, + showWhen: `animate-text-glow${suffix}`, + description: "Size of the glow blur", + }, + { + id: `glow-intensity${suffix}`, + type: "range", + label: "Intensity", + defaultValue: 1, + min: 0.5, + max: 2, + step: 0.1, + showWhen: `animate-text-glow${suffix}`, + description: "Glow brightness", + }, + { + id: `glow-pulse${suffix}`, + type: "checkbox", + label: "Pulse Animation", + defaultValue: true, + showWhen: `animate-text-glow${suffix}`, + description: "Animate the glow", + }, + { + id: `glow-pulse-speed${suffix}`, + type: "range", + label: "Pulse Speed", + defaultValue: 1, + min: 0.5, + max: 3, + step: 0.1, + showWhen: `animate-text-glow${suffix}`, + description: "Speed of pulse animation", + }, + ]; + } + + isEnabled(controlValues) { + const suffix = this.textLineNumber === 1 ? "" : "2"; + return controlValues[`animate-text-glow${suffix}`] === true; + } + + apply(context, controlValues, animState, renderData) { + const suffix = this.textLineNumber === 1 ? "" : "2"; + + // Check if ticker is active - if so, ticker handles rendering + const tickerActive = controlValues[`animate-text-ticker${suffix}`]; + if (tickerActive) { + return; + } + + // Check flash visibility + const flashActive = controlValues[`animate-text-flash${suffix}`]; + if (flashActive && renderData[`textFlashVisible${suffix}`] === false) { + return; + } + + const text = controlValues[`button-text${suffix}`] || ""; + if (!text || text.trim() === "") return; + + const glowColor = controlValues[`glow-color${suffix}`] || "#00ffff"; + const baseBlur = controlValues[`glow-blur${suffix}`] || 8; + const intensity = controlValues[`glow-intensity${suffix}`] || 1; + const pulse = controlValues[`glow-pulse${suffix}`] !== false; + const pulseSpeed = controlValues[`glow-pulse-speed${suffix}`] || 1; + + // Calculate pulse multiplier + let pulseMultiplier = 1; + if (pulse && animState) { + const phase = animState.getPhase(pulseSpeed); + pulseMultiplier = 0.6 + Math.sin(phase) * 0.4; + } + + const blur = baseBlur * pulseMultiplier; + + // Get font settings + const fontSize = controlValues[`font-size${suffix}`] || 14; + const fontFamily = controlValues[`font-family${suffix}`] || "Arial"; + const fontWeight = controlValues[`text${suffix}-bold`] ? "bold" : "normal"; + const fontStyle = controlValues[`text${suffix}-italic`] ? "italic" : "normal"; + + context.font = `${fontStyle} ${fontWeight} ${fontSize}px "${fontFamily}"`; + context.textAlign = "center"; + context.textBaseline = "middle"; + + // Get text color + let textColor; + const colorType = controlValues[`text${suffix}-color-type`] || "solid"; + if (colorType === "gradient") { + textColor = controlValues[`text${suffix}-gradient-color1`] || "#ffffff"; + } else { + textColor = controlValues[`text${suffix}-color`] || "#ffffff"; + } + + // Calculate position + const x = controlValues[`text${suffix}-x`] || 50; + const y = controlValues[`text${suffix}-y`] || 50; + const posX = (x / 100) * renderData.width; + const posY = (y / 100) * renderData.height; + + context.save(); + + // Draw glow layers (multiple passes for stronger glow) + const passes = Math.ceil(intensity * 3); + for (let i = 0; i < passes; i++) { + context.shadowColor = glowColor; + context.shadowBlur = blur * (1 + i * 0.3); + context.shadowOffsetX = 0; + context.shadowOffsetY = 0; + context.fillStyle = glowColor; + context.globalAlpha = 0.3 * intensity * pulseMultiplier; + context.fillText(text, posX, posY); + } + + // Draw outline if enabled + if (controlValues[`text${suffix}-outline`]) { + context.globalAlpha = 1; + context.shadowBlur = 0; + context.strokeStyle = controlValues[`text${suffix}-outline-color`] || "#000000"; + context.lineWidth = 2; + context.strokeText(text, posX, posY); + } + + // Draw main text with subtle glow + context.globalAlpha = 1; + context.shadowColor = glowColor; + context.shadowBlur = blur * 0.5; + + // Handle gradient fill + if (colorType === "gradient") { + const color1 = controlValues[`text${suffix}-gradient-color1`] || "#ffffff"; + const color2 = controlValues[`text${suffix}-gradient-color2`] || "#00ffff"; + const angle = (controlValues[`text${suffix}-gradient-angle`] || 90) * (Math.PI / 180); + const textWidth = context.measureText(text).width; + const x1 = posX + Math.cos(angle) * textWidth * 0.5; + const y1 = posY + Math.sin(angle) * fontSize * 0.5; + const x2 = posX - Math.cos(angle) * textWidth * 0.5; + const y2 = posY - Math.sin(angle) * fontSize * 0.5; + const gradient = context.createLinearGradient(x1, y1, x2, y2); + gradient.addColorStop(0, color1); + gradient.addColorStop(1, color2); + context.fillStyle = gradient; + } else { + context.fillStyle = textColor; + } + + context.fillText(text, posX, posY); + + context.restore(); + } +} + +export function register(generator) { + generator.registerEffect(new TextGlowEffect(1)); + generator.registerEffect(new TextGlowEffect(2)); +} diff --git a/static/js/button-generator/effects/text-standard.js b/static/js/button-generator/effects/text-standard.js index c1ebf71..6ca86d1 100644 --- a/static/js/button-generator/effects/text-standard.js +++ b/static/js/button-generator/effects/text-standard.js @@ -125,6 +125,10 @@ export class StandardTextEffect extends ButtonEffect { { value: "VT323", label: "VT323" }, { value: "Press Start 2P", label: "Press Start 2P" }, { value: "DSEG7-Classic", label: "DSEG7" }, + { value: "Pixelify Sans", label: "Pixelify Sans" }, + { value: "Bungee Spice", label: "Bungee Spice" }, + { value: "Creepster", label: "Creepster" }, + { value: "Barrio", label: "Barrio" }, ], }, { @@ -151,8 +155,19 @@ export class StandardTextEffect extends ButtonEffect { const rainbowActive = controlValues[`animate-text-rainbow${suffix}`]; const spinActive = controlValues[`animate-text-spin${suffix}`]; const tickerActive = controlValues[`animate-text-ticker${suffix}`]; + const bounceActive = controlValues[`animate-text-bounce${suffix}`]; + const glowActive = controlValues[`animate-text-glow${suffix}`]; - return text && text.trim() !== "" && !waveActive && !rainbowActive && !spinActive && !tickerActive; + return ( + text && + text.trim() !== "" && + !waveActive && + !rainbowActive && + !spinActive && + !tickerActive && + !bounceActive && + !glowActive + ); } apply(context, controlValues, animState, renderData) { diff --git a/static/js/button-generator/main.js b/static/js/button-generator/main.js index 695507d..5153e1c 100644 --- a/static/js/button-generator/main.js +++ b/static/js/button-generator/main.js @@ -37,6 +37,11 @@ import * as noise from "./effects/noise.js"; import * as rotate from "./effects/rotate.js"; import * as hologram from "./effects/hologram.js"; import * as spotlight from "./effects/spotlight.js"; +import * as sparkle from "./effects/background-sparkle.js"; +import * as textGlow from "./effects/text-glow.js"; +import * as plasma from "./effects/background-plasma.js"; +import * as emboss from "./effects/emboss.js"; +import * as bounceText from "./effects/text-bounce.js"; /** * Initialize the button generator application @@ -71,7 +76,6 @@ async function setupApp() { fps: 20, duration: 2, fonts: [ - "Arial", "Lato", "Roboto", "Open Sans", @@ -82,6 +86,10 @@ async function setupApp() { "VT323", "Press Start 2P", "DSEG7-Classic", + "Pixelify Sans", + "Bungee Spice", + "Creepster", + "Barrio", ], }); @@ -115,6 +123,11 @@ async function setupApp() { rotate.register(generator); hologram.register(generator); spotlight.register(generator); + sparkle.register(generator); + textGlow.register(generator); + plasma.register(generator); + emboss.register(generator); + bounceText.register(generator); console.log(`Registered ${generator.getAllEffects().length} effects`); @@ -159,21 +172,21 @@ async function setupApp() { * Add GIF export settings controls */ function addGifExportSettings(container, generator) { - const groupDiv = document.createElement('div'); - groupDiv.className = 'control-group collapsed'; + const groupDiv = document.createElement("div"); + groupDiv.className = "control-group collapsed"; // Header - const header = document.createElement('h3'); - header.className = 'control-group-header'; - header.textContent = 'Advanced Settings'; + const header = document.createElement("h3"); + header.className = "control-group-header"; + header.textContent = "Advanced Settings"; groupDiv.appendChild(header); - const controlsDiv = document.createElement('div'); - controlsDiv.className = 'control-group-controls'; + const controlsDiv = document.createElement("div"); + controlsDiv.className = "control-group-controls"; // Color count control - const colorsWrapper = document.createElement('div'); - colorsWrapper.className = 'control-wrapper'; + const colorsWrapper = document.createElement("div"); + colorsWrapper.className = "control-wrapper"; colorsWrapper.innerHTML = `
Note: This only affects frame-by-frame settings, i.e. 8 colours would be 8 colours per frame. I am working on a solution for this.