4
\$\begingroup\$

I've created a battle screen transition effect similar to Final Fantasy games, you can see it in action here: https://gilles-leblanc.github.io/ff8-transition/

I'm interested in code quality, performance, optimizations and general JavaScript usage.

One thing in particular is I declared the color function inside the colorSwoosh function to make it private to that function as a form of encapsulation, I'm unsure about whether this is a good thing.

const SideEnum = Object.freeze({"left": 1, "right": -1});
const Pass = Object.freeze({"first": 1, "second": 2});

let canvas;
let context;
let imageData;
const numberOfIndicesPerPixel = 4;
let xProgress = [];
const onColor = 255;
const offColor = 0;
let height, width, segmentLength, sideMultiplier, initialX, lineHeight;
let numberOfFramesRan;

const config = {
  color: onColor,
  currentPass: Pass.first,
  direction: SideEnum.left,  
  frameLimitFactor: 1,        
  horizontalSegments: 10,
  initSpread: 0.2,
  lineHeight: 4,
}

var initTransition = function() {
  xProgress = [];
  const spread = width * config.initSpread;

  for (let y = 0; y < height; y += config.lineHeight) {      
    let numberOfPixelsToInit = Math.floor(Math.random() * Math.floor(spread));

    for (let z = 0; z < config.lineHeight; z++) {
      xProgress.push(numberOfPixelsToInit);    

      for (let x = 0; x <= numberOfPixelsToInit; x++) {
        imageData.data[(y + z) * width + (initialX + x * sideMultiplier)] = config.color;
      }    
    }    
  }

  context.putImageData(imageData, 0, 0);
}

var colorSwoosh = function(timestamp) {  
  function color(passMultiplier) {
    const calcValueToAddFunc = function(x, segmentLength) {
      return config.horizontalSegments - Math.floor(x / segmentLength);
    }

    for (let y = 0; y < height; y++) {      
      for (let x = xProgress[y]; x <= width; x++) {      
        const valueToAdd = calcValueToAddFunc(x, segmentLength + xProgress[y]);
        imageData.data[y * width + (initialX + x * sideMultiplier)] += valueToAdd * passMultiplier;
      }
    }

    context.putImageData(imageData, 0, 0);
  }

  if (config.frameLimitFactor === 0 || Math.floor(timestamp % config.frameLimitFactor) === 0) {
    color(config.currentPass === Pass.first ? 1 : -1);    
  }   

  const lastValueToCheck = config.direction === SideEnum.right ? 1 : imageData.data.length - numberOfIndicesPerPixel;
  numberOfFramesRan++;

  if (config.currentPass === Pass.first) {
    if (numberOfFramesRan < 100 && imageData.data[lastValueToCheck] < config.color) {
      window.requestAnimationFrame(colorSwoosh); 
    } else {
      config.color = offColor;
      config.currentPass = Pass.second;
      config.frameLimitFactor = 1;
      initTransition();
      window.requestAnimationFrame(colorSwoosh);

      setTimeout(() => {
        document.getElementById('start-button').disabled = false;
      }, 2000);
    }  
  } else {
    if (imageData.data[lastValueToCheck] > config.color) {
      window.requestAnimationFrame(colorSwoosh);
    }
  }
};

window.addEventListener("load", function() {
  canvas = document.getElementById('main-canvas');
  startButton = document.getElementById('start-button');

  const canvasWidth = canvas.getBoundingClientRect().width;
  const canvasHeight = canvas.getBoundingClientRect().height;
  canvas.setAttribute("width", canvasWidth);
  canvas.setAttribute("height", canvasHeight);

  context = canvas.getContext('2d', { alpha: false });
  context.fillStyle = "black";
  context.fillRect(0, 0, canvasWidth, canvasHeight);

  startButton.onclick = function() {
    numberOfFramesRan = 0;
    startButton.disabled = true;
    startButton.blur();
    config.color = onColor;   
    config.currentPass = Pass.first;

    if (Math.floor(Math.random() * 2) === 0) {
      config.direction = SideEnum.right;
    }

    config.frameLimitFactor = +document.getElementById('frameSkip').value;
    config.horizontalSegments = +document.getElementById('segmentation').value;
    config.initSpread = +document.getElementById('initSpread').value;
    config.lineHeight = +document.getElementById('lineHeight').value;

    if (document.documentElement.clientWidth <= 800) {
      config.lineHeight = 2;
    }

    context.drawImage(document.getElementById('ff8'), 0, 0, canvasWidth, canvasHeight);

    setTimeout(() => {      
      imageData = context.getImageData(0, 0, canvasWidth, canvasHeight);

      width = imageData.width * numberOfIndicesPerPixel;  
      height = imageData.height;
      segmentLength = width / config.horizontalSegments;
      sideMultiplier = config.direction === SideEnum.left ? 1 : -1;
      initialX = config.direction === SideEnum.left ? 0 : width - 1;  

      initTransition();
      window.requestAnimationFrame(colorSwoosh);
    }, 1000);    
  };
});
\$\endgroup\$
2
\$\begingroup\$

Code and style

  • Many variables you have declared can be constants const rather than let.
  • Element properties defined in the DOM do not need to be set via setAttribute eg canvas.setAttribute("width", canvasWidth); can be canvas.width = canvasWidth
  • window is the global this. You do not need to specify it. eg window.requestAnimationFrame can be requestAnimationFrame, window.addEventListener( as addEventListener
  • Declare functions using statements (a function declaration) rather than expressions. eg var funcName = function() { is better as function funcName() { because function statements seclarations are hoisted, while function expressions are not.
  • Try to avoid for(let as this pattern has some additional memory and assignment overhead, it also has some very unusual behavior that can catch the unwary. eg in the function initTransition the 3 loop counters x,y,z are better declared as function scoped var
  • Try to keep variable names short and concise. They only exist within the scope they are declared in and as such gain much of their semantic meaning from that scope. eg numberOfPixelsToInit is a sentence and pixelLen, pixels, or just size would fit the role.
  • Don't reassign variables if you don't need to. Eg you declare and define let xProgress = []; and the first time you use it you reassign an array to it in initTransition. In this case rather than create a new array just empty the array with xProgress.length = 0 and then you can define it as a const xProgress
  • There is no need to do the double floor. Eg Math.floor(Math.random() * Math.floor(spread)); can be Math.floor(Math.random() * spread); and will produce the same result. You can use the shorter form if the value is positive 32bit signed integer Math.random() * spread | 0 bitwise | (OR) 0 to floor. All bitwise operations will convert the Number to a signed 32bit integer.
  • Use aliases to reduce overhead of compound property lookups. eg const lineHeight = config.lineHeight; in function initTransition so you need not have the lookup overhead in the loops
  • Variables SideEnum and Pass should start with lowercase. PascalCase naming is reserved for functions called with the new token
  • Always use addEventListener to add event, adding events via the named event should be avoided. eg startButton.onclick = function() { should be startButton.addEventListener("click", ....

There are more issues however your use of getImageData and setImageDate is a major point of concern so I have spent the time creating an example rather than stepping through your code line by line.

Question

"One thing in particular is I declared the color function inside the colorSwoosh function to make it private to that function as a form of encapsulation, I'm unsure about whether this is a good thing."

Neither good nor bad.

Modern JS has modules that will encapsulate the content to the modules scope so if you used a module color as a function would be fine in modules main scope.

If you don't use modules use IIF pattern and to encapsulate. IIF is an immediately invoked function. The function is created (function(){})() and the brackets at the end call it. The function will close over and encapsulate the content. You can return an interface if needed.

IIF

const myFX = (() => {

    // all the code vars and functions within the IIF scope
    const myPrivateConst = "foo";
    var myPrivateVar;
    function myPrivateFunction() {}



    // Define an interface if you need to access outside this scope
    const API = {
        someStartFunction() { /* do stuff */ }, 
        someOtherStuff() { /* do stuff */ }, 
        stopStuff() { /* do stuff */ }, 

    };
    return API

})();

GPUs do pixels, not CPUs

setImageData and getImageData are frame rate killers, if you use them to animate you are doing it wrong.

Using the GPU via canvas 2D API

The example below attempts to mimic your wipe animation without using image data and CPU to process pixels. Rather it uses the GPU (via 2D API) to manipulate pixels and lets the CPU handle timing and setting up.

I could not work out (too many options) just what you wanted it to look like so the example is very basic and just wipes right to left.

It uses a mask image (canvas that is not displayed) that on the first swipe reduces pixel alpha to show the canvas background (white) and on the chasing swipe refills the pixels to the final swipe color (black). Will resize to fit page once media has loaded. Resize will reset animations. Click to restart wipe FX. If media can not be found animation will not start.

There are comments where I think you need them. I have used your image, I hope that is OK and you have rights to use?

Example

/*
   Notes
   Display canvas alpha setting must be true as the canvas background will be the first wipe colour (white in this example)
   Frame rate is assumed constant at 60 fps
   I am assuming you have usage rights and I am using this image under assumption of fair use in response
    to your question https://codereview.stackexchange.com/questions/226919/screen-transition-effect-like-in-final-fantasy-games
   
*/

requestAnimationFrame(mainLoop); // request first frame. This starts the main animation loop. will not reader till ready
var update = true;     // When true render content 
var ready = false;     // When content loaded and ready to animate
const FRAME_RATE = 60; // Per second. This does not set frame rate

// settings for the alpha gradient used in the FX
const GRAD_RES = 1 / 40; // resolution of grad curve, no smaller than 1/GRAD_WIDTH
const GRAD_POW = 4.2;    // Ease power of grad curve
const GRAD_WIDTH = 256;  // 256 to match max alpha steps

const config = {
    imgURL: "https://gilles-leblanc.github.io/ff8-transition/ff8.jpg",
    image: new Image(),
    width: innerWidth,
    height: innerHeight,
    spread: 0.6,      // fraction of display size. Max spread amount
    spreadMin: 0.2,   // fraction of display size. Min spread amount
    lineHeight: 4,    // in pixels
    wipeBOffset: 0.5, // distance behind first swipe of second swipe in fractions of display width * 2
                      // if wipeBOffset > 1 then second swipe will not happen
                      // MUST be greater than 0 
    wipeStrength: 0.1,// alpha setting of wipe FX
    wipeTime: 1,      // in seconds. MUST!!! be greater than 1/60 second
    wipeEase: 2,      // strength of wipe ease. if 1 no ease,0 < val < 1 ease out, 1 < val ease in
    waitTime: 0.5,    // in seconds, time to wait after FX before displaying info
    font: "32px Arial Black", // font to render info
    infoColor: "white",       // Color to render info. MUST BE VALID css color
    info: "Click to repeat!", // text displayed after wipe complete
    wipeAColor: "#FFF",       // WipeA colour  MUST be valid CSS color
    wipeBColor: "0,0,0,",     // WipeB colour  R,G,B parts of CSS color format rgba(R,G,B,A)
};
config.image.src = config.imgURL;
config.image.addEventListener("load", setup);

const animation = {
    wipeA: { posX: 0,},
    wipeB: { posX: 0,},
    waitForInfo: 0,
    active: false,
    speed: 0, 
    reset() {
        animation.waitForInfo = config.waitTime * FRAME_RATE | 0;
        animation.active = true;
        animation.wipeA.posX = 1;
        animation.wipeB.posX = 1 + config.wipeBOffset;
        animation.speed = (config.width / (config.wipeTime * FRAME_RATE)) / config.width;
        config.display.ctx.drawImage(config.image, 0, 0, config.width, config.height);
    },
    render() {
        const ctx = config.display.ctx;
        const mask = config.mask;
        animation.wipeA.posX -= animation.speed;
        animation.wipeB.posX -= animation.speed;
        var x = calcEasePos(animation.wipeA.posX);
        animation.active = false;
        ctx.globalAlpha = config.wipeStrength;
        if (x < config.width && x > -config.width) {
            ctx.globalCompositeOperation = "destination-out";
            ctx.drawImage(mask, x, 0);
            ctx.globalCompositeOperation = "source-over"; 
            animation.active = true;
        }
        x = calcEasePos(animation.wipeB.posX);;
        if (x <= config.width && x > -config.width * config.spread) {
            ctx.drawImage(mask,x,0);
            // fill rect that is trailing edge of this swipe
            ctx.fillStyle = `rgba(${config.wipeBColor}1)`;
            ctx.fillRect(x + config.width,0,ctx.canvas.width, ctx.canvas.height);            
            animation.active = true;
        }
        ctx.globalAlpha = 1;
    },
        
}
function calcEasePos(unitPos) {
    return  unitPos > 1 ? config.width + 1 :
       unitPos < 0 ? -config.width : unitPos ** config.wipeEase * config.width * 2 - config.width;
}

function createCanvas(width, height) {
    return Object.assign(document.createElement("canvas"), {width, height});
}
function createGradient(ctx) {
    var i;
    const grad = ctx.createLinearGradient(0, 0, GRAD_WIDTH, 0);
    grad.addColorStop(0, `rgba(${config.wipeBColor}0)`); // from alpha 0 on the left
    for (i = GRAD_RES; i < 1; i += GRAD_RES) {  // Create logarithmic curve on gradient       
        grad.addColorStop(i, `rgba(${config.wipeBColor}${i ** GRAD_POW})`); 
    }
    grad.addColorStop(1, `rgba(${config.wipeBColor}1)`); // to alpha 1 on the right
    return grad;
}
function drawFinalfade() { // after wipeB complete this ensures that canvas color ends up matching desired color
    const ctx = config.display.ctx;
    ctx.globalAlpha = config.wipeStrength;
    ctx.fillStyle = `rgba(${config.wipeBColor}1)`;
    ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
    ctx.globalAlpha = 1;
}
function displayInfo() {
    const ctx = config.display.ctx;
    ctx.font = config.font;
    ctx.textAlign = "center";
    ctx.textbaseline = "middle";
    ctx.fillStyle = config.infoColor;
    ctx.fillText(config.info , config.width / 2, config.height / 2);
}
function createMask() { 
    var y;
    const can = createCanvas(config.width, config.height), ctx = can.getContext("2d");
    const spread = config.width * (config.spread - config.spreadMin), lineH = config.lineHeight;
    const spreadMin = config.width * config.spreadMin;
    const spreadRange = config.width * config.spread;
    const grad = createGradient(ctx);
    ctx.fillStyle = `rgba(${config.wipeBColor}1)`; // filling right side of mask
    ctx.fillRect(spreadRange, 0, config.width - spreadRange  | 0, config.height);
    
    // render each row scaling (via setTransform) the gradient to fit the random spread
    ctx.fillStyle = grad;
    for (y = 0; y < config.height; y += lineH) {
        const size = Math.random() * spread  + spreadMin | 0
        const scale = size / GRAD_WIDTH;
        ctx.setTransform(scale, 0, 0, 1, 0, y);  // only scale x axis 
        ctx.fillRect(0, 0, spreadRange * GRAD_WIDTH / scale, lineH);
    }
    ctx.setTransform(1, 0, 0, 1, 0, 0); // restore default transform
    return can;
}
function createDisplay() {  // Main render canvas added to document.body
    const can = createCanvas(config.width, config.height);    
    can.style.backgroundColor = config.wipeAColor;
    const ctx = can.ctx = can.getContext("2d", {alpha : true});
    ctx.drawImage(config.image, 0, 0, can.width, can.height);
    document.body.appendChild(can);  // There should be a CSS rule for canvas element
    can.addEventListener("click",() => {
        if (!animation.active) {
            animation.reset();
            update = true;
        }
    });
    return can;
}
function setup() { // call once only
    config.display = createDisplay();
    config.mask = createMask();
    animation.reset();
    ready = update = true;
    setup = undefined; // This will force an exception if setup is called a second time
}
    
function mainLoop() {
    if (ready) { // if ready check for resize
        if (innerWidth !== config.width || innerHeight !== config.height) {
            config.display.width = config.width = innerWidth;
            config.display.height = config.height = innerHeight;
            config.mask = createMask();
            animation.reset();
            ready = update = true;
        }
    }
        

    if (ready && update) { // Avoid unneeded rendering
        if (animation.active) {
            animation.render();
            update = true; 
        } else {
            if (animation.waitForInfo) {
                drawFinalfade();
                animation.waitForInfo --;
                update = true; 
            } else {
                update = false; // stops rendering
                displayInfo();
            }
        }
    }
    requestAnimationFrame(mainLoop);
}
canvas {
  position: absolute;
  left: 0px;
  top: 0px;
}

\$\endgroup\$
  • \$\begingroup\$ I was hoping the use of a single image was considered fair use. Thanks for the thorough answer. \$\endgroup\$ – Gilles Aug 29 at 1:19
  • \$\begingroup\$ About: declare functions using statements rather than expressions [...] because function statements are hoisted, while function expressions are not. Isn't that reversed? developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/… Function declaration are hoisted and not function expressions . javascriptweblog.wordpress.com/2010/07/06/… \$\endgroup\$ – Gilles Aug 29 at 3:07
  • \$\begingroup\$ @Gilles ??? As in answer Function expressions are not hoisted. Function statement and function declarations are the same thing. I gave example of each in the answer \$\endgroup\$ – Blindman67 Aug 29 at 5:34
  • \$\begingroup\$ @Gilles I just re read my answer and it was full of typos and bad punctuation. I have cleaned it up a little. Sorry about that, I must have been more worn out than I thought. \$\endgroup\$ – Blindman67 Aug 29 at 18:24

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.