elm/Examples/elm-js/Pong/Pong.elm

305 lines
12 KiB
Elm

{----- Overview ------------------------------------------------------
This game displays some of the strengths of Functional Reactive
Programming (FRP). By the end of this file we will have written an
entire GUI/game without any imperative code! No global mutable state,
no flipping pixels, no destructive updates. In fact, Elm disallows all
of these things at the language level. So good design and safe coding
practices are a requirement, not just self-inforced suggestions.
This code neatly divides Pong into three major parts: modeling the
game, updating the game, and viewing the game. It may be helpful to
think of it as a functional variation on the Model-View-Controller
paradigm.
The code for Pong is structured as follows:
1. MODEL:
First we need to define Pong. We do this by modelling Pong with
simple data structures. We need two categories of model:
- Inputs to the game. For Pong, this is keyboard input from
users and clock-ticks from the frame rate manager.
- A model of the game itself: paddles, ball, score, etc.
Without a model of the game we would have nothing to update
or display!
These models are the basis for the next two sections, holding
all of the information about Pong that we will need.
2. UPDATE:
When new inputs come in, we need to update the current state
of the game. Without updates, this version of Pong would be very
very boring! This section defines a number of 'step functions'
that step the game forward based on our inputs. By separating
this from the model and display code, we can change how the game
works (how it steps forward) without changing anything else: the
underlying model and the display code need not be touched.
3. VIEW:
Finally, we need a display function that defines the user's view
of the game. This code is separate from the game logic, so like
the update logic, it can be modified without affecting any other
part of the program. We can also define many different views
of the same underlying model. In Pong there's not much need for
this, but as your model becomes more complex this may be very
useful!
If you would like to make a game or larger application in Elm, use
this structure! Maybe even use this file as a starting point for
playing around with your own ideas.
Let's get started!
-----------------------------------------------------------------------}
module Pong where
import JavaScript
import Keyboard.Raw
time = lift (\t -> t / 1000) (Time.fps 60)
-- Determine the time that has elapsed since the last event. This is
-- our timestep, telling us how far everything should move in the next
-- frame.
delta = lift snd $ foldp (\t1 (t0,d) -> (t1, t1-t0)) (0,0) time
------------------------------------------------------------------------
------ Modelling user input ------
------------------------------------------------------------------------
-- Each paddle can be moving up, down, or not at all. We'll call this
-- the `direction' of the paddle.
data Direction = Up | Neutral | Down
-- During gameplay, all keyboard input is about the position of the two
-- paddles. So the keyboard input can be reduced to two `Directions'.
-- Furthermore, the SPACE key is used to start the game between rounds,
-- so we also need a boolean value to represent whether it is pressed.
data KeyInput = KeyInput Bool Direction Direction
defaultKeyInput = KeyInput False Neutral Neutral
-- Now we determine how to update the direction of a paddle based on
-- keyboard input. The first two args of `updatePaddle` are the key
-- codes of the up and down keys. The next argument is the key that
-- has been pressed. The last argument is the previously calculated
-- direction of the paddle, which is getting updated.
updateDirection upKey downKey key direction =
case direction of
Up -> if key == downKey then Neutral else Up
Down -> if key == upKey then Neutral else Down
Neutral -> if key == upKey then Up else
if key == downKey then Down else Neutral
updateDirection1 = updateDirection 87 83 -- 'w' for up and 's' for down
updateDirection2 = updateDirection 38 40 -- 'UP' for up and 'DOWN' for down
-- Update the keyboard input representation based on a particular key press.
updateInput key (KeyInput space dir1 dir2) =
KeyInput (space || key == 32)
(updateDirection1 key dir1)
(updateDirection2 key dir2)
-- `keysDown` has type (Signal [Int]) so we need to fold `updateInput`
-- over all of the keys that are currently pressed to get the current
-- keyboard state.
keyInput = lift (foldl updateInput defaultKeyInput) keysDown
------------------------------------------------------------------------
------ Combining all inputs ------
------------------------------------------------------------------------
-- The inputs to this game include a timestep (which we extracted from
-- JavaScript) and the keyboard input from the users.
data Input = Input Float KeyInput
-- Combine both kinds of inputs and filter out keyboard events. We only
-- want the game to refresh on clock-ticks, not key presses too.
input = sampleOn delta (lift2 Input delta keyInput)
------------------------------------------------------------------------
------ Modelling Pong / a State Machine ------
------------------------------------------------------------------------
-- Pong has two obvious components: the ball and two paddles.
data Paddle = Paddle Float -- y-position
data Ball = Ball (Float,Float) (Float,Float) -- position and velocity
-- But we also want to keep track of the current score and whether
-- the ball is currently in play. This will allow us to have rounds
-- of play rather than just having the ball move around continuously.
data Score = Score Int Int
data State = Play | BetweenRounds
-- Together, this information makes up the state of the game. We model
-- Pong by using the inputs (defined above) to update the state of the
-- game!
data GameState = GameState State Score Ball Paddle Paddle
-- I have chosen to parameterize the size of the board, so it can
-- be changed with minimal effort.
gameWidth = 600
gameHeight = 400
halfWidth = gameWidth / 2
halfHeight = gameHeight / 2
-- Before we can update anything, we must first define the default
-- configuration of the game. In our case we want to start between
-- rounds with a score of zero to zero.
defaultGame = GameState BetweenRounds
(Score 0 0)
(Ball (halfWidth, halfHeight) (150,150))
(Paddle halfHeight)
(Paddle halfHeight)
------------------------------------------------------------------------
------ Stepping from State to State ------
------------------------------------------------------------------------
-- Now to step the game from one state to another. We can break this up
-- into smaller components.
-- First, we define a step function for updating the position of
-- paddles. It depends on our timestep and a desired direction (given
-- by keyboard input).
stepPaddle delta dir (Paddle y) =
case dir of
Up -> Paddle $ clamp 20 (gameHeight-20) (y - 200 * delta)
Down -> Paddle $ clamp 20 (gameHeight-20) (y + 200 * delta)
Neutral -> Paddle y
-- We must also step the ball forward. This is more complicated due to
-- the many kinds of collisions that can happen. All together, this
-- function figures out the new velocity of the ball based on
-- collisions with the top and bottom borders and collisions with the
-- paddles. This new velocity is used to calculate a new position.
-- This function also determines whether a point has been scored and
-- who receives the point. Thus, its output is a new Ball and points
-- to be added to each player.
makePositive n = if n > 0 then n else 0-n
makeNegative n = if n > 0 then 0-n else n
within epsilon n x = x > n - epsilon && x < n + epsilon
stepVelocity velocity lowerCollision upperCollision =
if lowerCollision then makePositive velocity else
if upperCollision then makeNegative velocity else velocity
stepBall delta (Ball (x,y) (vx,vy)) (Paddle y1) (Paddle y2) =
let hitPaddle1 = within 20 y1 y && within 8 25 x
hitPaddle2 = within 20 y2 y && within 8 (gameWidth - 25) x
vx' = stepVelocity vx hitPaddle1 hitPaddle2
vy' = stepVelocity vy (y < 7) (y > gameHeight - 7)
scored = x > gameWidth || x < 0
x' = if scored then halfWidth else x + vx' * delta
y' = if scored then halfHeight else y + vy' * delta
in ( Ball (x',y') (vx',vy')
, if x > gameWidth then 1 else 0
, if x < 0 then 1 else 0 )
-- Finally, we define a step function for the entire game. This steps from state to
-- state based on the inputs to the game.
stepGame (Input delta (KeyInput space dir1 dir2))
(GameState state (Score s1 s2) ball paddle1 paddle2) =
let (ball',s1',s2') = if state == Play then stepBall delta ball paddle1 paddle2
else (ball, 0, 0)
state' = case state of
Play -> if s1' /= s2' then BetweenRounds else state
BetweenRounds -> if space then Play else state
in GameState state'
(Score (s1+s1') (s2+s2'))
ball'
(stepPaddle delta dir1 paddle1)
(stepPaddle delta dir2 paddle2)
-- Now we put it all together. We have a signal of inputs that changes whenever there
-- is a clock tick. This input signal carries the all the information we need about
-- the keyboard. We also have a step function that steps from one game-state to the
-- next based on some inputs.
-- The `gameState` signal steps forward every time a new input comes in. It starts
-- as the default game and progresses based on user behavior.
gameState = foldp stepGame defaultGame input
------------------------------------------------------------------------
------ Displaying the Game ------
------------------------------------------------------------------------
-- These functions take a GameState and turn it into something a user
-- can see and understand. It is totally independent of how the game
-- updates, it only needs to know the current game state. This allows us
-- to change how the game looks without changing any of the logic of the
-- game.
-- This function displays the current score and directions.
scoreBoard w inPlay p1 p2 =
let code = text . monospace . toText
stack top bottom = flow down [ code " ", code top, code bottom ]
msg = width w . centeredText . monospace $ toText "Press SPACE to begin"
board = flow right [ stack "W" "S", spacer 20 1
, text . Text.height 4 . toText $ show p1 ++ " " ++ show p2
, spacer 20 1, stack "&uarr;" "&darr;" ]
score = container w (heightOf board) midTop board
in if inPlay then score else score `above` msg
-- This function displays the entire GameState.
display (w,h) (GameState state (Score p1 p2) (Ball pos _) (Paddle y1) (Paddle y2)) =
layers
[ let pongGreen = rgb 60 100 60 in
container w h middle $ collage gameWidth gameHeight
[ filled pongGreen (rect gameWidth gameHeight (halfWidth,halfHeight))
, filled white (oval 15 15 pos) -- ball
, filled white (rect 10 40 ( 20, y1)) -- first paddle
, filled white (rect 10 40 (gameWidth - 20, y2)) -- second paddle
]
, scoreBoard w (state == Play) p1 p2
]
-- We can now define a view of the game (a signal of Elements) that changes
-- as the GameState changes. This is what the users will see.
main = lift2 display Window.dimensions gameState