engagementgamelab/CivicSeed

View on GitHub
client/code/game/game.player.js

Summary

Maintainability
F
1 wk
Test Coverage
'use strict'
/* global ss, $, $game, $BODY */

// TODO: Refactor

var _ = require('underscore')

var STEPS_PER_MOVE = 8 // Frames per move between tiles

// Private vars for player
// TODO: Find where these variables are used and place them
// so that variables are confined to smaller scopes or attached
// to objects that can organize them.
var _curFrame = 0
var _currentStepIncX = 0
var _currentStepIncY = 0
var _direction = 0
var _idleCounter = 0
var _info = null
var _renderInfo = null
var _previousSeedsDropped = null
var _startTime = null
var _seeds = null
var _totalSeeds = null
var _resources = null
var _colorMap = null
var _resume = null
var _playingTime = null
var _tilesColored = null
var _pledges = null
var _resourcesDiscovered = null
var _skinSuit = null
var _drawSeeds = null
var _drawSeedArea = {}

var $player = module.exports = {

  firstName: null,
  id: null,
  game: null,
  instanceName: null,
  currentLevel: null,
  botanistState: null,
  seenRobot: null,
  seriesOfMoves: [],
  currentStep: 0,
  seedPlanting: false,
  npcOnDeck: false,
  ready: false,
  seedMode: false,

  init: function (callback) {
    // get the players info from the db, alerts other users of presence
    ss.rpc('game.player.init', function (playerInfo) {
      // time in seconds since 1970 or whatever
      _startTime = new Date().getTime() / 1000

      _info = {
        srcX: 0,
        srcY: 0,
        x: playerInfo.game.position.x,
        y: playerInfo.game.position.y,
        offX: 0,
        offY: 0,
        prevOffX: 0,
        prevOffY: 0
      }

      // keeping this around because we then save to it on exit
      $player.game = playerInfo.game
      _setPlayerInformation(playerInfo)

      // tell others you have joined
      var subsetInfo = {
        _id: playerInfo.id,
        firstName: playerInfo.firstName,
        game: {
          tilesColored: playerInfo.game.tilesColored,
          rank: playerInfo.game.rank,
          currentLevel: playerInfo.game.currentLevel,
          position: playerInfo.game.position,
          skinSuit: playerInfo.game.skinSuit,
          playerColor: playerInfo.game.playerColor
        }
      }
      ss.rpc('game.player.tellOthers', subsetInfo)

      // set the render info
      _renderInfo = {
        id: $player.id,
        kind: 'player',
        firstName: $player.firstName,
        level: $player.currentLevel,
        colorIndex: playerInfo.game.playerColor,
        color: $player.getCSSColor(),
        srcX: 0,
        srcY: 0,
        curX: _info.x * $game.TILE_SIZE,
        curY: _info.y * $game.TILE_SIZE,
        prevX: _info.x * $game.TILE_SIZE,
        prevY: _info.y * $game.TILE_SIZE
      }

      _player.updateTotalSeeds()
      _updateRenderInfo()

      // we are ready, let everyone know dat
      $player.ready = true
      callback()
    })
  },

  resetInit: function () {
    _curFrame = 0
    _currentStepIncX = 0
    _currentStepIncY = 0
    _direction = 0
    _idleCounter = 0

    _info = null
    _renderInfo = null

    _previousSeedsDropped = null

    _startTime = null

    _seeds = null
    _totalSeeds = null
    _resources = null
    _colorMap = null
    _resume = null
    _playingTime = null
    _tilesColored = null
    _pledges = null
    _resourcesDiscovered = null
    _skinSuit = null

    _drawSeeds = null

    $player.firstName = null
    $player.id = null
    $player.game = null
    $player.instanceName = null
    $player.currentLevel = null
    $player.seenRobot = null
    $player.seriesOfMoves = []
    $player.currentStep = 0
    $player.seedPlanting = false
    $player.npcOnDeck = false
    $player.ready = false
    $player.seedMode = false
  },

  // Calculate movements and what to render for every game tick
  update: function () {
    if ($game.flags.check('is-moving') === true) {
      _move()
      _updateRenderInfo()
    } else {
      if ($game.flags.check('screen-transition') === true) {
        _updateRenderInfo()
      } else {
        _idle()
      }
    }
  },

  // Clear the character canvas to ready for redraw
  clear: function () {
    $game.$render.clearCharacter(_renderInfo)
  },

  // Start a movement -> pathfind
  // Decide if we need to load new viewport, or if we are going to visit an NPC
  // targetPosition is an object containing {x: xLocalPosition, y: yLocalPosition }
  beginMove: function (targetPosition) {
    var localPosition = $player.getLocalPosition()
    var path

    // Clear HUD
    $game.$chat.hideChat()
    if ($game.flags.check('visible-inventory')) {
      $game.inventory.close()
    }

    $game.flags.set('pathfinding')
    _info.offX = 0
    _info.offY = 0

    if ($game.bossModeUnlocked && $player.currentLevel > 3) {
      path = $game.$pathfinder.findPath({x: _info.x, y: _info.y}, targetPosition)

      $game.flags.unset('pathfinding')
      if (path.length > 0) {
        _sendMoveInfo(path)
      }
    } else {
      // Find path
      // If player is already moving, the start point of the path is the point of next move in the series
      // Otherwise, use the player's current local position
      if ($player.seriesOfMoves[0]) {
        path = $game.$pathfinder.findPath($game.$map.masterToLocal($player.seriesOfMoves[0].masterX, $player.seriesOfMoves[0].masterY), targetPosition)
      } else {
        path = $game.$pathfinder.findPath(localPosition, targetPosition)
      }

      if (path.length > 0) {
        // Reset flags, in case they were set by a previous move
        // and now needs to be cancelled.
        $game.flags.unset('screen-will-transition')

        // If a transition is necessary, preload data for loading next screen
        // No transition occurs if this is the edge of the game map
        // We do this here instead of at _endMove() to cut down on load time
        if ((targetPosition.x === 0 ||
            targetPosition.x === $game.VIEWPORT_WIDTH - 1 ||
            targetPosition.y === 0 ||
            targetPosition.y === $game.VIEWPORT_HEIGHT - 1) &&
            $game.$map.isMapEdge(targetPosition) === false) {
          $game.flags.set('screen-will-transition')
          $game.$map.calculateNext(targetPosition.x, targetPosition.y)
        }

        _sendMoveInfo(path)

        // Send update to server so everyone else gets your path
        ss.rpc('game.player.movePlayer', path, $player.id)
      } else {
        _endMove()
      }

      $game.flags.unset('pathfinding')
    }
  },

  moveStraight: function (direction) {
    var location
    var targetPosition
    var x, y

    // Get current local position
    // If player is moving, start calculation from next move in series
    // Else, use current position
    if ($player.seriesOfMoves.length > 0) {
      location = $game.$map.masterToLocal($player.seriesOfMoves[0].masterX, $player.seriesOfMoves[0].masterY)
    } else {
      location = $player.getLocalPosition()
    }

    // Initialize x, y position
    x = location.x
    y = location.y

    // Using target direction,
    // analyze grid until we hit any tile with state above 0 (Go)
    // and that becomes our new target position
    switch (direction) {
      case 'up':
        while (y >= 0 && $game.$map.currentTiles[x][y].tileState === 0) {
          targetPosition = { x: x, y: y }
          y--
        }
        break
      case 'down':
        y = location.y
        while ((y <= $game.VIEWPORT_HEIGHT - 1) && $game.$map.currentTiles[x][y].tileState === 0) {
          targetPosition = { x: x, y: y }
          y++
        }
        break
      case 'left':
        x = location.x
        while (x >= 0 && $game.$map.currentTiles[x][y].tileState === 0) {
          targetPosition = { x: x, y: y }
          x--
        }
        break
      case 'right':
        x = location.x
        while ((x <= $game.VIEWPORT_WIDTH - 1) && $game.$map.currentTiles[x][y].tileState === 0) {
          targetPosition = { x: x, y: y }
          x++
        }
        break
      default:
        // No default case
        break
    }

    // Send move target position through beginMove()
    // beginMove() handles logic on what to do once the player
    // reaches that destination
    $player.beginMove(targetPosition)
  },

  moveStop: function () {
    $player.seriesOfMoves.splice(1, Number.MAX_VALUE)
  },

  // Moves the player as the viewport transitions
  slide: function (slideX, slideY) {
    _info.prevOffX = slideX * STEPS_PER_MOVE
    _info.prevOffY = slideY * STEPS_PER_MOVE
  },

  // When the player finishes moving or we just need a hard reset for rendering
  resetRenderValues: function () {
    _info.prevOffX = 0
    _info.prevOffY = 0
  },

  // decide what type of seed drop mechanic to do and check if they have seeds
  dropSeed: function (options) {
    if (options.x !== undefined && options.x >= 0) {
      options.mX = $game.$map.currentTiles[options.x][options.y].x
      options.mY = $game.$map.currentTiles[options.x][options.y].y
    }
    var mode = options.mode

    // regular seed mode
    if (mode === 'regular') {
      if (_seeds.regular < 1) {
        return false
      } else {
        // Default paint radius
        options.sz = 3
        options.radius = 1

        // If powered up, increase paint radius
        if ($game.flags.check('paint-up-1')) options.radius++
        if ($game.flags.check('paint-up-2')) options.radius++
        if ($game.flags.check('paint-up-3')) options.radius++
        if ($game.flags.check('paint-max')) options.radius = 4

        _player.calculateSeeds(options)
        return true
      }
    } else {
      // draw seed mode
      var seedArray = $.map(_drawSeeds, function (k, v) {
        return [k]
      })

      if (seedArray.length > 0) {
        // figure out the size covered
        var topLeftTile = $game.$map.currentTiles[_drawSeedArea.minX][_drawSeedArea.minY]
        var bottomRightTile = $game.$map.currentTiles[_drawSeedArea.maxX][_drawSeedArea.maxY]
        var centerTileX = $game.$map.currentTiles[15][7].x
        var centerTileY = $game.$map.currentTiles[15][7].y

        var data = {
          bombed: seedArray,
          options: {
            mX: centerTileX,
            mY: centerTileY
          },
          x1: topLeftTile.x,
          y1: topLeftTile.y,
          x2: bottomRightTile.x,
          y2: bottomRightTile.y,
          kind: 'draw'
        }
        _player.sendSeedBomb(data)
        return true
      }
    }
  },

  // determine which returning to npc prompt to show based on if player answered it or not
  getPrompt: function (id) {
    if (_resources[id]) {
      if (_resources[id].result) {
        return 2
      } else {
        return 1
      }
    }
    return 0
  },

  // Saves the user's answer locally
  saveResourceLocally: function (data) {
    var playerResource = _resources[data.id]

    // See if the resource is already in the player's game data
    // If so, retrieve it and update it
    // If not, set up a new object
    if (playerResource) {
      playerResource.answers.push(data.answer)
      if (Number.isInteger(data.attempts)) {
        playerResource.attempts = data.attempts
      } else {
        playerResource.attempts += 1
      }
      playerResource.result = data.correct
      playerResource.seedsRewarded = this.determineNumberOfSeedsToReward(playerResource)
      if (data.tagline) {
        playerResource.tagline = data.tagline
      }
    } else {
      // Create resource game data to save
      // This will be updated on the server later
      playerResource = {
        id: data.id,
        questionType: data.questionType,
        answers: [data.answer],
        attempts: Number.isInteger(data.attempts) ? data.attempts : 1,
        result: data.correct,
        seeded: [],
        skinSuit: data.skinSuit,
        rewarded: data.rewarded || false,
        tagline: null
      }

      // Determine seeds to reward
      playerResource.seedsRewarded = this.determineNumberOfSeedsToReward(playerResource)

      // Add it to game data
      _resources[data.id] = playerResource
    }

    // Return it so other things in the game know it exists
    return playerResource
  },

  // Determine how many seeds a player gets, based on their attempts
  determineNumberOfSeedsToReward: function (playerResource) {
    var rawAttempts = 6 - playerResource.attempts
    var seedsToAdd = (rawAttempts < 0) ? 0 : rawAttempts

    // If they took more than 1 try to get a binary, drop down more
    if (playerResource.questionType === 'truefalse' || playerResource.questionType === 'yesno') {
      if (seedsToAdd < 5 && seedsToAdd > 2) {
        seedsToAdd = 2
      }
    }

    return seedsToAdd
  },

  // checks if we should save out a new image of player's color map
  saveMapImage: function (force) {
    // only do this if we have dropped 5 new seeds
    if (_seeds.dropped - _previousSeedsDropped > 4 || force) {
      _colorMap = $game.$map.saveImage()
      var info = {
        id: $player.id,
        colorMap: _colorMap
      }
      ss.rpc('game.player.updateGameInfo', info)
      _previousSeedsDropped = _seeds.dropped
    }
  },

  // Gets player's answer for specific resource
  getAnswer: function (id) {
    return _resources[id]
  },

  // Simulates giving a map to the player
  giveMapToPlayer: function () {
    // Turn on minimap view on gameboard
    // NOTE: Currently always on by default.
    // $game.minimap.show()

    // Enable minimap view on progress window
    $('#progress-area .minimap').show()
  },

  // reset items and prepare other entities for fresh level
  nextLevel: function () {
    $player.currentLevel += 1
    $player.seenRobot = false
    $game.$botanist.setState(0)
    $game.flags.unset('botanist-teleported')
    _pledges = 5
    // $game.$render.loadTilesheet($player.currentLevel, true)

    // save new information to DB
    ss.rpc('game.player.updateGameInfo', {
      id: $player.id,
      botanistState: $game.$botanist.getState(),
      seenRobot: $player.seenRobot,
      pledges: _pledges,
      currentLevel: $player.currentLevel
    })

    $game.log('Congrats! You have completed level ' + $player.currentLevel + '!')

    if ($player.currentLevel < 4) {
      $game.$robot.setPosition()
      _renderInfo.level = $player.currentLevel

      // Clear inventory and redraw for next level
      $game.inventory.empty()
      $game.inventory.init()

      ss.rpc('game.player.levelChange', {
        id: $player.id,
        level: $player.currentLevel,
        name: $player.firstName
      })
    } else if ($game.bossModeUnlocked) {
      $game.toBossLevel()
    }
  },

  // return the calculation of how long they have been playing for (total)
  getPlayingTime: function () {
    var currentTime = new Date().getTime() / 1000
    var totalTime = Math.round((currentTime - _startTime) + _playingTime)
    return totalTime
  },

  // remove the menu once they have selected a seed flash player and disable other actions
  startSeeding: function (choice) {
    $player.seedMode = choice
    $('#seedventory').slideUp(function () {
      $game.flags.unset('visible-seedventory')
      $player.seedPlanting = true
    })
    var msg
    if (choice === 'regular') {
      msg = 'Click anywhere to plant a seed and watch color bloom there'
    } else {
      msg = 'Paintbrush mode activated - click and drag to draw'

      var graffitiEl = document.getElementById('graffiti')
      graffitiEl.style.display = 'block'
      graffitiEl.querySelector('.remaining').textContent = _seeds.draw
    }
    $game.alert(msg)
  },

  // Make a response public to all other users
  makePublic: function (id) {
    if (_resources[id]) {
      _resources[id].madePublic = true
      ss.rpc('game.npc.makeResponsePublic', {
        playerId: $player.id,
        resourceId: id,
        instanceName: $player.instanceName
      })
      // This emits an 'ss-addAnswer' event
    }
  },

  // Make a previously public response private to all other users
  makePrivate: function (id) {
    if (_resources[id]) {
      _resources[id].madePublic = false
      ss.rpc('game.npc.makeResponsePrivate', {
        playerId: $player.id,
        resourceId: id,
        instanceName: $player.instanceName
      })
      // This emits an 'ss-removeAnswer' event
    }
  },

  // Get ALL answers for all open questions for this player
  compileAnswers: function () {
    var html = ''

    $.each(_resources, function (index, resource) {
      if (resource.questionType === 'open') {
        var answer = resource.answers[resource.answers.length - 1]
        var question = $game.$resources.get(index).question
        var seededCount = resource.seeded.length

        html += '<p class="theQuestion"><strong>Q:</strong> ' + question + '</p><div class="theAnswer"><p class="answerText">' + answer + '</p>'
        if (seededCount > 0) {
          html += '<p class="seededCount">' + seededCount + ' likes</p>'
        }
        html += '</div>'
      }
    })

    return html
  },

  // See if the player has the specific resource already
  // Note that it must be correct and rewarded to count.
  checkForCompletedResource: function (id) {
    var resource = _resources[id]
    if (!resource) return false
    if (resource.result !== true) return false
    if (resource.rewarded !== true) return false
    return true
  },

  // See if the player has resource, complete or not
  checkForResource: function (id) {
    return (_resources[id]) ? true : false
  },

  // Set seeds to a specific amount
  setSeeds: function (kind, quantity) {
    _seeds[kind] = quantity

    // Save to DB
    ss.rpc('game.player.updateGameInfo', {
      id: $player.id,
      seeds: _seeds
    })

    // Update HUD
    _player.updateTotalSeeds()
  },

  // Add seeds to a specific type of seed
  addSeeds: function (kind, quantity) {
    // TODO: Use getSeeds() instead of a global.
    var total = _seeds[kind] + quantity
    $player.setSeeds(kind, total)
  },

  // put new answer into the resume
  resumeAnswer: function (answer) {
    _resume.push(answer)
    var info = {
      id: $player.id,
      resume: _resume
    }
    ss.rpc('game.player.updateGameInfo', info)
  },

  // keep track of how many seedITs the player has done
  updatePledges: function (quantity) {
    _pledges += quantity
    var info = {
      id: $player.id,
      pledges: _pledges
    }
    ss.rpc('game.player.updateGameInfo', info)
  },

  // Return the player's current position in the world
  getPosition: function () {
    return _info
  },

  // Return the player's current position on the screen
  getLocalPosition: function () {
    return $game.$map.masterToLocal(_info.x, _info.y)
  },

  // Get the player's color index number
  getColorIndex: function () {
    return $player.playerColor
  },

  // Get a color at a given index or use current player color index
  getColor: function (index) {
    if (!index) {
      index = $player.getColorIndex()
    }
    // Returns an object {r, g, b}, values from 0 - 255
    return COLORS[index]
  },

  // Get a color hex string at a given index or use current player color index
  getCSSColor: function (index) {
    var rgb = $player.getColor(index)
    // A quick way of converting to a hex string, e.g. #5599cc
    return '#' + ('0' + (rgb.r.toString(16))).slice(-2) + ('0' + (rgb.g.toString(16))).slice(-2) + ('0' + (rgb.b.toString(16))).slice(-2)
  },

  // get all the render info to draw player
  getRenderInfo: function () {
    return _renderInfo
  },

  // get the current color map
  getColorMap: function () {
    return _colorMap
  },

  // get the number of tiles colored
  getTilesColored: function () {
    return _tilesColored
  },

  // Get an object containing references to all collected resources
  // Plus information pertaining to the player's copy of the resource
  // e.g. answers, number of attempts, etc.
  getResources: function () {
    return _resources
  },

  // Get a particular resource that is in the player's collection
  // Note: this is not the original resource object
  getResource: function (id) {
    return _resources[id]
  },

  // Get the number of resources collected
  getResourcesDiscovered: function () {
    return _resourcesDiscovered
  },

  getSkinSuit: function () {
    var data = _skinSuit
    // We need to return this data as a
    // separate object to prevent other
    // scripts from writing directly to it
    return {
      head: data.head,
      torso: data.torso,
      legs: data.legs
    }
  },

  // When the player changes skin, update client model and save to db
  setSkinSuit: function (name, part) {
    // Update model
    if (part !== undefined) {
      _skinSuit[part] = name
    } else {
      // Assume entire suit
      _skinSuit.head = name
      _skinSuit.torso = name
      _skinSuit.legs = name
    }

    // Change skin on database
    ss.rpc('game.player.changeSkinSuit', {
      id: $player.id,
      skinSuit: _skinSuit
    })
  },

  // Returns a clone of the skinventory data
  getSkinventory: function () {
    var data = _skinSuit
    return data.unlocked
  },

  // Updates the skinventory on the database
  setSkinventory: function (skinventory) {
    _skinSuit.unlocked = skinventory
    ss.rpc('game.player.updateGameInfo', {
      id: $player.id,
      skinSuit: _skinSuit
    })
  },

  // Get seeds
  getSeeds: function () {
    return _seeds
  },

  // get the number of seeds dropped
  getSeedsDropped: function () {
    return _seeds.dropped
  },

  getMoveSpeed: function () {
    return _player.moveSpeed
  },

  getLevel: function () {
    return $player.currentLevel + 1
  },

  // get the quantity of seedITs made
  getPledges: function () {
    return _pledges
  },

  // get the current viewport position
  getRenderPosition: function () {
    return {x: _renderInfo.curX, y: _renderInfo.curY}
  },

  // Get region of the world that player is in
  getGameRegion: function () {
    // Compare player position to centers of the world
    var position = this.getPosition()
    var posX = position.x
    var posY = position.y
    var diffX = posX - ($game.TOTAL_WIDTH - 4) / 2
    var diffY = posY - ($game.TOTAL_HEIGHT + 8) / 2

    // Check for botanist's place first
    if (posX >= 57 && posX <= 84 && posY >= 66 && posY <= 78) {
      return 5
    }
    // 1 top left
    else if (diffX < 0 && diffY < 0) return 0
    // 2 top right
    else if (diffX > 0 && diffY < 0) return 1
    // 3 bottom right
    else if (diffX > 0 && diffY > 0) return 2
    // 4 bottom left
    else if (diffX < 0 && diffY > 0) return 3
    // no man's land
    else return -1
  },

  // Transport player magically (or scientifically) to any location in the game world
  beam: function (location) {
    $game.flags.set('is-beaming')
    $game.flags.set('screen-transition')
    $game.$input.resetUI()
    $game.$chat.clearAllChats()
    $('#beaming').show()

    _info.x = location.x
    _info.y = location.y
    _renderInfo.curX = location.x * $game.TILE_SIZE
    _renderInfo.curY = location.y * $game.TILE_SIZE
    _renderInfo.prevX = location.x * $game.TILE_SIZE
    _renderInfo.prevY = location.y * $game.TILE_SIZE

    var tx = (location.x === 0) ? 0 : location.x - 1
    var ty = (location.y === 0) ? 0 : location.y - 1
    var divX = Math.floor(tx / ($game.VIEWPORT_WIDTH - 2))
    var divY = Math.floor(ty / ($game.VIEWPORT_HEIGHT - 2))
    var startX = divX * ($game.VIEWPORT_WIDTH - 2)
    var startY = divY * ($game.VIEWPORT_HEIGHT - 2)

    $game.masterX = startX
    $game.masterY = startY

    // update npcs, other players?
    $game.$map.setBoundaries()
    $game.$map.firstStart(function () {
      $game.$render.renderAllTiles()
      setTimeout(function () {
        $game.flags.unset('is-beaming')
        $('#beaming').fadeOut()
        // Use default viewport transition end function
        $game.endTransition()
      }, 1000)

      // Publish beam status
      ss.rpc('game.player.beam', {
        id: $player.id,
        x: location.x,
        y: location.y
      })

      // Update player position on server and minimap
      _player.savePosition(location)
    })
  },

  // When another player pledges a seed, make the update in your local resources
  updateResource: function (data) {
    if (_resources[data.resourceId]) {
      _resources[data.resourceId].seeded.push(data.pledger)
    }
  },

  // Set the tagline to the resource locally
  setTagline: function (resource, tagline) {
    if (_resources[resource.id]) {
      _resources[resource.id].tagline = tagline
    }
  },

  // Save player's resource status to the database (whether completed, answered, etc)
  saveResource: function (resource) {
    var playerResource = _resources[resource.id]
    var npc = $game.$npc.findNpcByResourceId(resource.id)
    var npcLevel = npc.getLevel()
    var playerLevel = $player.getLevel()

    // Things to unlock / add if this is a correct answer
    // And also make sure it has not been awarded already
    if (playerResource.result === true && playerResource.rewarded === false) {
      // Add piece to inventory
      // Do not add if this is a resume type question
      if (playerLevel === npcLevel && resource.questionType !== 'resume') {
        $game.inventory.add({
          id: resource.id,
          name: resource.shape,
          tagline: playerResource.tagline
        })
      }

      // Add item to inventory count
      _resourcesDiscovered += 1

      // Add seeds
      $player.addSeeds('regular', playerResource.seedsRewarded)

      // Unlock skinsuit
      if (playerResource.skinSuit) {
        $game.$skins.unlockSkin(playerResource.skinSuit)
      }

      // Flip to true once rewarded
      playerResource.rewarded = true

      // If the resource saved is a community NPC resource, immediately
      // save all of the others! These ids are 4001, 4002 and 4003
      if (resource.id > 4000 && resource.id < 4004) {
        $player.saveOtherCommunityResources(resource.id)
      }
    }

    // Save resource to DB
    _player.saveResourceToDb(playerResource)

    // Hack to not include demo users
    if ($player.firstName !== 'Demo') {
      // Add this to the DB of resources for all player answers
      var newAnswer = {
        resourceId: resource.id,
        playerId: $player.id,
        name: $player.firstName,
        answer: playerResource.answers[playerResource.answers.length - 1],
        madePublic: false
      }
      ss.rpc('game.npc.saveResponse', $player.instanceName, newAnswer)
    }

    // Display NPC bubble with number of comments & update minimap radar
    $player.displayNpcComments()
    $game.minimap.radar.update()
  },

  // If one of the community resources is obtained, call this function to save
  // all of the other ones so that the NPCs who hold them are unlocked
  // answeredResourceId will be the one that is answered, either 4001, 4002 or 4003
  // TODO: This is hacky, is there a better way of doing this?
  saveOtherCommunityResources: function (answeredResourceId) {
    // Hack for community inventory items
    // Dummy data for other resources
    var ids = [4001, 4002, 4003]
    var bunchOfResources = []
    var masterNpcResource = _resources[4000]

    for (var i = 0; i < ids.length; i++) {
      if (ids[i] === answeredResourceId) continue

      var dummyData = {
        id: ids[i],
        answer: '',
        attempts: 0,
        npcLevel: null,
        questionType: null,
        skinSuit: null,
        correct: true,
        rewarded: true,
        tagline: null
      }

      var dummyResource = $player.saveResourceLocally(dummyData)
      bunchOfResources.push(dummyResource)
    }

    // Also: the Master NPC resource article will be directed
    // to a resource equal to the 400x range, which are the
    // videos about that community.
    masterNpcResource.url = answeredResourceId.toString()
    bunchOfResources.push(dummyResource)

    // Also update on resources
    $game.$resources.get(4000).url = answeredResourceId.toString()

    ss.rpc('game.player.saveMoreThanOneResource', {
      id: $player.id,
      resources: bunchOfResources
    })
  },

  // show a bubble over visited npcs of how many comments there are
  displayNpcComments: function () {
    // Clear any previous comment bubbles
    $player.clearNpcComments()

    // Get on-screen NPCs
    var npcs = $game.$npc.getOnScreenNpcs()

    // Go thru each on-screen NPC; figure out what to display inside the bubble and what to display when player hovers over it.
    for (var n = 0; n < npcs.length; n++) {
      var npc = $game.$npc.get(npcs[n])
      var resourceId = npc.resource.id
      var theResource = _resources[resourceId]
      var contents = null
      var message = null

      // If player has obtained the NPC's resource
      if (theResource && theResource.result === true) {
        // For open-ended questions
        if (theResource.questionType === 'open') {
          contents = $game.$resources.get(resourceId).getNumResponses()
          if (contents > 0) {
            message = 'Click to view ' + contents + ' public answers'
          } else {
            message = 'There are no public answers, click to make yours public'
          }
        } else {
          // For all other question types
          contents = '*'
          message = 'You answered this question correctly'
        }
        _addBubble(npc, contents, message)
      } else if (($game.flags.check('local-radar') || $game.flags.check('global-radar'))) {
        // If player is wearing a rader that can sense if NPCs have a resource to give
        // Display indicator if:
        // - NPC is holding a resource
        // - player doesn't have it yet
        // - and the player is at least the NPC's level
        // (since it is possible to obtain NPC rewards from a
        // lower-level NPC if the player skipped it earlier)
        if (npc.isHolding === true && (!theResource || theResource.result === false) && $player.getLevel() >= npc.getLevel()) {
          contents = '!'
          message = 'This character has something for you!'
          _addBubble(npc, contents, message)
        }
      }
    }

    // Create and append the bubble to the gameboard
    function _addBubble (npc, contents, hoverMessage) {
      var gameboardEl = document.getElementById('gameboard')
      var npcPosition = npc.getLocalPosition()
      var resourceId = npc.resource.id
      var theBubble = _createBubbleElement(npc.id, contents)

      // Append bubble to gameboard
      gameboardEl.appendChild(theBubble)

      // Positioning
      $(theBubble).css({
        top: npcPosition.y - 68,
        left: npcPosition.x
      })

      // Bind mouse actions to the comment bubble
      // Show a message on hover
      $(theBubble).on('mouseenter', function () {
        _showMessageOnHover(hoverMessage)
      })

      // For open ended questions, display player responses on click
      if (_resources[resourceId] && _resources[resourceId].questionType === 'open') {
        $(theBubble).on('click', function () {
          _examineResponsesOnClick(resourceId)
        })
      }
    }

    function _createBubbleElement (npcId, contents) {
      var el = document.createElement('div')
      el.classList.add('npc-bubble')
      // Hacky way to style the '!' differently
      if (contents === '!') {
        el.classList.add('npc-bubble-exclaim')
      }
      el.id = 'npcBubble' + npcId
      el.textContent = contents
      return el
    }

    function _examineResponsesOnClick (resourceIndex) {
      $game.$resources.examineResponses(resourceIndex)
    }

    function _showMessageOnHover (message) {
      if (_.isString(message)) $game.alert(message)
    }
  },

  clearNpcComments: function () {
    $('.npc-bubble').remove()
  },

  // save the player's current position to the DB
  saveTimeToDB: function () {
    var endTime = new Date().getTime() / 1000
    var totalTime = parseInt(endTime - _startTime, 10)
    _playingTime += totalTime
    var info = {
      id: $player.id,
      playingTime: _playingTime
    }
    _startTime = endTime
    ss.rpc('game.player.updateGameInfo', info)
  },

  // call the save seed function from outside player
  saveSeeds: function () {
    _saveSeedsToDB()
  },

  // update the running array for current tiles colored to push to DB on end of drawing
  drawSeed: function (pos) {
    if (_seeds.draw > 0) {
      var drawLocal = false
      if ($player.seedMode === 'draw') {
        var currentTile = $game.$map.currentTiles[pos.x][pos.y]
        var index = currentTile.mapIndex

        // add to array and color if we haven't done it
        if (!_drawSeeds[index]) {
          $player.addSeeds('draw', -1)
          document.getElementById('graffiti').querySelector('.remaining').textContent = _seeds.draw
          drawLocal = true
          _drawSeeds[index] = {
            x: currentTile.x,
            y: currentTile.y,
            mapIndex: index,
            instanceName: $player.instanceName
          }
          // keep track area positions
          if (pos.x < _drawSeedArea.minX) {
            _drawSeedArea.minX = pos.x
          }
          if (pos.y < _drawSeedArea.minY) {
            _drawSeedArea.minY = pos.y
          }
          if (pos.x > _drawSeedArea.maxX) {
            _drawSeedArea.maxX = pos.x
          }
          if (pos.y > _drawSeedArea.maxY) {
            _drawSeedArea.maxY = pos.y
          }
        }
        // empty so add it
        if (drawLocal) {
          // blend the prev. color with new color
          // show auto
          $game.$map.currentTiles[pos.x][pos.y].colored = true

          // draw over the current tiles to show player they are drawing
          $game.$render.clearMapTile(pos)
          $game.$render.renderTile(pos.x, pos.y)
        }
      }
    } else {
      $player.dropSeed({mode: 'draw'})
      $game.flags.unset('draw-mode')
      $player.seedMode = false
      $BODY.off('mousedown touchstart', '#gameboard')
      $player.seedPlanting = false
      $game.alert('You are out of seeds!')
      _saveSeedsToDB()
    }
  },

  // put initial seed drawn in running array
  drawFirstSeed: function () {
    var pos = $game.$mouse.getCurrentPosition()
    _drawSeeds = {}
    _drawSeedArea = {
      minX: 29,
      maxX: 0,
      minY: 14,
      maxY: 0
    }
    $player.drawSeed(pos)
  },

  // if boss mode then must change up pos info
  setPositionInfo: function () {
    if ($game.bossModeUnlocked && $player.currentLevel > 3) {
      _info.x = 15
      _info.y = 8
    }
  },

  // turns off draw mode if no seeds left
  checkSeedLevel: function () {
    if (_seeds.draw <= 0) {
      $game.flags.unset('draw-mode')
    }
  },

  setMoveSpeed: function (multiplier) {
    _player.moveSpeed = multiplier || 1
  }

}

/**
  *
  *  PRIVATE FUNCTIONS
  *
 **/

var _player = {

  moveSpeed: 1,

  // * * * * * * *   SEEDING   * * * * * * *

  // Fgure out which tiles to color when a seed is dropped
  calculateSeeds: function (options) {
    var tiles = []

    // Get the tiles that need to be bombed
    if (options.radius > 1) {
      // If radius is more than 1, send a circular bomb
      // A radius of 1 is equal to a 3x3 area
      tiles = _getTilesInCircle(options.mX, options.mY, options.radius)
    } else {
      // Send a square bomb
      tiles = _getTilesInSquare(options.mX, options.mY, options.sz)
    }

    // Add additional info
    for (var i in tiles) {
      tiles[i].mapIndex = tiles[i].y * $game.TOTAL_WIDTH + tiles[i].x
      tiles[i].instanceName = $player.instanceName
    }

    // Utility function for getting an array of all points a radius from a particular X,Y
    function _getTilesInCircle (x, y, r) {
      var tiles = []

      for (var j = x - r; j <= x + r; j++)
        for (var k = y - r; k <= y + r; k++)
          if ((_distance({ x: j, y: k }, { x: x, y: y }) <= r) &&
              (_isInMap(j, k))) tiles.push({ x: j, y: k })

      return tiles
    }

    // Utility function for getting an array of all points in a square around X,Y
    function _getTilesInSquare (x, y, sz) {
      var tiles = []
      var mid = Math.floor(sz / 2)
      var cornerX = x - mid
      var cornerY = y - mid

      for (var j = cornerX; j < cornerX + sz; j++)
        for (var k = cornerY; k < cornerY + sz; k++)
          if (_isInMap(j, k)) tiles.push({ x: j, y: k })

      return tiles
    }

    // Utility function for finding the distance from a point
    function _distance (p1, p2) {
      var dx = p2.x - p1.x
      var dy = p2.y - p1.y
      dx *= dx
      dy *= dy
      return Math.sqrt(dx + dy)
    }

    // Utility function for verifying if a point is on the map
    function _isInMap (x, y) {
      return (x > -1 && x < $game.TOTAL_WIDTH && y > -1 && y < $game.TOTAL_HEIGHT) ? true : false
    }

    if (tiles.length > 0) {
      // Set a correct size for the bounding box if radius mode
      if (options.radius > 1) options.sz = (options.radius - 1) * 2 + 3

      var origX = options.mX - Math.floor(options.sz / 2)
      var origY = options.mY - Math.floor(options.sz / 2)

      _player.sendSeedBomb({
        bombed: tiles,
        options: options,
        x1: origX,
        y1: origY,
        x2: origX + options.sz,
        y2: origY + options.sz,
        kind: 'regular'
      })
    }
  },

  // plant the seed on the server and wait for response and update hud and map
  sendSeedBomb: function (data) {
    var waitingEl = document.getElementById('waiting-for-seed')

    // set a waiting boolean so we don't plant more until receive data back from rpc
    $game.flags.set('awaiting-seed')

    // send the data to the rpc
    var info = {
      id: $player.id,
      name: $player.firstName,
      x1: data.x1,
      y1: data.y1,
      x2: data.x2,
      y2: data.y2,
      tilesColored: _tilesColored,
      instanceName: $player.instanceName,
      kind: data.kind
    }

    var loc = $game.$map.masterToLocal(data.options.mX, data.options.mY)

    $(waitingEl)
      .css({
        top: loc.y * 32,
        left: loc.x * 32
      })
      .show()

    ss.rpc('game.player.dropSeed', data.bombed, info, function (result) {
      $game.flags.unset('awaiting-seed')

      $(waitingEl).fadeOut()
      if (result > 0) {
        _seeds.dropped += 1   // increase the drop count for the player
        $game.$audio.playTriggerFx('seedDrop')  // play sound clip
        _tilesColored += result

        if (data.kind === 'regular') {
          $player.addSeeds('regular', -1)

          // If player is out of seeds, end it
          if (_seeds.regular === 0) {
            _player.endSeedMode()
          }
        } else {
          $player.addSeeds('draw', 0)

          if (_seeds.draw === 0) {
            _player.endSeedMode()

            // Other actions unique to draw mode
            $game.flags.unset('draw-mode')
            $BODY.off('mousedown touchstart', '#gameboard')
            document.getElementById('graffiti').style.display = 'none'
          }
        }
      }
    })
  },

  // Generic end seed mode
  endSeedMode: function() {
    $player.seedMode = false
    $player.seedPlanting = false
    $game.alert('You are out of seeds!')
    $('.hud-seed').removeClass('hud-button-active')
    $player.saveMapImage(true)
    // TODO: save seed values to DB
    _saveSeedsToDB()
  },

  // Update seed counts
  updateTotalSeeds: function () {
    _totalSeeds = _seeds.regular + _seeds.draw

    $game.setBadgeCount('.hud-seed', _totalSeeds)
    $game.setBadgeCount('.regular-button', _seeds.regular)
    $game.setBadgeCount('.draw-button', _seeds.draw)
  },

  // * * * * * * *   DATABASE   * * * * * * *

  // Save a new resource to the database
  saveResourceToDb: function (resource) {
    ss.rpc('game.player.saveResource', {
      id: $player.id,
      resource: resource,
      inventory: $game.inventory.get(),
      resourcesDiscovered: _resourcesDiscovered
    })
  },

  // Saves player location
  savePosition: function (position) {
    // Location is an object with x and y properties
    // Update on server
    ss.rpc('game.player.savePosition', {
      id: $player.id,
      position: {
        x: position.x,
        y: position.y
      }
    })

    // Update on minimap
    $game.minimap.updatePlayer($player.id, position)
  }
}

// on init, set local and global variables for all player info
function _setPlayerInformation (info) {
  // Ensure that flags start from a clean state
  $game.flags.unsetAll()

  // private
  _seeds = info.game.seeds
  _previousSeedsDropped = _seeds.dropped
  _resources = _objectify(info.game.resources)
  $game.inventory.set(info.game.inventory)
  _colorMap = info.game.colorMap
  _resume = info.game.resume
  _playingTime = info.game.playingTime
  _tilesColored = info.game.tilesColored
  _pledges = info.game.pledges
  _resourcesDiscovered = info.game.resourcesDiscovered
  _skinSuit = info.game.skinSuit

  // hack
  // console.log(_resources);
  if (!_resources) {
    _resources = {}
  }

  // public
  $player.id = info.id
  $player.firstName = info.firstName
  $player.currentLevel = info.game.currentLevel
  $player.instanceName = info.game.instanceName
  $player.seenRobot = info.game.seenRobot
  $player.isMuted = info.game.isMuted
  $player.playerColor = info.game.playerColor

  $game.$botanist.setState(info.game.botanistState)

  if (info.game.firstTime === true) {
    $game.flags.set('first-time')
  }
}

// calculate new render information based on the player's position
function _updateRenderInfo () {
  // get local render information. update if appropriate.
  var loc = $game.$map.masterToLocal(_info.x, _info.y)
  if (loc) {
    var prevX = loc.x * $game.TILE_SIZE + _info.prevOffX * $game.STEP_PIXELS
    var prevY = loc.y * $game.TILE_SIZE + _info.prevOffY * $game.STEP_PIXELS
    var curX = loc.x * $game.TILE_SIZE + _info.offX * $game.STEP_PIXELS
    var curY = loc.y * $game.TILE_SIZE + _info.offY * $game.STEP_PIXELS

    _renderInfo.prevX = prevX
    _renderInfo.prevY = prevY
    _renderInfo.srcX = _info.srcX
    _renderInfo.srcY = _info.srcY
    _renderInfo.curX = curX
    _renderInfo.curY = curY
  }
}

// figure out how much to move the player during a walk and wait frame to show
function _move () {
  /** IMPORTANT note: x and y are really flipped!!! **/ // is this true?

  // First, check what step of animation we are on.
  // After one animation cycle, reset steps and moves
  if ($player.currentStep >= STEPS_PER_MOVE) {
    $player.currentStep = 0

    // The next move becomes the current player's current position
    _info.x = $player.seriesOfMoves[0].masterX
    _info.y = $player.seriesOfMoves[0].masterY

    // Once set, remove the move from queue immediately
    $player.seriesOfMoves.shift()

    // Update player's location on the minimap
    $game.minimap.updatePlayer($player.id, _info)
  }

  // If there are no more moves, finish
  if ($player.seriesOfMoves.length <= 0) {
    if ($game.bossModeUnlocked && $player.currentLevel > 3) {
      _info.offX = 0
      _info.offY = 0
      _info.srcX = 0
      _info.srcY = 0
      _info.prevOffX = 0
      _info.prevOffY = 0

      $game.$boss.endMove(_info)
    } else {
      _endMove()
    }
  } else {
    // There are more moves, keep setting each frame of animation
    var currentSpeed = $player.getMoveSpeed()

    // If it is the first step, then figure out which direction to face
    if ($player.currentStep === 0) {
      _currentStepIncX = $player.seriesOfMoves[0].masterX - _info.x
      _currentStepIncY = $player.seriesOfMoves[0].masterY - _info.y

      // set the previous offsets to 0 because the last visit
      // was the actual rounded master
      _info.prevOffX = 0
      _info.prevOffY = 0

      // set direction for sprite sheets
      // direction refers to the y location on the sprite sheet
      // since the character will be in different rows
      // will be 0,1,2,3
      if (_currentStepIncX === 1) {
        _direction = 2
      } else if (_currentStepIncX === -1) {
        _direction = 1
      } else if (_currentStepIncY === -1) {
        _direction = 4
      } else {
        _direction = 3
      }
    } else {
      _info.prevOffX = _info.offX
      _info.prevOffY = _info.offY
    }

    _info.offX = $player.currentStep * _currentStepIncX
    _info.offY = $player.currentStep * _currentStepIncY

    // Change the source frame every X frames
    // We need to get a number between 0 and whatever that is guanteed to be modulo'able by 8
    // and hit zero, so that the frame can increment
    if ((Math.floor($player.currentStep / currentSpeed) - 1) % 8 === 0) {
      _curFrame += 1
      // Reset when we have hit the number of frames
      if (_curFrame >= 4) {
        _curFrame = 0
      }
    }
    _info.srcX = _curFrame * $game.TILE_SIZE
    _info.srcY = _direction * $game.TILE_SIZE * 2

    // Increment the current step
    $player.currentStep += 1 * currentSpeed
  }
}

// once the move is sent out to all players, update the players next moves
function _sendMoveInfo (moves) {
  $game.flags.set('is-moving')

  // If there is already a move queue, let the current one finish and\
  // then replace the rest with the new moves.
  if ($player.seriesOfMoves.length > 0) {
    $player.seriesOfMoves.splice(1, Number.MAX_VALUE)
    $player.seriesOfMoves = $player.seriesOfMoves.concat(moves)
  } else {
    $player.seriesOfMoves = moves
  }
}

// When a move is done, decide what to do next (if it is a transition)
function _endMove () {
  // Save position to database
  _player.savePosition({
    x: _info.x,
    y: _info.y
  })

  // Put the character back to normal position
  _info.offX = 0
  _info.offY = 0
  _info.srcX = 0
  _info.srcY = 0
  _info.prevOffX = 0
  _info.prevOffY = 0

  // Are we at the edge of the world?
  if ($game.$map.isMapEdge($player.getLocalPosition()) === true) {
    $game.alert('Edge of the world!')
  }

  // Transition screen if the player can move to the next screen
  if ($game.flags.check('screen-will-transition') === true) {
    $game.beginTransition()
  }

  // Update music
  $game.$audio.update()

  // Activate NPC
  // npcOnDeck can equal zero so be sure to check against boolean, rather than falsy
  if ($player.npcOnDeck !== false) {
    $game.$npc.activate($player.npcOnDeck)
  }

  $game.flags.unset('is-moving')
}

// determine what frame to render while standing
function _idle () {
  _idleCounter += 1

  if (_idleCounter >= 64) {
    _idleCounter = 0
    _info.srcX = 0
    _info.srcY = 0
    _renderInfo.squat = false

    _updateRenderInfo()
  } else if (_idleCounter === 48) {
    _info.srcX = 32
    _info.srcY = 0
    _renderInfo.squat = true

    _updateRenderInfo()
  }
}

// save current seed data to db
function _saveSeedsToDB () {
  var info = {
    id: $player.id,
    seeds: _seeds,
    tilesColored: _tilesColored
  }
  ss.rpc('game.player.updateGameInfo', info)
}

// turn array into object
function _objectify (input) {
  var result = {}
  for (var i = 0; i < input.length; i++) {
    result[input[i].id] = input[i]
    result[input[i].id].arrayLookup = i
  }
  return result
}

// Temporary global for player colors.
// TODO: Use the backend for this?
var COLORS = [
  {
    'r': 255,
    'g': 255,
    'b': 255
  },
  {
    'r': 205,
    'g': 95,
    'b': 243
  },
  {
    'r': 106,
    'g': 220,
    'b': 230
  },
  {
    'r': 242,
    'g': 202,
    'b': 93
  },
  {
    'r': 184,
    'g': 239,
    'b': 98
  },
  {
    'r': 236,
    'g': 109,
    'b': 168
  },
  {
    'r': 109,
    'g': 227,
    'b': 209
  },
  {
    'r': 242,
    'g': 243,
    'b': 95
  },
  {
    'r': 146,
    'g': 235,
    'b': 103
  },
  {
    'r': 245,
    'g': 97,
    'b': 93
  },
  {
    'r': 108,
    'g': 230,
    'b': 179
  },
  {
    'r': 241,
    'g': 166,
    'b': 92
  },
  {
    'r': 242,
    'g': 95,
    'b': 218
  },
  {
    'r': 242,
    'g': 223,
    'b': 95
  },
  {
    'r': 243,
    'g': 231,
    'b': 99
  },
  {
    'r': 241,
    'g': 93,
    'b': 103
  },
  {
    'r': 233,
    'g': 239,
    'b': 98
  },
  {
    'r': 243,
    'g': 101,
    'b': 121
  },
  {
    'r': 203,
    'g': 245,
    'b': 93
  },
  {
    'r': 227,
    'g': 93,
    'b': 183
  },
  {
    'r': 245,
    'g': 113,
    'b': 91
  },
  {
    'r': 122,
    'g': 235,
    'b': 158
  },
  {
    'r': 242,
    'g': 150,
    'b': 95
  },
  {
    'r': 220,
    'g': 245,
    'b': 94
  },
  {
    'r': 214,
    'g': 95,
    'b': 243
  }
]