<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">

    <title>Alien.js — 3D Ripple</title>

    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
    <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto+Mono">
    <link rel="stylesheet" href="assets/css/style.css">
</head>
<body>
    <div id="root"></div>
    <script type="module">
        import { Assets, Color, DirectionalLight, Events, Group, HemisphereLight, Interface, LinearFilter, Mesh, MeshStandardMaterial, PerspectiveCamera, PlaneBufferGeometry, Scene, Stage, Texture, Uniform, Vector2, WebGLRenderer, clearTween, delayedCall, getFrustum, ticker } from '../build/alien.js';

        class Config {
            static BG_COLOR = '#0e0e0e';
            static UI_COLOR = 'rgba(255, 255, 255, 0.94)';
        }

        class GridCanvas extends Interface {
            constructor() {
                super(null, 'canvas');

                this.initCanvas();
                this.initGrid();
            }

            initCanvas() {
                this.context = this.element.getContext('2d');
            }

            initGrid() {
                this.grid = {
                    fillStyle: Config.BG_COLOR,
                    strokeStyle: Config.UI_COLOR
                };
            }

            drawGrid() {
                this.context.fillStyle = this.grid.fillStyle;
                this.context.strokeStyle = this.grid.strokeStyle;

                this.context.fillRect(0, 0, this.element.width, this.element.height);
                this.context.beginPath();

                for (let x = 0; x < this.element.width; x += this.element.width / 10) {
                    this.context.moveTo(x, 0);
                    this.context.lineTo(x, this.element.height);
                }

                for (let y = 0; y < this.element.height; y += this.element.height / 10) {
                    this.context.moveTo(0, y);
                    this.context.lineTo(this.element.width, y);
                }

                this.context.stroke();
            }

            /**
            * Public methods
            */

            resize = (width, height, dpr) => {
                this.element.width = Math.round(width * dpr);
                this.element.height = Math.round(height * dpr);
                this.context.scale(dpr, dpr);

                this.update();
            };

            update = () => {
                this.context.clearRect(0, 0, this.element.width, this.element.height);

                this.drawGrid();
            };
        }

        class Ripple extends Group {
            constructor() {
                super();

                this.canvas = {};
                this.context = {};
                this.texture = {};
                this.image = {};
                this.ripples = [];
                this.multiplier = 1;
                this.needsUpdate = false;
                this.isLoaded = false;
                this.visible = false;

                this.initCanvas();
                this.initMesh();
            }

            initCanvas() {
                this.grid = new GridCanvas();
                this.createCanvas('displace');
                this.createCanvas('ripple');
            }

            initMesh() {
                this.texture.grid = new Texture(this.grid.element);
                this.texture.grid.minFilter = LinearFilter;
                this.texture.grid.generateMipmaps = false;
                this.texture.displace = new Texture(this.canvas.displace);
                this.texture.displace.minFilter = LinearFilter;
                this.texture.displace.generateMipmaps = false;

                this.material = new MeshStandardMaterial({
                    map: this.texture.grid,
                    // map: this.texture.displace,
                    displacementMap: this.texture.displace,
                    displacementScale: 0.3,
                    transparent: true,
                    depthWrite: false,
                    depthTest: false,
                    // wireframe: true
                });

                this.mesh = new Mesh(new PlaneBufferGeometry(1, 1, 64, 64), this.material);
                this.mesh.frustumCulled = false;
                this.add(this.mesh);
            }

            async initImages() {
                const now = Date.now();
                const svgHTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><filter id="ripple-${now}"><feGaussianBlur in="SourceGraphic" stdDeviation="16"/></filter><g fill="none" filter="url(#ripple-${now})"><rect width="100%" height="100%"></rect><circle cx="512" cy="512" r="256" stroke="#fff" stroke-width="64"/></g></svg>`;

                await Promise.all([
                    this.loadImage('ripple', `data:image/svg+xml;base64,${btoa(svgHTML)}`)
                ]);

                this.isLoaded = true;

                this.resize();
            }

            async loadImage(name, path) {
                const img = await Assets.loadImage(path);
                this.image[name] = img;

                return img;
            }

            createCanvas(name) {
                this.canvas[name] = document.createElement('canvas');
                this.context[name] = this.canvas[name].getContext('2d');
            }

            resizeCanvas(name, width, height, dpr) {
                this.canvas[name].width = Math.round(width * dpr);
                this.canvas[name].height = Math.round(height * dpr);
                this.context[name].scale(dpr, dpr);
            }

            updateRipples() {
                const calc = (frame, min, max, total) => max * Math.sin(frame / total * (Math.PI / 2)) + min;
                let offset = 0;

                this.ripples.forEach((ripple, i) => {
                    if (ripple.frame++ >= 0) {
                        const frame = ripple.frame;

                        ripple.x = ripple.direction > 0 ? -0.5 + calc(frame, 0, 1, 260) : 1.5 - calc(frame, 0, 1, 260);
                        ripple.scale = 1.5 + calc(frame, 0, 12 * this.multiplier, 260);
                        ripple.alpha = frame > 200 ? 1 - calc(frame - 200, 0, 1, 60) : 1;

                        if (frame >= 260) {
                            this.ripples.splice(i - offset++, 1);

                            if (!this.ripples.length) {
                                this.needsUpdate = false;
                            }
                        }
                    }
                });
            }

            drawDisplace() {
                this.context.displace.fillRect(0, 0, this.canvas.displace.width, this.canvas.displace.height);

                this.updateRipples();

                this.ripples.forEach(ripple => {
                    const width = this.canvas.ripple.width * ripple.scale;
                    const height = this.canvas.ripple.height * ripple.scale;

                    this.context.displace.save();
                    this.context.displace.translate(this.canvas.displace.width * ripple.x, this.canvas.displace.height * ripple.y);
                    this.context.displace.transform(1, ripple.skew, 0, 1 + ripple.random, 0, 0);
                    this.context.displace.globalAlpha = ripple.alpha;
                    this.context.displace.drawImage(this.canvas.ripple, -width / 2, -height / 2, width, height);
                    this.context.displace.restore();
                });
            }

            /**
             * Public methods
             */

            resize = () => {
                this.grid.resize(Stage.width, Stage.height, Stage.dpr);
                this.resizeCanvas('displace', Stage.width, Stage.height, Stage.dpr);
                this.resizeCanvas('ripple', 256, 256, Stage.dpr);

                this.texture.grid.needsUpdate = true;

                if (this.isLoaded) {
                    // Draws from a canvas are faster
                    this.context.ripple.drawImage(this.image.ripple, 0, 0, this.canvas.ripple.width, this.canvas.ripple.height);

                    this.update();
                }

                if (Stage.width < Stage.height) {
                    this.multiplier = 0.5;
                } else {
                    this.multiplier = 1;
                }

                this.material.displacementScale = 5 * this.multiplier;

                const frustum = WorldController.getFrustum();
                this.mesh.scale.set(frustum.width, frustum.height, 1);
            };

            update = () => {
                if (!this.visible) {
                    return;
                }

                this.drawDisplace();

                this.texture.displace.needsUpdate = true;
            };

            addRipple = (direction = 1, skew = 0, frame = 0) => {
                this.ripples.push({
                    direction,
                    x: direction > 0 ? -0.5 : 1.5,
                    y: 0.5,
                    skew: 0.5 * Math.random() * skew,
                    scale: 1.5,
                    alpha: 1,
                    frame,
                    random: Math.random()
                });
            };

            wave = direction => {
                this.addRipple(direction, 1, 0);
                this.addRipple(direction, -1, 10 * Math.random() + 5);

                this.needsUpdate = true;
            };

            animateIn = () => {
                this.visible = true;
            };

            ready = () => this.initImages();
        }

        class RippleController {
            static init(ripple) {
                this.ripple = ripple;
                this.pos = new Vector2();
                this.last = new Vector2();
                this.delta = new Vector2();
                this.pos.set(Stage.width / 2, Stage.height / 2);
                this.last.copy(this.pos);

                this.addListeners();
                this.constantWaving();
            }

            static addListeners() {
                Stage.events.on(Events.VISIBILITY, this.onVisibility);
                Stage.element.addEventListener('touchstart', this.onTouchStart);
                Stage.element.addEventListener('mousedown', this.onTouchStart);
                window.addEventListener('touchmove', this.onTouchMove);
                window.addEventListener('mousemove', this.onTouchMove);
            }

            /**
             * Event handlers
             */

            static onVisibility = () => {
                if (document.hidden) {
                    clearTween(this.constant);
                } else {
                    this.constantWaving();
                }
            };

            static onTouchStart = e => {
                e.preventDefault();

                this.onTouchMove(e);
            };

            static onTouchMove = e => {
                if (!this.ripple.visible) {
                    return;
                }

                const event = {};

                if (e.changedTouches && e.changedTouches.length) {
                    event.x = e.changedTouches[0].pageX;
                    event.y = e.changedTouches[0].pageY;
                } else {
                    event.x = e.clientX;
                    event.y = e.clientY;
                }

                this.pos.copy(event);
                this.delta.subVectors(this.pos, this.last);
                this.last.copy(this.pos);

                if (!this.timeout) {
                    this.timeout = true;

                    this.ripple.wave(this.delta.x > 0 ? 1 : -1);

                    delayedCall(2500, () => {
                        this.timeout = false;
                    });
                }
            };

            /**
             * Public methods
             */

            static resize = () => {
                this.ripple.resize();
            };

            static update = () => {
                if (this.ripple.needsUpdate) {
                    this.ripple.update();
                }
            };

            static constantWaving = () => {
                this.ripple.wave(1);

                this.constant = delayedCall(1000 * Math.random() + 1000, this.constantWaving);
            };
        }

        class RenderManager {
            static init(renderer, scene, camera) {
                this.renderer = renderer;
                this.scene = scene;
                this.camera = camera;

                this.initRenderer();
            }

            static initRenderer() {
            }

            /**
             * Public methods
             */

            static resize = (width, height, dpr) => {
                this.renderer.setPixelRatio(dpr);
                this.renderer.setSize(width, height);
            };

            static update = () => {
                this.renderer.render(this.scene, this.camera);
            };
        }

        class WorldController {
            static init() {
                this.initWorld();
                this.initLights();
            }

            static initWorld() {
                this.renderer = new WebGLRenderer({
                    powerPreference: 'high-performance',
                    stencil: false
                });
                this.element = this.renderer.domElement;

                // 3D scene
                this.scene = new Scene();
                this.scene.background = new Color(Config.BG_COLOR);
                this.camera = new PerspectiveCamera(25);
                this.camera.near = 0.1;
                this.camera.far = 1000;
                this.camera.position.z = 100;

                // Global uniforms
                this.resolution = new Uniform(new Vector2());
                this.aspect = new Uniform(1);
                this.time = new Uniform(0);
                this.frame = new Uniform(0);
            }

            static initLights() {
                this.scene.add(new HemisphereLight(0x606060, 0x404040));

                const light = new DirectionalLight(0xffffff);
                light.position.set(1, 1, 1);
                this.scene.add(light);
            }

            /**
             * Public methods
             */

            static resize = (width, height, dpr) => {
                this.camera.aspect = width / height;
                this.camera.updateProjectionMatrix();

                width = Math.round(width * dpr);
                height = Math.round(height * dpr);

                this.resolution.value.set(width, height);
                this.aspect.value = width / height;
            };

            static update = (time, delta, frame) => {
                this.time.value = time;
                this.frame.value = frame;
            };

            static getFrustum = () => getFrustum(this.camera);
        }

        class App {
            static async init() {
                this.initWorld();
                this.initViews();
                this.initControllers();

                this.addListeners();
                this.onResize();

                await this.ripple.ready();
                this.ripple.animateIn();
            }

            static initWorld() {
                WorldController.init();
                Stage.add(WorldController.element);
            }

            static initViews() {
                this.ripple = new Ripple();
                WorldController.scene.add(this.ripple);
            }

            static initControllers() {
                const { renderer, scene, camera } = WorldController;

                RippleController.init(this.ripple);
                RenderManager.init(renderer, scene, camera);
            }

            static addListeners() {
                Stage.events.on(Events.RESIZE, this.onResize);
                ticker.add(this.onUpdate);
            }

            /**
             * Event handlers
             */

            static onResize = () => {
                const { width, height, dpr } = Stage;

                WorldController.resize(width, height, dpr);
                RippleController.resize();
                RenderManager.resize(width, height, dpr);
            };

            static onUpdate = (time, delta, frame) => {
                WorldController.update(time, delta, frame);
                RippleController.update();
                RenderManager.update(time, delta, frame);
            };
        }

        App.init();
    </script>
</body>
</html>
