Mystify – commitdiff

You can use Git to clone the repository via the web URL. Download snapshot (zip)
Initial version main
authorJulian Fietkau <git@fietkau.software>
Sat, 3 Dec 2022 00:48:48 +0000 (01:48 +0100)
committerJulian Fietkau <git@fietkau.software>
Sat, 3 Dec 2022 00:48:48 +0000 (01:48 +0100)
README.md [new file with mode: 0644]
mystify.ts [new file with mode: 0644]

diff --git a/README.md b/README.md
new file mode 100644 (file)
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 <https://fietkau.plus/#mystify>.
+
+## 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 (file)
index 0000000..c0b99f5
--- /dev/null
@@ -0,0 +1,249 @@
+/* SPDX-License-Identifier: MIT
+ * ===========================================================================
+ * Mystify Web Animation v1.0
+ * <https://fietkau.plus/#mystify>
+ *
+ * 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<HTMLElement> = document.querySelectorAll<HTMLElement>('.mystify');
+containers.forEach((container) => initialize(container));
+
+setInterval(updateMain, 1000 / fpsTarget, containers);
+setInterval(updateTrail, trailDelay, containers);
+
+})();  
+