collider.coffee | |
---|---|
Collider is a simple game
| |
Setup the environment | |
Some basic parameters for our game | gameOptions =
height: 450
width: 700
nEnemies: 30
padding: 20 |
Somewhere to dump the score | gameStats =
score: 0
bestScore: 0 |
Setup the game board | |
Axes | |
Our game coordinates range from 0 to 100 in both x and y axes. This gets mapped to our pixelled game area using these scale functions | axes =
x: d3.scale.linear().domain([0,100]).range([0,gameOptions.width])
y: d3.scale.linear().domain([0,100]).range([0,gameOptions.height]) |
Game Board (svg region) | |
This looks like jQuery, and it is.
| gameBoard = d3.select('.container').append('svg:svg')
.attr('width', gameOptions.width)
.attr('height', gameOptions.height) |
Scores | |
Update our scoreboard, which is just in an html span | updateScore = ->
d3.select('#current-score')
.text(gameStats.score.toString())
|
Update our best score if current score is bigger, and update the scoreboard | updateBestScore = ->
gameStats.bestScore =
_.max [gameStats.bestScore, gameStats.score]
d3.select('#best-score').text(gameStats.bestScore.toString()) |
The PlayerThe player is a circle that the user can drag around the board with their mouse | |
Let's stick everything in a class to keep it clean | class Player |
An svg path, created using this tool to give our player a teardrop shape | path: 'm-7.5,1.62413c0,-5.04095 4.08318,-9.12413 9.12414,-9.12413c5.04096,0 9.70345,5.53145 11.87586,9.12413c-2.02759,2.72372 -6.8349,9.12415 -11.87586,9.12415c-5.04096,0 -9.12414,-4.08318 -9.12414,-9.12415z' |
Some state for the player to maintain | fill: '#ff6600'
x: 0
y: 0
angle: 0
r: 5 |
We need the gameOptions hash to restrict his motion | constructor: (gameOptions) ->
@gameOptions = gameOptions
|
Render the path to the gameBoard, and moves it to the middle also initializes dragging on the svg element | render: (to) =>
@el = to.append('svg:path')
.attr('d', @path)
.attr('fill', @fill)
@transform
x: @gameOptions.width * 0.5
y: @gameOptions.height * 0.5
@setupDragging()
this |
Getters and setters to ensure the player stays within the game boundary | getX: => @x
setX: (x) =>
minX = @gameOptions.padding
maxX = @gameOptions.width - @gameOptions.padding
x = minX if x <= minX
x = maxX if x >= maxX
@x = x
getY: => @y
setY: (y) =>
minY = @gameOptions.padding
maxY = @gameOptions.height - @gameOptions.padding
y = minY if y <= minY
y = maxY if y >= maxY
@y = y |
Since the player is an svg:path, we have to move/rotate him using transform. This method just lets us set any/all of the attributes and the rest will be taken from his internal state | transform: (opts) =>
@angle = opts.angle || @angle
@setX opts.x || @x
@setY opts.y || @y
@el.attr 'transform',
"rotate(#{@angle},#{@getX()},#{@getY()}) "+
"translate(#{@getX()},#{@getY()})" |
Moves the player to an absolute position on the gameboard | moveAbsolute: (x,y) =>
@transform
x:x
y:y |
Moves the player to a relative position, rotating him based on which direction he is moving | moveRelative: (dx,dy) =>
@transform
x: @getX()+dx
y: @getY()+dy
angle: 360 * (Math.atan2(dy,dx)/(Math.PI*2)) |
Use d3's behaviors to make the player draggable
| setupDragging: =>
dragMove = =>
@moveRelative(d3.event.dx, d3.event.dy)
drag = d3.behavior.drag()
.on('drag', dragMove)
@el.call(drag) |
Create our player by rendering him to the gameBoard | players = []
players.push new Player(gameOptions).render(gameBoard)
players.push new Player(gameOptions).render(gameBoard) |
EnemiesThe enemies are an array of simple objects with positions and an id,
they get rendered and updated in the | |
Creates an array of enemy data with random x and y positions by also creating an id, d3 can keep track of the enemies and move them rather than creating new ones later | createEnemies = ->
_.range(0,gameOptions.nEnemies).map (i) ->
{
id: i
x: Math.random()*100
y: Math.random()*100
} |
Rendering the gameboard | render = (enemy_data) -> |
Select all the enemies on the board and bind the data to them, using the enemies' id attribute as a key to ensure we update enemies in the future | enemies = gameBoard.selectAll('circle.enemy')
.data(enemy_data, (d) -> d.id) |
enter()any enemies which have just entered the game,
i.e. who haven't already got a circle bound to
an
| enemies.enter()
.append('svg:circle')
.attr('class', 'enemy')
.attr('cx', (enemy) -> axes.x(enemy.x))
.attr('cy', (enemy) -> axes.y(enemy.y))
.attr('r', 0) |
exit()if we have removed any enemies (currently this won't happen)
i.e. a circle is bound to an | enemies.exit()
.remove() |
update()If an enemies We will do this using a custom tween so we can test whether any enemies collide with our player on each step of the animation | |
Collision Detectionvery simple collision detection find the distance between the centers of an enemy and the players and if it's less the sum of their radii, there's been a collision so invoke the callback | checkCollision = (enemy, collidedCallback) ->
_(players).each (player) ->
radiusSum = parseFloat(enemy.attr('r')) + player.r
xDiff = parseFloat(enemy.attr('cx')) - player.x
yDiff = parseFloat(enemy.attr('cy')) - player.y
separation = Math.sqrt( Math.pow(xDiff,2) + Math.pow(yDiff,2) )
collidedCallback(player, enemy) if separation < radiusSum |
If we have a collision, just reset the score | onCollision = ->
updateBestScore()
gameStats.score = 0
updateScore() |
Custom TweenCreate a custom tween, that tests if the enemy has
collided with the player on each tick
The tween gets called once at the start of the tween
with the data we are tweening to.
The tween should yield a function that takes a "timestep" | tweenWithCollisionDetection = (endData) -> |
| enemy = d3.select(this) |
Get the initial position of the enemy | startPos =
x: parseFloat enemy.attr('cx')
y: parseFloat enemy.attr('cy') |
Map our endData to endPosition using our axes | endPos =
x: axes.x(endData.x)
y: axes.y(endData.y) |
Return our custom tween function | return (t) ->
checkCollision(enemy, onCollision) |
Next position, is `start_position + (end_position - start_position)*timestep | enemyNextPos =
x: startPos.x + (endPos.x - startPos.x)*t
y: startPos.y + (endPos.y - startPos.y)*t |
Update the enemy's position | enemy.attr('cx', enemyNextPos.x)
.attr('cy', enemyNextPos.y) |
Bind two transitions to the enemies when they are created/updated
| enemies
.transition()
.duration(500)
.attr('r', 10)
.transition()
.duration(2000)
.tween('custom', tweenWithCollisionDetection) |
Play the game! | |
Kick off the game, and set the turn iterations off
| play = ->
gameTurn = ->
newEnemyPositions = createEnemies()
render(newEnemyPositions)
increaseScore = ->
gameStats.score += 1
updateScore() |
Take a turn every 2 seconds | gameTurn()
setInterval gameTurn, 2000 |
Increment the score counter every 50ms | setInterval increaseScore, 50 |
Play! | play()
|