Green Snake Red Snake

/*************/
/* UTILITIES */
/*************/

/**
 * Returns true if the character of string s at position i is a digit;
 * otherwise, returns false.
 */
function isDigit(s, i) {
  return CharCode.isDigit(s.charCodeAt(i));
}

/**
 * Calls function f n times.
 */
function repeat(n, f) {
  while (n-- > 0) f();
}

function assign(target, source) {
  for (let propertyName in source) {
    target[propertyName] = source[propertyName];
  }
  return target;
}

/**
 * Returns the index of the beginning of the longest suffix of string s
 * that does not contain any digits.
 */
function indexOfLongestSuffixSansDigits(s) {
  let i = s.length - 1;
  while (i > 0 && !isDigit(s, i)) {
    i--;
  }
  return i + 1;
}

const CharCode = Object.freeze({
  Zero: '0'.charCodeAt(0),
  Nine: '9'.charCodeAt(0),
  isDigit: function(code) {
    return CharCode.Zero <= code && code <= CharCode.Nine;
  }
});

const KeyCode = Object.defineProperties({}, {
  Space: {value: 32},
  LeftArrow: {value: 37},
  UpArrow: {value: 38},
  RightArrow: {value: 39},
  DownArrow: {value: 40}
});

const Angle = Object.defineProperties({}, {
  halfTurn: {value: '180deg'},
  straight: {value: '0deg'},
  quarterTurnLeft: {value: '-90deg'},
  quarterTurnRight: {value: '90deg'}
});

const Measure = {
  indexOfUnits: function (string) {
    return indexOfLongestSuffixSansDigits(string);
  },
  parseNumber: function (value) {
    const number = parseFloat(value);
    return (isFinite(number)) ? number : 0;
  },
  units: function (string) {
    return string.slice(Measure.indexOfUnits(string));
  },
  separate: function (string) {
    const i = Measure.indexOfUnits(string);
    return {
      number: Measure.parseNumber(string.slice(0, i)),
      units: string.slice(i)
    };
  }
};

const FunctionCall = {
  arguments: {
    value: function (string, fname) {
      if (fname === undefined) {
        fname = "";
      }
      const s = fname + '(';
      const i = string.indexOf(s);
      const j = string.indexOf(')', i);
      return i < 0 || j < 0 ?
        "" : string.substring(i + s.length, j);
    }
  }
};

/**************/
/* PROTOTYPES */
/**************/

Element.prototype = Object.defineProperties({}, {
  measureProperties: {
    value: Object.freeze([
      'top', 'bottom',
      'left', 'right',
      'width', 'height',
      'borderWidth', 'borderRadius'
    ])
  },
  simpleProperties: {
    value: Object.freeze([
      'transform',
      'backgroundColor',
      'zIndex'
    ])
  },
  defaultStyle: {
    value: {}
  },
  containsPoint: {
    value: function(x, y) {      
      return x > this.left &&
        x < this.left + this.width &&
        y > this.top &&
        y < this.top + this.height;
    }
  },
  containsCornerOrMiddle: {
    value: function(o) {

      const top = o.top,
        left = o.left,
        width = o.width,
        height = o.height,
        right = left + width,
        bottom = top + height,
        xMid = (left + right) / 2,
        yMid = (top + bottom) / 2;

      return this.containsPoint(xMid, yMid) ||
        this.containsPoint(xMid, top) ||
        this.containsPoint(right, yMid) ||
        this.containsPoint(xMid, bottom) ||
        this.containsPoint(left, yMid) ||
        this.containsPoint(left, top) ||
        this.containsPoint(right, top) ||
        this.containsPoint(left, bottom) ||
        this.containsPoint(right, bottom);
    }
  },
  intersectsWith: {
    value: function(o) {
      return this.containsCornerOrMiddle(o) ||
        o.containsCornerOrMiddle(this);
    }
  }
});

Shape.prototype =
Object.defineProperties(Object.create(Element.prototype), {
  defaultStyle: {
    value: Object.freeze({
      backgroundColor: 'black',
      height: '100px',
      width: '100px',
      position: 'absolute'
    })
  }
});

/*************************/
/* CONSTRUCTOR FUNCTIONS */
/*************************/

function Element(tagName, style) {

  /* Private Fields */

  const thisElement = this;
  const element = document.createElement(tagName);

  /* Public Properties */

  this.measureProperties.forEach(function (propertyName) {
    Object.defineProperty(thisElement, propertyName, {
      get: function () { return getNumberPart(propertyName); },
      set: function (value) { setMeasure(propertyName, value); }
    });
  });
  this.simpleProperties.forEach(function (propertyName) {
    Object.defineProperty(thisElement, propertyName, {
      get: function () { return element.style[propertyName]; },
      set: function (value) { element.style[propertyName] = value; }
    });
  });
  Object.defineProperty(thisElement, 'angle', {
    get: function () {
      return FunctionCall.arguments(thisElement.style.transform, 'rotate');
    }
  });

  /* Public Methods */

  this.render = function () {
    document.body.appendChild(element);
    return thisElement;
  };
  this.rotate = function (amt) {
    element.style.transform = 'rotate(' + amt + ')';
    return thisElement;
  };
  this.appendChild = function (otherElement) {
    if (otherElement instanceof Element)
      otherElement.appendTo(element);
    else
      element.appendChild(otherElement);
    return thisElement;
  };
  this.appendTo = function (otherElement) {
    otherElement.appendChild(element);
    return thisElement
  };

  /* Initialization */

  (function initialize() {
    const thisStyle = element.style;
    assign(thisStyle, thisElement.defaultStyle);
    assign(thisStyle, style);
  })();

  /* Private Utilities */

  function getNumberPart(propertyName) {
    return Measure.separate(element.style[propertyName]).number;
  }
  function setMeasure(property, value) {
    const m = Measure.separate(value.toString());
    const units = m.units.length > 0 ?
      m.units : Measure.units(element.style[property]);
    element.style[property] = m.number + units;
  }
}

function Shape(style) {
  Element.call(this, 'div', style);
}

function List() {
  let list = [];
  Object.defineProperty(this, 'length', {
    get: function () { return list.length; }
  });
  this.elementAt = function (i) { return list[i]; };
  this.addAtHead = function (e) { return list.unshift(e); };
  this.addAtTail = function (e) { return list.push(e); };
  this.removeAtHead = function () { return list.shift(); };
  this.removeAtTail = function () { return list.pop(); };
  this.clear = function () {
    const oldList = list;
    list = [];
    return oldList;
  };
  this.forEach = function (f) { return list.forEach(f); };
  this.forSome = function (f, lower, upper) {
    if (lower === undefined) lower = 0;
    if (upper === undefined) upper = list.length;
    for (let i = lower; i < upper; i++) f(list[i]);
  };
}

function Queue() {
  const list = new List();
  Object.defineProperty(this, 'length', {
    get: function () { return list.length; }
  });
  this.add = list.addAtTail;
  this.remove = list.removeAtHead;
  this.clear = list.clear;
}

function Stack() {
  const list = new List();
  Object.defineProperty(this, 'length', {
    get: function () { return list.length; }
  });
  this.push = list.addAtHead;
  this.pop = list.removeAtHead;
  this.clear = list.clear;
}

/**********/
/* SNAKES */
/**********/

/* Snake Constants */

Object.defineProperties(Snake, {
  units: {value: 'px'},
  North: {value: Angle.halfTurn},
  South: {value: Angle.straight},
  East: {value: Angle.quarterTurnLeft},
  West: {value: Angle.quarterTurnRight}
});

Object.defineProperties(Snake, {
  GoingNorth: {value: 'rotate(' + Snake.North + ')'},
  GoingSouth: {value: 'rotate(' + Snake.South + ')'},
  GoingEast: {value: 'rotate(' + Snake.East + ')'},
  GoingWest: {value: 'rotate(' + Snake.West + ')'}
});

Snake.defaultParameters = Object.freeze({
  delay: 500,
  length: 4,
  width: 20,
  skinColor: 'green',
  eyeColor: 'blue'
});

/* Snake Constructor */

function Snake(options) {

  "use strict";

  /* Private Fields */

  const parameters = assign({}, Snake.defaultParameters);
  Object.freeze(assign(parameters, options));

  const
    thisSnake = this,
    delay = parameters.delay,
    initialSnakeLength = parameters.length,
    snakeWidth = parameters.width,
    eyeColor = parameters.eyeColor,
    stepSize = parameters.width,
    snakeBody = new List(),
    methods = {},
    steps = new List(),
    callStack = new Stack(),
    keyCodeAngleMap = (function () {
      const map = Object.create(null);
      map[KeyCode.UpArrow] = Snake.North;
      map[KeyCode.DownArrow] = Snake.South;
      map[KeyCode.RightArrow] = Snake.East;
      map[KeyCode.LeftArrow] = Snake.West;
      return Object.freeze(map);
    })();

  let thisSnakeIsDead = false,
    commands = new Queue,
    angle = Snake.South,
    sT = 1,
    sL = 0,
    timeoutId = undefined,
    skinColor = parameters.skinColor;

  let commandSet = commands;

  /* Public Properties */

  Object.defineProperties(this, {
    skinColor: { get: getSkinColor, set: setSkinColor },
    isHalted: { get: isHalted },
    isSleeping: { get: isSleeping },
    isDead: { get: isDead },
    isBitingOwnBody: { get: isBitingOwnBody },
    isBitingBodyOf: { value: isBitingBodyOf },
    isBodyBeingBittenBy: { value: isBodyBeingBittenBy } 
  });

  /* Public Mutator Methods */

  this.go = go;
  this.stop = halt;
  this.grow = grow;
  this.kill = die;
  this.goNorth = function (n) { return go(n, Snake.North); };
  this.goSouth = function (n) { return go(n, Snake.South); };
  this.goEast = function (n) { return go(n, Snake.East); };
  this.goWest = function (n) { return go(n, Snake.West); };
  this.sleep = sleep;
  this.wake = wake;
  this.pause = function(ms) { return interrupt().sleep(ms); }
  this.beginMethod = beginMethod;
  this.endMethod = endMethod;
  this.doMethod = doMethod;
  this.ifTrue = ifTrue;
  this.whileTrue = whileTrue;

  /* Initialize new snake. */

  (function initialize() {
    createSnake(initialSnakeLength);
    window.addEventListener('keydown', function (e) {
      if (commands.length > 0)
        commands.clear();
      if (callStack.length > 0)
        callStack.clear();
      angle = keyCodeAngleMap[e.keyCode];
      if (isHalted())
        poke();
    });
  })();

  /* Return new snake. */

  return Object.freeze(this);

  /* Private Utilities */

  // Operations

  function wake() {
    angle = getHead().angle;
    poke();
  }
  function poke() {
    if (thisSnakeIsDead) return;
    if (commands.length === 0 && callStack.length > 0) {
      commands = callStack.pop();
    }
    if (commands.length > 0) {
      angle = commands.remove().call();
    }
    if (angle === undefined) {
      halt();
    }
    else {
      performNextStep();
      if (isBitingOwnBody()) {
        die();
      }
      else {
        timeoutId = window.setTimeout(poke, delay);
      }
    }
    return angle;
  }
  function halt() {
    if (!isHalted()) {
      window.clearTimeout(timeoutId);
      timeoutId = undefined;
    }
  }
  function die() {
    thisSnakeIsDead = true;
    thisSnake.skinColor = 'transparent';
  }
  function go(n, a) {
    if (a === undefined) a = angle;
    if (!isFinite(n)) n = 0;
    while (n-- > 0) {
      commandSet.add(function () { return a; });
    }
    if (isHalted() && !isBeingProgrammed()) poke();
    return thisSnake;
  }
  function performNextStep() {
    sT = sL = 0;
    switch(angle) {
      case Snake.North: sT = -1; break;
      case Snake.South: sT = 1; break;
      case Snake.East: sL = 1; break;
      case Snake.West: sL = -1; break;
    }
    step(sT*stepSize, sL*stepSize, angle);
  }
  function step(distTop, distLeft, angle) {
    if (distTop === 0 && distLeft === 0) return;
    steps.addAtTail({
      distT: distTop,
      distL: distLeft,
      angle: angle}
    );
    moveSegments();
  }
  function moveSegments() {
    const n = snakeBody.length;
    let m = steps.length;
    for (let i = 0; i < n && m-- > 0; i++) {
      const snakeSegment = snakeBody.elementAt(i);
      const step = steps.elementAt(m);
      snakeSegment.top += step.distT;
      snakeSegment.left += step.distL;
      snakeSegment.rotate(step.angle);
    }
    moveBackBone();
  }
  function moveBackBone() {
    const n = snakeBody.length;
    let m = steps.length;
    for (let i = 1; i < n && m-- > 0; i++) {
      const curr = getTransformValue(i);
      const prev = getTransformValue(i - 1);
      if (curr !== prev) { // Turning.
        if (turnedRight(curr, prev))
          rotateVertebrae(i, Angle.quarterTurnLeft);
        else if (turnedLeft(curr, prev))
          rotateVertebrae(i, Angle.quarterTurnRight);
        else if (reversed(curr, prev))
          rotateVertebrae(i, Angle.halfTurn);
        if (i === (n - 1)) { // Straighten tail.
          const snakeSegment = snakeBody.elementAt(i);
          rotateVertebrae(i, Angle.straight);
          snakeSegment.transform = prev;
        }
      }
      else { // Going straight.
        rotateVertebrae(i, Angle.straight);
      }
    }
  }
  function rotateVertebrae(n, angle) {
    snakeBody.elementAt(n).vertebrae2.rotate(angle);
  }
  function setSkinColor(color) {
    skinColor = color;
    snakeBody.forEach(function (segment) {
      segment.skin.backgroundColor = color;
    });
  }

  // Values

  function getHead() {
    return snakeBody.elementAt(0);
  }
  function getSkinColor() {
    return skinColor;
  }
  function isBeingProgrammed() {
    return commandSet !== commands;
  }
  function isHalted() {
    return timeoutId === undefined;
  }
  function isDead() {
    return thisSnakeIsDead;
  }
  function isSleeping() {
    return isHalted() && !isDead();
  }
  function isBitingOwnBody() {
    return isBitingBodyOf(thisSnake);
  }
  function isBodyBeingBittenBy(head) {
    const n = snakeBody.length;
    for (let i = 1; i < n; i++) {
      if (snakeBody.elementAt(i).intersectsWith(head))
        return true;
    }
    return false;    
  }
  function isBitingBodyOf(snake) {
    return snake.isBodyBeingBittenBy(getHead());
  }
  function getTransformValue(n) {
    return (n < 0) ? undefined : snakeBody.elementAt(n).transform;
  }
  function turnedRight(curr, prev) {
    switch (prev) {
      case Snake.GoingNorth: return curr === Snake.GoingEast;
      case Snake.GoingSouth: return curr === Snake.GoingWest;
      case Snake.GoingEast: return curr === Snake.GoingSouth;
      case Snake.GoingWest: return curr === Snake.GoingNorth;
    }
    return false;
  }
  function turnedLeft(curr, prev) {
    switch (prev) {
      case Snake.GoingNorth: return curr === Snake.GoingWest;
      case Snake.GoingSouth: return curr === Snake.GoingEast;
      case Snake.GoingEast: return curr === Snake.GoingNorth;
      case Snake.GoingWest: return curr === Snake.GoingSouth;
    }
    return false;
  }
  function reversed(curr, prev) {
    switch (prev) {
      case Snake.GoingNorth: return curr === Snake.GoingSouth;
      case Snake.GoingSouth: return curr === Snake.GoingNorth;
      case Snake.GoingEast: return curr === Snake.GoingWest;
      case Snake.GoingWest: return curr === Snake.GoingEast;
    }
    return false;
  }

  // Programming

  function interrupt() {
    if (commands.length > 0) {
      callStack.push(commands);
      commands = new Queue();
    }
    return thisSnake;
  }
  function isTrue(expression) {
    if (typeof expression === 'function')
      return expression() === true;
    else
      return expression === true;
  }
  function fetchNextCommand() {
    return commands.remove();
  }
  function skipNextCommand() {
    fetchNextCommand();
  }
  function executeNextCommand() {
    return fetchNextCommand().call();
  }
  function ifTrue(expression) {
    commandSet.add(function () {
      if (!isTrue(expression)) {
        skipNextCommand();
      }
      return executeNextCommand();
    });
  }
  function whileTrue(expression) {
    const whileLoop = 
    function () {
      const command = fetchNextCommand();
      if (isTrue(expression)) {
        interrupt;
        commands.add(command);
        commands.add(whileLoop);
        commands.add(command);
      }
      return executeNextCommand();
    };
    commandSet.add(whileLoop);
    return thisSnake;
  }
  function doMethod(name) {
    commandSet.add(function () {
      interrupt();
      loadMethod(name);
      return executeNextCommand();
    });
    if (isHalted() && !isBeingProgrammed()) {
      poke();
    }
    return thisSnake;
  }
  function sleep(ms) {
    commandSet.add(function () {
      if (Number.isFinite(ms)) {
        window.setTimeout(wake, ms);
      }
      return undefined;
    });
    return thisSnake;
  }
  function parseMethodName(name) {
    return (name === undefined) ? 'anonymous' : name;
  }
  function beginMethod(name) {
    name = parseMethodName(name);
    commandSet = methods[name] = new Queue();
    return thisSnake;
  }
  function endMethod() {
    commandSet = commands;
    return thisSnake;
  }
  function loadMethod(name) {
    name = parseMethodName(name);
    const method = methods[name];
    if (method !== undefined && method.length !== undefined) {
      let n = method.length;
      while (n-- > 0) {
        const cmd = method.remove();
        commands.add(cmd);
        method.add(cmd);
      }
    }
    return thisSnake;
  }

  // Create

  function grow(n) {
    if (n === undefined) n = 1;
    repeat(n, appendBodySegment);    
  }
  function createSnake(n) {
    createHeadSegment();
    grow(n-1);
  }
  function createHeadSegment() {
    const head = createHead();
    head.appendChild(head.skin = createFace());
    return attachToSnake(head);
  }
  function appendBodySegment() {
    const segment = createBackboneSegment();
    segment.appendChild(segment.skin = createSkin());
    return attachToSnake(segment);
  }
  function attachToSnake(segment) {
    let last, top, left;
    if (snakeBody.length > 0) {
      last = snakeBody.elementAt(snakeBody.length - 1);
      top = last.top;
      left = last.left;
    }
    snakeBody.addAtTail(segment);
    segment.zIndex = -snakeBody.length;
    segment.render();
    step(sT * stepSize, sL * stepSize, angle);
    if (last !== undefined) {
      segment.top = top;
      segment.left = left;
    }
    return segment;
  }
  function createSkin() {
    return new Shape({
      width: snakeWidth + Snake.units,
      height: snakeWidth + Snake.units,
      backgroundColor: skinColor
    });
  }
  function createVertebrae(propertyName) {
    const width = snakeWidth / 8;
    const style = {
      left: ((snakeWidth - width) / 2) + Snake.units,
      width: width + Snake.units,      
      height: (snakeWidth / 2) + Snake.units,
      backgroundColor: 'white'
    };
    style[propertyName] = 0;
    return new Shape(style);
  }
  function createIntervertebralDisc() {
    const width = snakeWidth / 6;
    return new Shape({
      left: ((snakeWidth - width) / 2) + Snake.units,
      top: ((snakeWidth - width) / 2) + Snake.units,
      width: width + Snake.units,      
      height: width + Snake.units,
      backgroundColor: 'white',
      borderRadius: '50%',
    });
  }
  function createBackground(color) {
    return new Shape({
      width: snakeWidth + Snake.units,
      height: snakeWidth + Snake.units,
      backgroundColor: color
    });
  }
  function createBackboneSegment() {
    const background = createBackground('black');
    background.vertebrae2 = createBackground('transparent');
    background.vertebrae2.appendChild(createVertebrae('bottom'))
    background.appendChild(background.vertebrae2);
    background.appendChild(createVertebrae('top'));
    return background.appendChild(createIntervertebralDisc());
  }
  function createSkull() {
    const skull = new Shape({
      width: snakeWidth + Snake.units,      
      height: snakeWidth + Snake.units,
      bottom: 0,
      backgroundColor: 'white',
      borderRadius: '45%'
    });
    return addEyes(skull, 'black');
  }
  function createHead() {
    const background = createBackground('black');
    background.appendChild(createTongue(background));
    return background.appendChild(createSkull());
  }
  function createFace() {
    return addEyes(createSkin(), eyeColor);
  }
  function createTongue(head) {
    const tongueLength = head.height / 2;
    const tongueWidth = head.width / 5;
    return new Shape({
      width: tongueWidth,
      height: tongueLength + 'px',
      top: head.height + 'px',
      left: ((head.width / 2) - (tongueWidth / 2)) + 'px',
      backgroundColor: 'red',
      clipPath: 'polygon(0% 0%, 100% 0%, 100% 100%, 50% 70%, 0% 100%)'
    });
  }
  function createEye(eyeColor) {
    const eyeDiameter = snakeWidth / 5;
    return new Shape({
      borderRadius: '50%',
      width: eyeDiameter + Snake.units,
      height: eyeDiameter + Snake.units,
      backgroundColor: eyeColor
    });
  }
  function addEyes(face, eyeColor) {
    const leftEye = createEye(eyeColor);
    const rightEye = createEye(eyeColor);
    const eyeInset = face.width / 6;
    const eyeDepth = 3 * face.height / 7;
    rightEye.right = leftEye.left = eyeInset;
    rightEye.bottom = leftEye.bottom = eyeDepth;
    face.appendChild(leftEye);
    return face.appendChild(rightEye);
  }
}

/* Various Types of Snakes */

function GreenSnake() {
  Snake.call(this, {
    delay: 800,
    length: 10,
    width: 25,
    skinColor: 'green',
    eyeColor: 'blue'
  });
}
function BlueSnake() {
  Snake.call(this, {
    delay: 400,
    length: 20,
    width: 15,
    skinColor: 'blue',
    eyeColor: 'yellow'
  });
}
function OrangeSnake() {
  Snake.call(this, {
    delay: 100,
    length: 8,
    width: 20,
    skinColor: 'orange',
    eyeColor: 'purple'
  });
}
function RedEyeSnake() {
  Snake.call(this, {
    delay: 600,
    length: 12,
    width: 18,
    skinColor: 'transparent',
    eyeColor: 'red'
  });
}

const DefaultSnake = Snake;
const LongSnake = BlueSnake;
const ShortSnake = OrangeSnake;
const FastSnake = OrangeSnake;
const SlowSnake = GreenSnake;
const BareBonesSnake = RedEyeSnake;