When a player starts the game, we commit a #world
, a #player
, and some #obstacles
. These will keep all of the essential state of the game. All of this information could have been stored on the world, but for clarity we break the important bits of state into objects that they effect.
- The
#world
tracks the distance the player has travelled, the current game screen, and the high score. - The
#player
stores his current y position and (vertical) velocity. - The
obstacles
have their (horizontal) offset and gap widths. We put distance on the world and only keep two obstacles; rather than moving the player through the world, we keep the player stationary and move the world past the player. When an obstacle goes off screen, we will wrap it around, update the placement of its gap, and continue on.
Add a flappy eve and a world for it to flap in:
commit
[#player #self name: "eve" x: 25 y: 50 velocity: 0]
[#world screen: "menu" tick: 0 distance: 0 best: 0 gravity: -0.061]
[#obstacle gap: 35 offset: 0]
[#obstacle gap: 35 offset: -1]
[#time #system/timer resolution: 16.66666666667]
Next we draw the backdrop of the world. The player and obstacle will be drawn later based on their current state. Throughout the app we use resources from [@bhauman's flappy bird demo in clojure][1]. Since none of these things change over time, we commit them once when the player starts the game.
search
world = [#world]
commit
world <- [#ui/div style: [user-select: "none" -webkit-user-select: "none" -moz-user-select: "none"] children:
[#svg/root #game-window viewBox: "10 0 80 100", width: 480 children:
[#svg/rect x: 0 y: 0 width: 100 height: 53 fill: "rgb(112, 197, 206)" sort: 0]
[#svg/image x: 0 y: 52 width: 100 height: 43 preserveAspectRatio: "xMinYMin slice" href: "https://cdn.rawgit.com/bhauman/flappy-bird-demo/master/resources/public/imgs/background.png" sort: 1]
[#svg/rect x: 0 y: 95 width: 100 height: 5 fill: "rgb(222, 216, 149)" sort: 0]]]
These following blocks handle drawing the game's other screens (such as the main menu and the game over scene).
The main menu displays a message instructing the player how to start the game.
search
[#world screen: "menu"]
svg = [#game-window]
bind
svg.children += [#svg/text x: 50 y: 45 text-anchor: "middle" font-size: 6 text: "Click the screen to begin!" sort: 10]
The "game over" screen displays the final score of the last game, the high score of all games, and a message inviting the player to play the game again.
search
[#world screen: "game over" score best]
svg = [#game-window]
bind
svg.children += [#svg/text x: 50 y: 30 text-anchor: "middle" font-size: 6 text: "Game Over :(" sort: 10]
svg.children += [#svg/text x: 50 y: 55 text-anchor: "middle" font-size: 6 text: "Score {{score}}" sort: 10]
svg.children += [#svg/text x: 50 y: 65 text-anchor: "middle" font-size: 6 text: "Best {{best}}" sort: 10]
svg.children += [#svg/text x: 50 y: 85 text-anchor: "middle" font-size: 4 text: "Click to play again!" sort: 10]
We haven't calculated the score yet, so let's do that. We calculate the score as the floor
of the distance, meaning we just round the distance down to the nearest integer. If the distance between pipes is changed, this value can be scaled to search.
search
world = [#world distance]
bind
world.score := math/floor[value: distance]
When the game is on the "menu" or "game over" screens, a click anywhere in the application will (re)start the game. Additionally, if the current score is better than the current best, we'll swap them out now. Along with starting the game, we make sure to reset the distance and player positions in the came of a restart.
search
world = [#world]
[#html/event/click]
ok = if world.screen = "menu" then "true"
else if world.screen = "game over" then "true"
new = if world.score > world.best then world.score
else world.best
player = [#player]
commit
world.screen := "game"
world.distance := 0
world.best := new
player.y := 50
player.velocity := 0
Next we draw the #player
at its (x,y) coordinates. Since the player is stationary in x, setting his x position here dynamically is just a formality, but it allows us to configure his position on the screen when we initialize. We create the sprite first, then set the x and y positions to let us reuse the same element regardless of where the player is.
Draw the player
search
svg = [#game-window]
player = [#player x y]
bind
sprite = [#svg/image player | width: 10 height: 10 href: "http://i.imgur.com/sp68LtM.gif" sort: 8]
sprite.x := x - 5
sprite.y := y - 5
svg.children += sprite
Drawing obstacles is much the same process as drawing the player, but we encapsulate the sprites into a nested SVG to group and move them as a unit.
Draw the obstacles
search
svg = [#game-window]
obstacle = [#obstacle x height gap]
bottom-height = height + gap
imgs = "https://cdn.rawgit.com/bhauman/flappy-bird-demo/master/resources/public/imgs"
bind
sprite-group = [#svg/element tagname: "svg" #obs-spr obstacle | sort: 2 overflow: "visible" children:
[#svg/image obstacle y: 0 width: 10 height, preserveAspectRatio: "none" href: "{{imgs}}/pillar-bkg.png" sort: 1]
[#svg/image obstacle x: -1 y: height - 5 width: 12 height: 5 href: "{{imgs}}/lower-pillar-head.png" sort: 2]
[#svg/image obstacle y: bottom-height width: 10 height: 90 - bottom-height, preserveAspectRatio: "none" href: "{{imgs}}/pillar-bkg.png" sort: 1]
[#svg/image obstacle x: -1 y: bottom-height width: 12 height: 5 href: "{{imgs}}/lower-pillar-head.png" sort: 2]]
sprite-group.x := x
svg.children += sprite-group
Now we need some logic to actually play the game. We slide obstacles along proportional to the distance travelled, and wrap them around to the beginning once they're entirely off screen. Additionally, we only show obstacles once their distance travelled is positive. This allows us to offset a pipe in the future, without the modulo operator wrapping it around to start off halfway through the screen.
Every 2 distance a wild obstacle appears
search
[#world distance]
obstacle = [#obstacle offset]
obstacle-distance = distance + offset
obstacle-distance >= 0
bind
obstacle.x := 100 - (50 * math/mod[value: obstacle-distance, by: 2])
When the obstacle is offscreen (x > 90
), we randomly adjust the height of its gap to ensure the game doesn't play the same way twice. Eve's current random implementation yields a single result per seed per evaluation, so you can ask for random[seed: "foo"]
in multiple queries and get the same result in that evaluation. In practice, this means that for every unique sample of randomness you care about in a program at a fixed time, you should use a unique seed. In this case, since we want one sample per obstacle, we just use the obstacle UUIDs as our seeds. The magic numbers in the equation just keep the gap from being at the very top of the screen or underground.
Readjust the height of the gap every time the obstacle resets
search
[#time tick]
[#world screen: "game" tick != tick]
obstacle = [#obstacle x > 98]
height = random/number[seed: tick] * 30 + 5
commit
obstacle.height := height
When a player clicks during gameplay, we give the bird some lift by setting its velocity.
search
[#html/event/click]
[#world screen: "game"]
player = [#player #self]
commit
player.velocity := 1.17
Next, we scroll the world in time with tick updates. Eve is currently locked to 60fps updates here, but this will probably be configurable in the future. Importantly, we only want to update the world state once per tick, so to ensure that we note the offset of the tick we last computed in world.tick
and ensure we’re not recomputing for the same offset.
search
[#time tick]
world = [#world screen: "game" tick != tick gravity]
player = [#player y velocity]
not([#html/event/click])
commit
world.tick := tick
world.distance := world.distance + 1 / 60
player.y := y - velocity
player.velocity := velocity + gravity
Checking collision with the ground is very simple. Since we know the y height of the ground, we just check if the player's bottom (determined by center + radius) is below that point.
The game is lost if the player hits the ground.
search
world = [#world screen: "game"]
[#player y > 85] // ground height + player radius
commit
world.screen := "game over"
Collision with the pipes is only slightly harder. Since they come in pairs, we first determine if the player is horizontally in a slice that may contain pipes and if so, whether we're above or below the gap. If neither, we're in the clear, otherwise we've collided.
The game is lost if the player hits an #obstacle
search
world = [#world screen: "game"]
[#player x y]
[#obstacle x: obstacle-x height gap]
∂x = math/absolute[value: obstacle-x + 5 - x] - 10 // distance between the edges of player and obstacle (offset of 1/2 obstacle width because origin is on the left)
∂x < 0
collision = if y - 5 <= height then true
else if y + 5 >= gap + height then true
commit
world.screen := "game over"