From: Julian Fietkau Date: Sat, 3 Dec 2022 00:48:48 +0000 (+0100) Subject: Initial version X-Git-Url: https://fietkau.software/Mystify.git/commitdiff_plain?repo=Mystify;ds=inline;h=dc4e931aa2f0db32560345aa287e1a2b877cc5ec;p=Mystify;a=commitdiff_plain Initial version --- dc4e931aa2f0db32560345aa287e1a2b877cc5ec diff --git a/README.md b/README.md new file mode 100644 index 0000000..7568c14 --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +# Mystify Web Animation + +This is a snipped of TypeScript that animates bouncing polygons in the style of +the Mystify screensaver from Windows 3.1/95/98 in an SVG element. It was a +leisure programming exercise and my first contact with TypeScript. It is +published for reference and in case anyone has a use for it. + +A live version is available at . + +## License + +*Mystify Web Animation* (c) 2022 [Julian Fietkau](https://fietkau.me), +released under the MIT license. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the “Software”), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT +OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR +THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/mystify.ts b/mystify.ts new file mode 100644 index 0000000..c0b99f5 --- /dev/null +++ b/mystify.ts @@ -0,0 +1,249 @@ +/* SPDX-License-Identifier: MIT + * =========================================================================== + * Mystify Web Animation v1.0 + * + * + * Copyright (C) 2022 Julian Fietkau + * + * This script can animate SVG elements to look like the Mystify screensaver + * from early versions of MS Windows. It animates two polygons in shifting + * colors that leave behind shadows. There isn't much to it, it's just a small + * learning exercise in TypeScript. + * =========================================================================== + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and associated documentation files (the “Software”), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included + * in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS + * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT + * OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR + * THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * =========================================================================== + */ + +'use strict'; +(() => { + +// This is my first time writing any TypeScript. Feel free to copy and reuse in +// accordance with the license, but I recommend not trying to learn from this. + +// The FPS setting impacts the calculation steps, the actual rendering happens +// through requestAnimationFrame. +const fpsTarget: number = 60; +const movementSpeed: number = 0.9 / fpsTarget; +const numPolygons: number = 2; +const pointsPerPolygon: number = 4; +const trailDelay: number = 15; // Add new trail after this many milliseconds. +const trailLength: number = 12; // Number of trail segments +const mainStrokeWidth: number = 2; +// The border calculation assumes that trailStrokeWidth <= mainStrokeWidth. +const trailStrokeWidth: number = 1.5; + +interface Point { + x: number; + y: number; +} +interface Direction { + dx: number; + dy: number; +} +interface Color { + h: number; + s: number; + l: number; + // So colors can change relatively smoothly, each color holds information + // on its recent change. dh and dl are only slightly modified each update + // and then added to d and l respectively, allowing for a more contiguous + // drift through the color space. + dh: number; + dl: number; +} +interface PolygonSVGElement extends SVGSVGElement { + // All points in this drawing in their current state grouped by polygon + points?: Point[]; + // All directions in this drawing, one corresponding to each point + directions?: Direction[]; + // One color per polygon + colors?: Color[]; + // One trail per polygon, pre-formatted as an SVG path definition + trails?: string[]; +} + +// Call once for each container upon startup to perform initialization. +// Container must be a HTMLElement that contains at least one SVG element. +// The SVG must have a viewBox attribute set. +function initialize(container: HTMLElement): void { + let svg: PolygonSVGElement = container.querySelector('svg'); + while(svg.children.length > 0) { + svg.removeChild(svg.firstChild); + } + svg.setAttribute('fill', 'none'); + svg.setAttribute('shape-rendering', 'optimizeSpeed'); + + svg.points = []; + svg.directions = []; + svg.colors = []; + // Initialize trails to needed size, but empty, so we can do index-based access + // even with incomplete trails right after startup. + svg.trails = Array(numPolygons * trailLength); + + for(let polygonIndex: number = 0; polygonIndex < numPolygons; polygonIndex++) { + let trail = document.createElementNS('http://www.w3.org/2000/svg', 'g'); + trail.setAttribute('stroke-width', trailStrokeWidth.toString()); + svg.appendChild(trail); + let path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('stroke-width', mainStrokeWidth.toString()); + svg.appendChild(path); + let color: Color = { + h: Math.random(), + s: 1.0, + l: 0.3 + 0.4 * Math.random(), + dh: 0, + dl: 0, + }; + svg.colors.push(color); + for(let pointIndex: number = 0; pointIndex < pointsPerPolygon; pointIndex++) { + let point: Point = { + x: Math.random(), + y: Math.random(), + }; + svg.points.push(point); + let direction: Direction = { + dx: Math.random() - 0.5, + dy: Math.random() - 0.5, + }; + svg.directions.push(direction); + } + } +} + +// Call as often as desired to calculate updated values for the current points +// and colors. Will internally call render() through requestAnimationFrame(). +function updateMain(containers: HTMLElement[]): void { + containers.forEach((container) => { + if(container.dataset.paused == 'true') return; + let svg: PolygonSVGElement = container.querySelector('svg'); + for(let polygonIndex: number = 0; polygonIndex < numPolygons; polygonIndex++) { + let color: Color = svg.colors[polygonIndex]; + color.dh += Math.min(0.003, Math.max(-0.003, 0.0001 * (Math.random() - 0.5))); + color.h += color.dh; + color.h = (color.h < 0) ? color.h + 1 : color.h % 1; // Wrap around at 0 and 1. + color.dl += Math.min(0.003, Math.max(-0.003, 0.0001 * (Math.random() - 0.5))); + color.l += color.dl; + color.l = Math.min(0.7, Math.max(0.3, color.l)); // Clamp within 0.3 and 0.7. + } + for(let pointIndex: number = 0; pointIndex < svg.points.length; pointIndex++) { + let point: Point = svg.points[pointIndex]; + // Code duplication could be reduced here by iterating over the x and y + // properties by name, but mixed access through dot and bracket notations did + // not play nice with my build process. + point.x += svg.directions[pointIndex].dx * movementSpeed; + if(point.x < 0) { + point.x *= -1; // mirror at 0 + svg.directions[pointIndex].dx = 0.2 + Math.abs(Math.random() - 0.5); + } + if(point.x > 1) { + point.x = 1 + (point.x - 1) * -1; // mirror at 1 + svg.directions[pointIndex].dx = -0.2 - Math.abs(Math.random() - 0.5); + } + point.y += svg.directions[pointIndex].dy * movementSpeed; + if(point.y < 0) { + point.y *= -1; // mirror at 0 + svg.directions[pointIndex].dy = 0.2 + Math.abs(Math.random() - 0.5); + } + if(point.y > 1) { + point.y = 1 + (point.y - 1) * -1; // mirror at 1 + svg.directions[pointIndex].dy = -0.2 - Math.abs(Math.random() - 0.5); + } + } + requestAnimationFrame((timestamp) => render(svg)); + }); +} + +// Convert relative x and y values (used by the Point type) to absolute coordinates +// for use in SVG path definitions. Half of mainStrokeWidth is subtracted on each +// side as a margin. +function toPathCoords(x: number, y: number, width: number, height: number): string { + let fullX = x * (width - mainStrokeWidth) + 0.5 * mainStrokeWidth; + let fullY = y * (height - mainStrokeWidth) + 0.5 * mainStrokeWidth; + return fullX.toFixed(3) + ' ' + fullY.toFixed(3); +} + +// Convert an array of points to a full SVG path definition. Requires viewBox width +// and height. +function toPathDef(points: Point[], width: number, height: number): string { + let pathDef: string = 'M ' + toPathCoords(points[0].x, points[0].y, width, height); + for(let remainingPointIndex: number = 1; remainingPointIndex < points.length; remainingPointIndex++) { + let point: Point = points[remainingPointIndex]; + pathDef += 'L ' + toPathCoords(point.x, point.y, width, height); + } + pathDef +=' Z'; + return pathDef; +} + +// Calculate a new trail step for each polygon. It is recommended to call this +// less often than updateMain. +function updateTrail(containers: HTMLElement[]): void { + containers.forEach((container) => { + if(container.dataset.paused == 'true') return; + let svg: PolygonSVGElement = container.querySelector('svg'); + let width = parseInt(svg.getAttribute('viewBox').split(' ')[2], 10); + let height = parseInt(svg.getAttribute('viewBox').split(' ')[3], 10); + for(let polygonIndex: number = 0; polygonIndex < numPolygons; polygonIndex++) { + for(let trailIndex = trailLength - 1; trailIndex >= 1; trailIndex--) { + svg.trails[polygonIndex * trailLength + trailIndex] = svg.trails[polygonIndex * trailLength + trailIndex - 1]; + } + let points: Point[] = svg.points.slice(polygonIndex * pointsPerPolygon, (polygonIndex + 1) * pointsPerPolygon); + let currentPathDef: string = toPathDef(points, width, height); + svg.trails[polygonIndex * trailLength] = currentPathDef; + } + }); +} + +// Convert a Color to a CSS color specification string. +function toCSS(color: Color): string { + return 'hsl(' + color.h.toFixed(3) + 'turn ' + Math.round(100 * color.s).toString() + '% ' + Math.round(100 * color.l).toString() + '%)'; +} + +// Take the current data and manipulate the output SVG to show it. +function render(svg: PolygonSVGElement): void { + let polygons = svg.querySelectorAll(':scope > path'); + let width = parseInt(svg.getAttribute('viewBox').split(' ')[2], 10); + let height = parseInt(svg.getAttribute('viewBox').split(' ')[3], 10); + for(let polygonIndex: number = 0; polygonIndex < polygons.length; polygonIndex++) { + // Unpack NodeList to Array so we can slice(). + [...svg.children].slice(polygonIndex * 2, polygonIndex * 2 + 2).forEach((elem) => { + elem.setAttribute('stroke', toCSS(svg.colors[polygonIndex])); + }); + let points: Point[] = svg.points.slice(polygonIndex * pointsPerPolygon, (polygonIndex + 1) * pointsPerPolygon); + polygons[polygonIndex].setAttribute('d', toPathDef(points, width, height)); + let trail = polygons[polygonIndex].previousElementSibling; + for(let trailIndex = 0; trailIndex < trailLength; trailIndex++) { + if(trail.children.length < trailIndex + 1) { + let newTrail = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + newTrail.setAttribute('opacity', (1 - trailIndex / trailLength).toFixed(3)); + trail.appendChild(newTrail); + } + trail.children[trailIndex].setAttribute('d', svg.trails[polygonIndex * trailLength + trailIndex]); + } + } +} + +// This code theoretically supports multiple Mystify containers within the same document. +let containers: NodeListOf = document.querySelectorAll('.mystify'); +containers.forEach((container) => initialize(container)); + +setInterval(updateMain, 1000 / fpsTarget, containers); +setInterval(updateTrail, trailDelay, containers); + +})(); +