<p>Double click to split. <a id="keyboardUp" href="#">Increase</a> / <a id="keyboardDown" href="#">decrease</a> size or <a id="keyboardLeft" href="#">Previous</a> / <a id="keyboardRight" href="#">Next</a> skin.</p>
<canvas id='world'></canvas>
body {
    background-color: #333333;
    padding: 0;
    margin: 0;
    overflow: hidden;
}
p {
    position: absolute;
    z-index: 99;
    color: #cccccc;
    margin: 2px;
    padding: 0;
    font-family: Arial;
    font-size: 10px;
}
a { 
    color: #f4f4f4;
    font-weight: bold;
}
/**
  * https://lab.hakim.se/blob/03/
  */
BlobWorld = new function() {
  
  var SCREEN_WIDTH = window.innerWidth;
  var SCREEN_HEIGHT = window.innerHeight;
  
  var canvas;
  var context;
  var blobs = [];
  
  var dragBlob;
  
  var screenX = window.screenX;
  var screenY = window.screenY;
  
  var mouseX = (window.innerWidth - SCREEN_WIDTH);
  var mouseY = (window.innerHeight - SCREEN_HEIGHT);
  var mouseIsDown = false;
  var mouseDownOffset = { x: 0, y: 0 };
  
  // The bounds of the world
  var worldRect = { x: 0, y: 0, width: 0, height: 0 };
  
  // The world gravity, applied to all blobs
  var gravity = { x: 0, y: 1.2 };
  
  // A pair of blobs that should be merged
  var mergeQueue = { blobA: -1, blobB: -1 };
  
  var skinIndex = 0;
  var skins = [
       { fillStyle: 'rgba(0,200,250,1.0)', strokeStyle: '#ffffff', lineWidth: 5, debug: false },
       { fillStyle: '', strokeStyle: '', lineWidth: 0, debug: true },
     { fillStyle: 'rgba(0,0,0,0.1)', strokeStyle: 'rgba(255,255,255,1.0)', lineWidth: 6, debug: false },
       { fillStyle: 'rgba(0,230,110,1.0)', strokeStyle: 'rgba(0,0,0,1.0)', lineWidth: 2, debug: false },
       { fillStyle: 'rgba(255,255,0,1.0)', strokeStyle: 'rgba(0,0,0,1.0)', lineWidth: 4, debug: false },
       { fillStyle: 'rgba(255,255,255,1.0)', strokeStyle: 'rgba(0,0,0,1.0)', lineWidth: 4, debug: false }
  ];
  
  this.init = function() {
    
    canvas = document.getElementById( 'world' );
    
    if (canvas && canvas.getContext) {
      context = canvas.getContext('2d');
      
      // Register event listeners
      window.addEventListener('mousemove', documentMouseMoveHandler, false);
      canvas.addEventListener('mousedown', documentMouseDownHandler, false);
      canvas.addEventListener('dblclick', documentDoubleClickHandler, false);
      window.addEventListener('mouseup', documentMouseUpHandler, false);
      document.addEventListener('touchstart', documentTouchStartHandler, false);
      document.addEventListener('touchmove', documentTouchMoveHandler, false);
      document.addEventListener('touchend', documentTouchEndHandler, false);
      document.addEventListener('keydown', documentKeyDownHandler, false);
      window.addEventListener('resize', windowResizeHandler, false);
      
      document.getElementById( 'keyboardUp' ).addEventListener('click', keyboardUpHandler, false);
      document.getElementById( 'keyboardDown' ).addEventListener('click', keyboardDownHandler, false);
      document.getElementById( 'keyboardLeft' ).addEventListener('click', keyboardLeftHandler, false);
      document.getElementById( 'keyboardRight' ).addEventListener('click', keyboardRightHandler, false);
      
      createBlob( { x: SCREEN_WIDTH*0.5, y: SCREEN_HEIGHT*0.1 } );
      
      windowResizeHandler();
      
      setInterval( loop, 1000 / 60 );
    }
  };
  
  function createBlob( position ) {
    var blob = new Blob();
    
    blob.position.x = position.x;
    blob.position.y = position.y;
    
    blob.generateNodes();
    
    blobs.push( blob );
  }
  
  function splitBlob( blob ) {
    if( blob.quality > 8 ) {
      blobs.push( blob.split() );
    }
  }
  
  function mergeBlobs( blobA, blobB ) {
    var t = getTime();
    
    if( !blobs[blobA] || !blobs[blobB] ) {
      return;
    }
    
    if( t - blobs[blobA].lastSplitTime > 500 && t - blobs[blobB].lastSplitTime > 500 ) {
      // Merge blobB with blobA
      blobs[blobA].merge( blobs[blobB] );
      
      // Remove blobB since blobA will take over its body
      blobs.splice( blobB, 1 );
    }
  }

  function documentMouseMoveHandler(event) {
    mouseX = event.clientX - (window.innerWidth - SCREEN_WIDTH) * .5;
    mouseY = event.clientY - (window.innerHeight - SCREEN_HEIGHT) * .5;
  }
  
  function documentMouseDownHandler(event) {
    event.preventDefault();
    
    mouseIsDown = true;
    
    dragBlob = blobs[ findClosestBody( blobs, { x: mouseX, y: mouseY } ) ];
    var closestNodeIndex = findClosestBody( dragBlob.nodes, { x: mouseX, y: mouseY } );
    dragBlob.dragNodeIndex = closestNodeIndex;
    
    mouseDownOffset.y = 100;
  }
  
  function documentMouseUpHandler(event) {
    mouseIsDown = false;
    
    if( dragBlob ) {
      dragBlob.dragNodeIndex = -1;
      dragBlob = null;
    }
  }
  
  function documentTouchStartHandler(event) {
    if(event.touches.length == 1) {
      event.preventDefault();
      
      mouseIsDown = true;
      
      mouseX = event.touches[0].pageX - (window.innerWidth - SCREEN_WIDTH) * .5;
      mouseY = event.touches[0].pageY - (window.innerHeight - SCREEN_HEIGHT) * .5;
      
      dragBlob = blobs[ findClosestBody( blobs, { x: mouseX, y: mouseY } ) ];
      var closestNodeIndex = findClosestBody( dragBlob.nodes, { x: mouseX, y: mouseY } );
      dragBlob.dragNodeIndex = closestNodeIndex;
      
      mouseDownOffset.y = 100;
    }
  }
  
  function documentTouchMoveHandler(event) {
    if(event.touches.length == 1) {
      event.preventDefault();

      mouseX = event.touches[0].pageX - (window.innerWidth - SCREEN_WIDTH) * .5;
      mouseY = event.touches[0].pageY - (window.innerHeight - SCREEN_HEIGHT) * .5;
    }
  }
  
  function documentTouchEndHandler(event) {
    mouseIsDown = false;
    
    if( dragBlob ) {
      dragBlob.dragNodeIndex = -1;
      dragBlob = null;
    }
  }
  
  function documentDoubleClickHandler(event) {
    var mouse = { x: mouseX, y: mouseY };
    
    var blob = blobs[findClosestBody( blobs, mouse )];
    
    if( distanceBetween( blob.position, mouse ) < blob.radius + 30 ) {
      splitBlob( blob );
    }
  }
  
  function documentKeyDownHandler(event) {
    switch( event.keyCode ) {
      case 40:
        changeBlobRadius( -10 );
        event.preventDefault();
        break;
      case 38:
        changeBlobRadius( 10 );
        event.preventDefault();
        break;
      case 37:
        changeSkin( -1 );
        event.preventDefault();
        break;
      case 39:
        changeSkin( 1 );
        event.preventDefault();
        break;
    }
  }
  
  function keyboardUpHandler(event) {
    event.preventDefault();
    changeBlobRadius( 20 );
    }
  
  function keyboardDownHandler(event) {
    event.preventDefault();
    changeBlobRadius( -20 );
  }
  
  function keyboardLeftHandler(event) {
    event.preventDefault();
    changeSkin( -1 );
  }
  
  function keyboardRightHandler(event) {
    event.preventDefault();
    changeSkin( 1 );
  }
  
  function changeSkin( offset ) {
    skinIndex += offset;
    skinIndex = skinIndex < 0 ? skins.length-1 : skinIndex;
    skinIndex = skinIndex > skins.length-1 ? 0 : skinIndex;
  }
  
  function changeBlobRadius( offset ) {
    for( var i = 0, len = blobs.length; i < len; i++ ) {
      blob = blobs[i];
      
      var oldRadius = blob.radius;
      
      blob.radius += offset;
      blob.radius = Math.max( 40, Math.min( blob.radius, 280 ) );
      
      if( blob.radius != oldRadius ) {
        blob.updateNormals();
      }
    }
  }
  
  function findClosestBody( bodies, position ) {
    var closestDistance = 9999;
    var currentDistance = 9999;
    var closestIndex = -1;
    
    for( var i = 0, len = bodies.length; i < len; i++ ) {
      var body = bodies[i];
      
      currentDistance = distanceBetween( body.position, { x: position.x, y: position.y } );
      
      if( currentDistance < closestDistance ) {
        closestDistance = currentDistance;
        closestIndex = i;
      }
    }
    
    return closestIndex;
  }
  
  function windowResizeHandler() {
    SCREEN_WIDTH = window.innerWidth;
    SCREEN_HEIGHT = window.innerHeight;
    
    canvas.width = SCREEN_WIDTH;
    canvas.height = SCREEN_HEIGHT;
    
    worldRect.x = 3;
    worldRect.y = 3;
    worldRect.width = SCREEN_WIDTH-6;
    worldRect.height = SCREEN_HEIGHT-6;
  }

  function loop() {
    
    var skin = skins[skinIndex];
    
    // The area around the dirty region to include in the clear
    var dirtySpread = 80;
    
    var u1, u2, ulen, blob;
    
    // Clear the dirty rects of all blobs
    for( u1 = 0, ulen = blobs.length; u1 < ulen; u1++ ) {
      blob = blobs[u1];
      
      // Clear all pixels in the dirty region
      context.clearRect(blob.dirtyRegion.left-dirtySpread,blob.dirtyRegion.top-dirtySpread,blob.dirtyRegion.right-blob.dirtyRegion.left+(dirtySpread*2),blob.dirtyRegion.bottom-blob.dirtyRegion.top+(dirtySpread*2));
      
      // Reset the dirty region so that it can be expanded anew
      blob.dirtyRegion = { left: worldRect.x + worldRect.width, top: worldRect.y + worldRect.height, right: 0, bottom: 0 };
    }
    
    // If there is a merge queued, solve it now
    if( mergeQueue.blobA != -1 && mergeQueue.blobB != -1 ) {
      mergeBlobs( mergeQueue.blobA, mergeQueue.blobB );
      
      mergeQueue.blobA = -1;
      mergeQueue.blobB = -1;
    }
    
    // If the mouse is down, start adding the velocity needed to move towards the mouse position
    if( dragBlob ) {
      dragBlob.velocity.x += ( ( mouseX + mouseDownOffset.x ) - dragBlob.position.x ) * 0.01;
      dragBlob.velocity.y += ( ( mouseY + mouseDownOffset.y ) - dragBlob.position.y ) * 0.01;
    }
    
    for( u1 = 0, ulen = blobs.length; u1 < ulen; u1++ ) {
      blob = blobs[u1];
      
      for( u2 = 0; u2 < ulen; u2++ ) {
        var otherBlob = blobs[u2];
        
        if( otherBlob != blob ) {
          var distance = distanceBetween( { x: blob.position.x, y: blob.position.y }, { x: otherBlob.position.x, y: otherBlob.position.y } );
          
          if( distance < blob.radius + otherBlob.radius ) {
            mergeQueue.blobA = u1;
            mergeQueue.blobB = u2;
          }
        }
      }
      
      // Track window movement
      blob.velocity.x += ( window.screenX - screenX ) * (0.04 + (Math.random()*0.1));
      blob.velocity.y += ( window.screenY - screenY ) * (0.04 + (Math.random()*0.1));
      
      var friction = { x: 1.035, y: 1.035 };
      
      // Enforce horizontal world bounds
      if( blob.position.x > worldRect.x + worldRect.width ) {
        blob.velocity.x -= ( blob.position.x - worldRect.width ) * 0.05;
        friction.y = 1.07;
      }
      else if( blob.position.x < worldRect.x ) {
        blob.velocity.x += Math.abs( worldRect.x - blob.position.x ) * 0.05;
        friction.y = 1.07;
      }
      
      // Enforce vertical world bounds
      if( blob.position.y > worldRect.y + worldRect.height ) {
        blob.velocity.y -= ( blob.position.y - worldRect.height ) * 0.05;
        friction.x = 1.07;
      }
      else if( blob.position.y < worldRect.y ) {
        blob.velocity.y += Math.abs( worldRect.y - blob.position.y ) * 0.05;
        friction.x = 1.07;
      }
      
      // Gravity
      blob.velocity.x += gravity.x;
      blob.velocity.y += gravity.y;
      
      // Friction
      blob.velocity.x /= friction.x;
      blob.velocity.y /= friction.y;
      
      // Apply the velocity to the entire blob
      blob.position.x += blob.velocity.x;
      blob.position.y += blob.velocity.y;
      
      var i, j, len, node, joint, position;
      
      // Update all node ghosts (previous positions). All nodes need to be synced before the below
      // calculation loop to avoid tearing between the first nodes
      for (i = 0, len = blob.nodes.length; i < len; i++) {
        node = blob.nodes[i];
        
        node.ghost.x = node.position.x;
        node.ghost.y = node.position.y;
      }
      
      var dragNode = blob.nodes[blob.dragNodeIndex];
      if( dragNode ) {
        var angle = Math.atan2( mouseY - (blob.position.y-80), mouseX - blob.position.x );
        
        blob.rotation += ( angle - blob.rotation ) * 0.03;
        blob.updateNormals();
      }
      
      // Calculation loop
      for (i = 0, len = blob.nodes.length; i < len; i++) {
        node = blob.nodes[i];
        
        // Move towards the normal target
        node.normal.x += ( node.normalTarget.x - node.normal.x ) * 0.05;
        node.normal.y += ( node.normalTarget.y - node.normal.y ) * 0.05;
        
        // This point will be used as the new position for this node, after all factors have been applied
        position = { x: blob.position.x, y: blob.position.y };
        
        // Apply the joints
        for( j = 0; j < node.joints.length; j++ ) {
          joint = node.joints[j];
          
          // Determine the strain on the joints
          var strainX = ( (joint.node.ghost.x - node.ghost.x) - (joint.node.normal.x - node.normal.x) );
          var strainY = ( (joint.node.ghost.y - node.ghost.y) - (joint.node.normal.y - node.normal.y) );
          
          position.x += strainX * joint.strength;
          position.y += strainY * joint.strength;
        }
        
        // Offset by the normal
        position.x += node.normal.x;
        position.y += node.normal.y;
        
        // Apply the drag offset (if applicable)
        if( i == blob.dragNodeIndex ) {
          position.x += ( mouseX - position.x ) * 0.98;
          position.y += ( mouseY - position.y ) * 0.98;
        }
        
        // Apply the calculated position to the node (with easing)
        node.position.x += ( position.x - node.position.x ) * 0.1;
        node.position.y += ( position.y - node.position.y ) * 0.1;
        
        // Limit the node position to screen bounds
        node.position.x = Math.max( Math.min( node.position.x, worldRect.x + worldRect.width ), worldRect.x );
        node.position.y = Math.max( Math.min( node.position.y, worldRect.y + worldRect.height ), worldRect.y );
        
        // Expand the dirty rect if needed
        blob.dirtyRegion.left = Math.min(blob.dirtyRegion.left, node.position.x);
        blob.dirtyRegion.top = Math.min(blob.dirtyRegion.top, node.position.y);
        blob.dirtyRegion.right = Math.max(blob.dirtyRegion.right, node.position.x);
        blob.dirtyRegion.bottom = Math.max(blob.dirtyRegion.bottom, node.position.y);
      }
      
      if( !skin.debug ) {
           context.beginPath();
        context.fillStyle = skin.fillStyle;
        context.strokeStyle = skin.strokeStyle;
        context.lineWidth = skin.lineWidth;
      }
      
      var cn = getArrayElementByOffset( blob.nodes, 0, -1 ); // current node
      var nn = getArrayElementByOffset( blob.nodes, 0, 0 ); // next node
      
      // Move to the first anchor
      context.moveTo( cn.position.x + ( nn.position.x - cn.position.x ) / 2, cn.position.y + ( nn.position.y - cn.position.y ) / 2 );
      
      // Rendering loop
      for (i = 0, len = blob.nodes.length; i < len; i++) {
        cn = getArrayElementByOffset( blob.nodes, i, 0 );
        nn = getArrayElementByOffset( blob.nodes, i, 1 );
        
        if( skin.debug ) {
          context.beginPath();
          context.lineWidth = 1;
          context.strokeStyle = "#ababab";
          
          for( j = 0; j < cn.joints.length; j++ ) {
            joint = cn.joints[j];
            context.moveTo( cn.position.x, cn.position.y );
            context.lineTo( joint.node.position.x, joint.node.position.y );
          }
          
          context.stroke();
          
          context.beginPath();
          context.fillStyle = i == 0? "#00ff00" : "#dddddd";
          context.arc(cn.position.x, cn.position.y, 5, 0, Math.PI*2, true);
          context.fill();
        }
        else {
          context.quadraticCurveTo( cn.position.x, cn.position.y, cn.position.x + ( nn.position.x - cn.position.x ) / 2, cn.position.y + ( nn.position.y - cn.position.y ) / 2 );
        }
      }
      
      if( skin.debug ) {
//        context.beginPath();
//        context.fillStyle = "rgba(100,255,100,0.3)";
//        context.fillRect(blob.dirtyRegion.left-dirtySpread,blob.dirtyRegion.top-dirtySpread,blob.dirtyRegion.right-blob.dirtyRegion.left+(dirtySpread*2),blob.dirtyRegion.bottom-blob.dirtyRegion.top+(dirtySpread*2));
//        context.fill();
      }
      
      context.stroke();
      context.fill();
    }
    
    screenX = window.screenX;
    screenY = window.screenY;
  }
    
  
};

function Blob() {
  this.position = { x: 0, y: 0 };
  this.velocity = { x: 0, y: 0 };
  this.radius = 120;
  this.quality = 32;
  this.nodes = [];
  this.rotation = -Math.PI * 0.5;
  this.dragNodeIndex = -1;
  this.dirtyRegion = { left: 0, top: 0, right: 0, bottom: 0 };
  this.lastSplitTime = 0;
  
  this.generateNodes = function() {
    this.nodes = [];
    
    var i, n;
    
    for (i = 0; i < this.quality; i++) {
      n = {
        normal: { x: 0, y: 0 },
        normalTarget: { x: 0, y: 0 },
        position: { x: this.position.x, y: this.position.y },
        ghost: { x: this.position.x, y: this.position.y },
        angle: 0
      };
      
      this.nodes.push( n );
    }
    
    this.updateJoints();
    
    this.updateNormals();
  };
  
  this.updateJoints = function() {
    for (var i = 0; i < this.quality; i++) {
      var n = this.nodes[i];
      
      n.joints = [
        { 
          node: getArrayElementByOffset( this.nodes, i, -1 ),
          strength: 2.2
        },
        { 
          node: getArrayElementByOffset( this.nodes, i, 1 ),
          strength: 2.2
        }
      ];
      
      n.joints.push( { 
        node: getArrayElementByOffset( this.nodes, i, -2 ),
        strength: 2.2
      } );
      
      n.joints.push( { 
        node: getArrayElementByOffset( this.nodes, i, 2 ),
        strength: 2.2
      } );
      
    }
  };
  
  this.updateNormals = function() {
    var i, j, n;
    
    for (i = 0; i < this.quality; i++) {
      
      var n = this.nodes[i];
      
      if( this.dragNodeIndex != -1 ) {
        j = i - this.dragNodeIndex;
        j = j < 0 ? this.quality + j : j;
      }
      else {
        j = i;
      }
      
      n.angle = ( (j / this.quality ) * Math.PI * 2 ) + this.rotation;
      
      n.normalTarget.x = Math.cos( n.angle ) * this.radius;
      n.normalTarget.y = Math.sin( n.angle ) * this.radius;
      
      if( n.normal.x == 0 && n.normal.y == 0 ) {
        n.normal.x = n.normalTarget.x;
        n.normal.y = n.normalTarget.y;
      }
    }
  };
  
  this.split = function() {
    
    var velocitySpread = this.radius / 10;
    var nodeSpread = Math.round( this.nodes.length * 0.5 );
    var radiusSpread = this.radius * 0.5;
    
    var sibling = new Blob();
    
    sibling.position.x = this.position.x;
    sibling.position.y = this.position.y;
    
    sibling.velocity.x = velocitySpread;
    sibling.velocity.y = this.velocity.y;
    
    sibling.nodes = [];
    
    var i = 0;
    while( i++ < nodeSpread ) {
      sibling.nodes.push( this.nodes.shift() );
    }
    
    sibling.radius = radiusSpread;
    sibling.quality = sibling.nodes.length;
    
    this.velocity.x = -velocitySpread;
    this.radius = radiusSpread;
    this.quality = this.nodes.length;
    
    this.dragNodeIndex = -1;
    this.updateJoints();
    this.updateNormals();
    
    sibling.dragNodeIndex = -1;
    sibling.updateJoints();
    sibling.updateNormals();
    
    sibling.lastSplitTime = getTime();
    this.lastSplitTime = getTime();
    
    return sibling;
    
  };
  
  this.merge = function( sibling ) {
    this.velocity.x *= 0.5;
    this.velocity.y *= 0.5;
    
    this.velocity.x += sibling.velocity.x * 0.5;
    this.velocity.y += sibling.velocity.y * 0.5;
    
    while( sibling.nodes.length ) {
      this.nodes.push( sibling.nodes.shift() );
    }
    
    this.quality = this.nodes.length;
    this.radius += sibling.radius;
    
    this.dragNodeIndex = -1;
    
    this.updateNormals();
    this.organizeNodesByProximity();
    this.updateJoints();
  };
  
  this.organizeNodesByProximity = function() {
    var i, j, outer, inner;
    
    var closestDistance, currentDistance, closestIndex;
    
    var newNodes = this.nodes.concat();
    var blackListed = [];
    
    for (i = 0; i < this.quality; i++) {
      outer = newNodes[i];
      
      currentDistance = 9999;
      closestDistance = 9999;
      closestIndex = -1;
      
      for(j = 0; j < this.quality; j++) {
        inner = newNodes[j];
        
        currentDistance = distanceBetween( inner.position, outer.position );
        
        if( currentDistance < closestDistance && blackListed.indexOf(inner) === -1 ) {
          closestDistance = currentDistance;
          closestIndex = j;
        }
      }
      
      this.nodes[i] = newNodes[closestIndex];
    }
    
  };
}

function getArrayElementByOffset( array, index, offset ) {
  if( array[index+offset] ) {
    return array[index+offset];
  }
  
  if( index+offset > array.length-1 ) {
    return array[index - array.length + offset];
  }
  
  if( index+offset < 0 ) {
    return array[array.length + ( index + offset )];
  }
}

function sortByField( list, field ) {
  var sortOnField = function( a, b ) {
    return a[field] - b[field];
  };
  
  list.sort( sortOnField );
}

function getTime() {
  return new Date().getTime();
}

function distanceBetween(p1,p2) {
  var dx = p2.x-p1.x;
  var dy = p2.y-p1.y;
  return Math.sqrt(dx*dx + dy*dy);
}


BlobWorld.init();
  
  

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

This Pen doesn't use any external JavaScript resources.