diff --git a/.editorconfig b/.editorconfig index 3b281f2..f1196bc 100644 --- a/.editorconfig +++ b/.editorconfig @@ -8,4 +8,5 @@ root = true end_of_line = lf insert_final_newline = true indent_style = space -indent_size = 2 +indent_size = 4 +max_line_length = 80 diff --git a/elm-package.json b/elm-package.json index c3c1aa6..4528a3f 100644 --- a/elm-package.json +++ b/elm-package.json @@ -1,17 +1,21 @@ { - "version": "1.0.0", - "summary": "A functional reactive implementation of Terry Cavanagh's game Hexagon in Elm", - "repository": "https://github.com/sbaechler/polygon.git", - "license": "BSD3", - "source-directories": [ - "src", - "../elm-audio" - ], - "native-modules": true, - "exposed-modules": [], - "dependencies": { - "elm-lang/core": "3.0.0 <= v < 4.0.0", - "jwmerrill/elm-animation-frame": "1.0.5 <= v < 2.0.0" - }, - "elm-version": "0.16.0 <= v < 0.17.0" + "version": "1.1.0", + "summary": "A functional reactive implementation of Terry Cavanagh's game Hexagon in Elm", + "repository": "https://github.com/sbaechler/polygon.git", + "license": "All rights reserved", + "source-directories": [ + "src", + "lib/elm-audio/src" + ], + "exposed-modules": [], + "native-modules": true, + "dependencies": { + "elm-lang/animation-frame": "1.0.0 <= v < 2.0.0", + "elm-lang/core": "5.0.0 <= v < 6.0.0", + "elm-lang/html": "2.0.0 <= v < 3.0.0", + "elm-lang/window": "1.0.0 <= v < 2.0.0", + "evancz/elm-graphics": "1.0.0 <= v < 2.0.0", + "ohanhi/keyboard-extra": "3.0.4 <= v < 4.0.0" + }, + "elm-version": "0.18.0 <= v < 0.19.0" } diff --git a/package.json b/package.json index dca8aa2..ad771dc 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,11 @@ { "private": true, "scripts": { - "build": "elm-make --yes src/Game.elm --output=./elm.js && cp index.template.html index.html", - "watch": "node watch.js", - "server": "python -m SimpleHTTPServer 5000" + "build": "elm-make src/Hexagon.elm --output=public/index.html", + "server": "elm-reactor", + "setup": "elm-package install && git submodule update --init" }, "dependencies": { - "watch": "^0.16.0" + "elm": "^0.18.0" } } diff --git a/src/Hexagon.elm b/src/Hexagon.elm index 33e0648..8ed7ad5 100644 --- a/src/Hexagon.elm +++ b/src/Hexagon.elm @@ -1,122 +1,586 @@ -module Game where -import Time exposing ( .. ) +module Main exposing (..) + +import Time exposing (..) +import List exposing (..) +import Tuple exposing (..) import AnimationFrame -import Keyboard +import Keyboard.Extra exposing (Key(..)) import Window -import Graphics.Collage exposing (..) -import Graphics.Element exposing (..) +import Collage exposing (..) +import Element exposing (..) import Color exposing (..) +import Html exposing (Html) +import Debug +import Text +import String exposing (padLeft) + + +-- MODEL + + +type State + = NewGame + | Play + | GameOver + | Pause + + -- MODEL type alias Player = - { angle: Float } + { angle : Float } + + +type Direction + = Left + | Right + | Still + + +type alias Enemy = + { radius : Float + , parts : List (Bool) + } -type alias Input = - { space : Bool - , dir : Int - } type alias Game = - { - player : Player - } + { player : Player + , direction : Direction + , enemies : List (Enemy) + , enemySpeed : Float + , pressedKeys : List Key + , state : State + , timeStart : Time + , timeTick : Time + , msRunning : Float + , autoRotateAngle : Float + , autoRotateSpeed : Float + } +type alias Colors = + { dark : Color + , medium : Color + , bright : Color + } -(gameWidth, gameHeight) = (1024, 576) -- 16:9 -(halfWidth, halfHeight) = (gameWidth/2, gameHeight/2) -(iHalfWidth, iHalfHeight) = (gameWidth//2, gameHeight//2) +type Msg + = Step Time + | KeyboardMsg Keyboard.Extra.Msg + + +( gameWidth, gameHeight ) = + ( 1024, 576 ) +( halfWidth, halfHeight ) = + ( gameWidth / 2, gameHeight / 2 ) +( iHalfWidth, iHalfHeight ) = + ( gameWidth // 2, gameHeight // 2 ) playerRadius : Float -playerRadius = gameWidth / 10.0 +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" + + +bgBlack : Color +bgBlack = + rgb 20 20 20 --- The global game state -defaultGame : Game -defaultGame = - { - player = Player (degrees 30) - } -- UPDATE -updatePlayerAngle: Float -> Int -> Float +updatePlayerAngle : Float -> Direction -> Float updatePlayerAngle angle dir = - let - newAngle = (angle + toFloat (-dir * 4) * 0.032) - in - if newAngle < 0 then - newAngle + 2 * pi - else if newAngle > 2 * pi then - newAngle - 2 * pi + 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 + + +updatePlayer : Direction -> Game -> Player +updatePlayer dir { player, state } = + if state == Play then + let + newAngle = + if state == NewGame then + degrees 30 + else + updatePlayerAngle player.angle dir + in + { player | angle = newAngle } else - newAngle - -updatePlayer: Input -> Game -> Player -updatePlayer {dir} {player} = - let - newAngle = updatePlayerAngle player.angle dir - in - { player | angle = newAngle } - --- Game loop: Transition from one state to the next. -update : (Time, Input) -> Game -> Game -update (timestamp, input) game = - - { game | - player = updatePlayer input game - } + player + + +isGameOver : Game -> Bool +isGameOver { player } = + False + + +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 |> Debug.log "φ") + |> Debug.log "autoRotateSpeed" + + +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 + Play + else + NewGame + + Play -> + if spacebar then + Pause + else + Play + + GameOver -> + if spacebar then + NewGame + else + GameOver + + Pause -> + if spacebar then + Play + else + Pause + 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 + nextCmd = + Cmd.none + + nextState = + case game.state of + NewGame -> + NewGame + + Play -> + if isGameOver game then + GameOver + else + Play + + _ -> + game.state + in + ( { game + | player = updatePlayer game.direction game + , enemies = updateEnemies game + , enemySpeed = updateEnemySpeed game + , state = Debug.log "state" nextState + , timeStart = + if game.state == NewGame then + time + else + game.timeStart + , timeTick = time + , msRunning = Debug.log "msRunning" (updateMsRunning time game) + , autoRotateAngle = updateAutoRotateAngle game + , autoRotateSpeed = updateAutoRotateSpeed game + } + , 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 + + -- VIEW -bgBlack : Color -bgBlack = - rgb 20 20 20 moveRadial : Float -> Float -> Form -> Form moveRadial angle radius = - move (radius * cos angle, radius * sin angle) + move ( radius * cos angle, radius * sin angle ) + makePlayer : Player -> Form makePlayer player = - let - angle = player.angle - degrees 30 - in - ngon 3 10 - |> filled (hsl angle 1 0.5) - |> moveRadial angle (playerRadius - 10) - |> rotate angle - -view : (Int,Int) -> Game -> Element -view (w, h) game = - container w h middle <| - collage gameWidth gameHeight - [ rect gameWidth gameHeight - |> filled bgBlack - , makePlayer game.player - ] + let + angle = + player.angle - degrees 30 + in + ngon 3 playerSize + |> filled (hsl angle 1 0.5) + |> moveRadial angle (playerRadius - playerSize) + |> rotate angle --- SIGNALS -main : Signal Element -main = - Signal.map2 view Window.dimensions gameState - -gameState : Signal Game -gameState = - Signal.foldp update defaultGame input - --- Creates an event stream from the keyboard inputs and is --- updated by AnimationFrame. -input : Signal (Time, Input) -input = - Signal.map2 Input - Keyboard.space - (Signal.map .x Keyboard.arrows) - -- only update on a new frame - |> Signal.sampleOn AnimationFrame.frame - |> Time.timestamp +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 + shape = + ngon 6 60 + + 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 + + +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 + 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 + , 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 + } + , Cmd.none + ) + + +main = + Html.program + { init = init + , update = update + , view = view + , subscriptions = subscriptions + }