module Main exposing (..) import Time exposing (..) import List exposing (..) import Tuple exposing (..) import AnimationFrame import Keyboard.Extra exposing (Key(..)) import Window import Collage exposing (..) import Element exposing (..) import Color exposing (..) import Html exposing (Html) import Debug import Text import Audio exposing (PlaybackOptions, defaultPlaybackOptions, Sound) import Task exposing (Task, andThen) import String exposing (padLeft) -- MODEL type State = Loading | NewGame | Starting | Play | Pausing | Pause | Resume | GameOver type alias Player = { angle : Float } type Direction = Left | Right | Still type alias Enemy = { radius : Float , parts : List (Bool) } type alias Game = { player : Player , direction : Direction , enemies : List (Enemy) , enemySpeed : Float , pressedKeys : List Key , state : State , timeStart : Time , timeTick : Time , msRunning : Float , autoRotateAngle : Float , autoRotateSpeed : Float , hasBass : Bool , music : Maybe Sound } type alias Colors = { dark : Color , medium : Color , bright : Color } type Msg = Step Time | KeyboardMsg Keyboard.Extra.Msg | MusicLoaded Sound | Error String | Noop ( gameWidth, gameHeight ) = ( 1024, 576 ) ( halfWidth, halfHeight ) = ( gameWidth / 2, gameHeight / 2 ) ( iHalfWidth, iHalfHeight ) = ( gameWidth // 2, gameHeight // 2 ) playerRadius : Float playerRadius = gameWidth / 10.0 playerSize : Float playerSize = 10.0 playerSpeed : Float playerSpeed = 0.12 enemyThickness = 30 enemyDistance = 350 enemyInitialSpeed = 0.25 enemyAcceleration = 0.000002 enemies = [ [ False, True, False, True, False, True ] , [ False, True, True, True, True, True ] , [ True, False, True, True, True, True ] , [ True, True, True, True, False, True ] , [ True, True, True, False, True, True ] , [ False, True, False, True, False, True ] , [ True, False, True, False, True, False ] , [ True, True, True, True, True, False ] ] startMessage = "SPACE to start, ←→ to move" beat = 138.0 |> bpm beatAmplitude = 0.06 beatPhase = 270 |> degrees -- Calculate Beat Per Minute bpm : Float -> Float bpm beat = (2.0 * pi * beat / 60) pump : Float -> Float pump progress = beatAmplitude * (beat * progress / 1000 + beatPhase |> sin) -- MUSIC hasBass : Time -> Bool hasBass time = if time < 20894 then False else if time < 41976 then True else if time < 55672 then False else if time < 67842 then True else if time < 187846 then False else if time < 215938 then True else False loadSound : Task String Sound loadSound = Audio.loadSound "music/shinytech.mp3" soundLoaded : Result String Sound -> Msg soundLoaded result = case result of Ok music -> MusicLoaded music Err msg -> Error msg playbackOptions = { defaultPlaybackOptions | loop = True , startAt = Nothing } playSound : Sound -> PlaybackOptions -> Cmd Msg playSound sound options = Task.attempt (always Noop) (Audio.playSound options sound) stopSound : Sound -> Cmd Msg stopSound sound = Task.perform (always Noop) (Audio.stopSound sound) bgBlack : Color bgBlack = rgb 20 20 20 -- UPDATE updatePlayerAngle : Float -> Direction -> Float updatePlayerAngle angle dir = let sign = if dir == Left then 1 else if dir == Right then -1 else 0 newAngle = angle + toFloat sign * playerSpeed in if newAngle < 0 then newAngle + 2 * pi else if newAngle > 2 * pi then newAngle - 2 * pi else newAngle collidesWith : Player -> Enemy -> Bool collidesWith player enemy = let collidesAtIndex : Int -> Bool collidesAtIndex index = let fromAngle = (toFloat index) * 60 toAngle = ((toFloat index) + 1) * 60 playerDegrees = player.angle * 360 / (2 * pi) in playerDegrees >= fromAngle && playerDegrees < toAngle in if enemy.radius > playerRadius || enemy.radius + enemyThickness < playerRadius - playerSize * 3 / 2 then False else -- check if open indexedMap (,) enemy.parts |> filter Tuple.second |> map Tuple.first |> any collidesAtIndex updatePlayer : Direction -> Game -> Player updatePlayer dir { player, enemies, state } = if state == Play then let newAngle = if state == NewGame then degrees 30 else updatePlayerAngle player.angle dir newPlayer = { player | angle = newAngle } in -- stop rotating if there is an enemy passing the ship if any (collidesWith newPlayer) enemies then player else newPlayer else player isGameOver : Game -> Bool isGameOver { player, enemies } = any (collidesWith player) enemies updateMsRunning : Time -> Game -> Time updateMsRunning timestamp game = case game.state of Play -> game.msRunning + timestamp - game.timeTick NewGame -> 0.0 _ -> game.msRunning updateAutoRotateAngle : Game -> Float updateAutoRotateAngle { autoRotateAngle, autoRotateSpeed } = autoRotateAngle + autoRotateSpeed updateAutoRotateSpeed : Game -> Float updateAutoRotateSpeed { msRunning, autoRotateSpeed } = 0.02 * sin (msRunning * 0.0003) updateEnemies : Game -> List (Enemy) updateEnemies game = let enemyProgress = game.msRunning * game.enemySpeed numEnemies = List.length enemies maxDistance = numEnemies * enemyDistance offsetForEnemy index = round <| enemyDistance * (toFloat index) - enemyProgress radiusFor index = (offsetForEnemy index) % maxDistance |> toFloat in List.indexedMap (\index parts -> { parts = parts , radius = radiusFor index } ) enemies updateEnemySpeed : Game -> Float updateEnemySpeed game = enemyInitialSpeed + game.msRunning * enemyAcceleration {-| Updates the game state on a keyboard command -} onUserInput : Keyboard.Extra.Msg -> Game -> ( Game, Cmd Msg ) onUserInput keyMsg game = let pressedKeys = Keyboard.Extra.update keyMsg game.pressedKeys spacebar = List.member Keyboard.Extra.Space pressedKeys && not (List.member Keyboard.Extra.Space game.pressedKeys) direction = if (Keyboard.Extra.arrows pressedKeys).x < 0 then Left else if (Keyboard.Extra.arrows pressedKeys).x > 0 then Right else Still nextState = case game.state of NewGame -> if spacebar then Starting else NewGame Play -> if spacebar then Pausing else Play GameOver -> if spacebar then NewGame else GameOver Pause -> if spacebar then Resume else Pause _ -> game.state in ( { game | pressedKeys = pressedKeys , direction = direction , state = nextState } , Cmd.none ) {-| Updates the game state on every frame -} onFrame : Time -> Game -> ( Game, Cmd Msg ) onFrame time game = let ( nextState, nextCmd ) = case game.music of Nothing -> ( Loading, Cmd.none ) Just music -> case game.state of Starting -> ( Play, playSound music { playbackOptions | startAt = Just 0 } ) Resume -> ( Play, playSound music playbackOptions ) Pausing -> ( Pause, stopSound music ) Play -> if isGameOver game then ( GameOver, stopSound music ) else ( Play, Cmd.none ) _ -> ( game.state, Cmd.none ) in ( { game | player = updatePlayer game.direction game , enemies = updateEnemies game , enemySpeed = updateEnemySpeed game , state = nextState , timeStart = if game.state == NewGame then time else game.timeStart , timeTick = time , msRunning = updateMsRunning time game , autoRotateAngle = updateAutoRotateAngle game , autoRotateSpeed = updateAutoRotateSpeed game , hasBass = hasBass game.msRunning } , nextCmd ) {-| Game loop: Transition from one state to the next. -} update : Msg -> Game -> ( Game, Cmd Msg ) update msg game = case msg of KeyboardMsg keyMsg -> onUserInput keyMsg game Step time -> onFrame time game MusicLoaded music -> ( { game | state = NewGame , music = Just music } , Cmd.none ) Error message -> Debug.crash message _ -> ( game, Cmd.none ) -- VIEW moveRadial : Float -> Float -> Form -> Form moveRadial angle radius = move ( radius * cos angle, radius * sin angle ) makePlayer : Player -> Form makePlayer player = let angle = player.angle - degrees 30 in ngon 3 playerSize |> filled (hsl angle 1 0.5) |> moveRadial angle (playerRadius - playerSize) |> rotate angle trapezoid : Float -> Float -> Color -> Form trapezoid base height color = let s = height / (tan <| degrees 60) in filled color <| polygon [ ( -base / 2, 0 ) , ( base / 2, 0 ) , ( base / 2 - s, height ) , ( -base / 2 + s, height ) ] makeEnemy : Color -> Enemy -> Form makeEnemy color enemy = let base = 2.0 * (enemy.radius + enemyThickness) / (sqrt 3) makeEnemyPart : Int -> Form makeEnemyPart index = trapezoid base enemyThickness color |> rotate (degrees <| toFloat (90 + index * 60)) |> moveRadial (degrees <| toFloat (index * 60)) (enemy.radius + enemyThickness) in group (indexedMap (,) enemy.parts |> filter Tuple.second |> map Tuple.first |> map makeEnemyPart) makeEnemies : Color -> List (Enemy) -> List (Form) makeEnemies color enemies = map (makeEnemy color) enemies hexagonElement : Int -> List ( Float, Float ) hexagonElement i = let radius = halfWidth * sqrt 2 angle0 = 60 * i |> toFloat |> degrees angle1 = 60 * (i + 1) |> toFloat |> degrees in [ ( 0.0, 0.0 ) , ( sin angle0 * radius, cos angle0 * radius ) , ( sin angle1 * radius, cos angle1 * radius ) ] makeField : Colors -> Form makeField colors = let color i = if i % 2 == 0 then colors.dark else colors.medium poly i = polygon (hexagonElement i) |> filled (color i) in group (map poly (List.range 0 5)) -- the polygon in the center: this is just decoration, so it has no own state makeCenterHole : Colors -> Game -> List Form makeCenterHole colors game = let bassAdd = if game.hasBass then 0 else 100.0 * (pump game.msRunning) shape = ngon 6 (60 + bassAdd) line = solid colors.bright in [ shape |> filled colors.dark |> rotate (degrees 90) , shape |> (outlined { line | width = 4.0 }) |> rotate (degrees 90) ] makeColors : Float -> Colors makeColors msRunning = let hue = 0.00005 * msRunning in { dark = (hsl hue 0.6 0.2) , medium = (hsl hue 0.6 0.3) , bright = (hsla hue 0.6 0.6 0.8) } makeTextBox : Float -> String -> Element makeTextBox size string = Text.fromString string |> Text.color (rgb 255 255 255) |> Text.monospace |> Text.height size |> leftAligned beatPulse : Game -> Form -> Form beatPulse game = if game.hasBass then scale (1 + (pump game.msRunning)) else identity formatTime : Time -> String formatTime running = let centiseconds = floor (Time.inMilliseconds running / 10) seconds = centiseconds // 100 centis = centiseconds % 100 in padLeft 3 '0' (toString seconds) ++ "." ++ padLeft 2 '0' (toString centis) view : Game -> Html.Html Msg view game = let bg = rect gameWidth gameHeight |> filled bgBlack colors = makeColors game.msRunning score = formatTime game.msRunning |> makeTextBox 50 message = makeTextBox 50 <| case game.state of Loading -> "Loading..." GameOver -> "Game Over" Pause -> "Pause" _ -> "" field = append [ makeField colors , makePlayer game.player , group <| makeEnemies colors.bright game.enemies ] (makeCenterHole colors game) |> group in toHtml <| container gameWidth gameHeight middle <| collage gameWidth gameHeight [ bg , field |> rotate game.autoRotateAngle |> beatPulse game , toForm message |> move ( 0, 40 ) , toForm score |> move ( 100 - halfWidth, halfHeight - 40 ) , toForm (if game.state == Play then spacer 1 1 else makeTextBox 20 startMessage ) |> move ( 0, 40 - halfHeight ) ] -- SUBSCRIPTIONS subscriptions : Game -> Sub Msg subscriptions game = Sub.batch [ AnimationFrame.times (\time -> Step time) , Sub.map KeyboardMsg Keyboard.Extra.subscriptions ] --INIT init : ( Game, Cmd Msg ) init = ( { player = Player (degrees 30) , pressedKeys = [] , direction = Still , state = NewGame , enemies = [] , enemySpeed = 0.0 , timeStart = 0.0 , timeTick = 0.0 , msRunning = 0.0 , autoRotateAngle = 0.0 , autoRotateSpeed = 0.0 , hasBass = False , music = Nothing } , Task.attempt soundLoaded loadSound ) main = Html.program { init = init , update = update , view = view , subscriptions = subscriptions }