├── CNAME
├── .dockerignore
├── favicon.ico
├── _site
├── resources
│ ├── splash.png
│ ├── konec-hry.png
│ ├── digits-large.png
│ ├── kurve-share.png
│ ├── icon
│ │ ├── apple-icon.png
│ │ ├── favicon-16x16.png
│ │ ├── favicon-32x32.png
│ │ ├── favicon-96x96.png
│ │ ├── ms-icon-144x144.png
│ │ ├── ms-icon-150x150.png
│ │ ├── ms-icon-310x310.png
│ │ ├── ms-icon-70x70.png
│ │ ├── apple-icon-57x57.png
│ │ ├── apple-icon-60x60.png
│ │ ├── apple-icon-72x72.png
│ │ ├── apple-icon-76x76.png
│ │ ├── android-icon-144x144.png
│ │ ├── android-icon-192x192.png
│ │ ├── android-icon-36x36.png
│ │ ├── android-icon-48x48.png
│ │ ├── android-icon-72x72.png
│ │ ├── android-icon-96x96.png
│ │ ├── apple-icon-114x114.png
│ │ ├── apple-icon-120x120.png
│ │ ├── apple-icon-144x144.png
│ │ ├── apple-icon-152x152.png
│ │ ├── apple-icon-180x180.png
│ │ └── apple-icon-precomposed.png
│ └── fonts
│ │ └── bgi-default-8x8.png
└── index.html
├── docs
├── original-game
│ ├── TSCR.CHR
│ ├── EGAVGA.BGI
│ └── ZATACKA.EXE
└── screenshots-from-original-game
│ └── cutting-corners-01-3-pixels.png
├── screenshots
├── kurve-lobby.png
├── kurve-round.png
└── kurve-splash.png
├── src
├── Types
│ ├── PlayerId.elm
│ ├── TurningState.elm
│ ├── FrameTime.elm
│ ├── PlayerStatus.elm
│ ├── Player.elm
│ ├── Score.elm
│ ├── Speed.elm
│ ├── Radius.elm
│ ├── Tickrate.elm
│ ├── Distance.elm
│ ├── Tick.elm
│ ├── Angle.elm
│ └── Kurve.elm
├── Thickness.elm
├── Menu.elm
├── Dialog.elm
├── Util.elm
├── GUI
│ ├── SplashScreen.elm
│ ├── Buttons
│ │ ├── Mouse.elm
│ │ └── Keyboard.elm
│ ├── EndScreen.elm
│ ├── Text.elm
│ ├── Lobby.elm
│ ├── TextOverlay.elm
│ ├── Scoreboard.elm
│ ├── Digits.elm
│ ├── Controls.elm
│ └── ConfirmQuitDialog.elm
├── App.elm
├── TestScenarios
│ ├── CrashIntoWallTop.elm
│ ├── CrashIntoWallBasic.elm
│ ├── CrashIntoWallBottom.elm
│ ├── CrashIntoWallLeft.elm
│ ├── CrashIntoWallRight.elm
│ ├── CrashIntoWallExactTiming.elm
│ ├── SpeedEffectOnGame.elm
│ ├── CrashIntoTipOfTailEnd.elm
│ ├── CrashIntoTailEnd90Degrees.elm
│ ├── CuttingCornersBasic.elm
│ ├── CrashIntoKurveTiming.elm
│ ├── CuttingCornersThreePixelsRealExample.elm
│ ├── AroundTheWorld.elm
│ ├── StressTestRealisticTurtleSurvivalRound.elm
│ └── CuttingCornersPerfectOverpainting.elm
├── Input.elm
├── Canvas.elm
├── Turning.elm
├── MainLoop.elm
├── Round.elm
├── Config.elm
├── World.elm
├── TestScenarioHelpers.elm
├── Players.elm
├── Spawn.elm
├── css
│ └── Zatacka.scss
├── Game.elm
└── Main.elm
├── tools
├── dosbox-linux.conf
├── dosbox-wsl.conf
├── show-game-state.sh
├── compile-scenario-glue.cjs
├── ScenarioInOriginalGame
│ ├── TheScenario.elm
│ ├── Hex.elm
│ ├── ScenarioCLI.elm
│ ├── ModMem.elm
│ ├── OriginalGamePlayers.elm
│ ├── MemoryLayout.elm
│ ├── ScenarioComments.elm
│ ├── ScenarioCore.elm
│ ├── CompileScenario.elm
│ └── GDB.elm
├── extract-glyphs-from-original-game.js
├── interpret-scanmem-output.py
└── scenario.py
├── elm-tooling.json
├── .gitignore
├── elm-watch.json
├── hooks
└── pre-push
├── browserconfig.xml
├── Dockerfile
├── README.md
├── manifest.json
├── elm.json
├── package.json
├── review
├── elm.json
└── src
│ └── ReviewConfig.elm
├── .github
└── workflows
│ ├── validation.yaml
│ └── deployment.yaml
└── tests
├── TestHelpers.elm
├── AchtungTest.elm
└── ScenarioInOriginalGameTest.elm
/CNAME:
--------------------------------------------------------------------------------
1 | kurve.se
2 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | elm-stuff
2 | node_modules
3 |
--------------------------------------------------------------------------------
/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SimonAlling/kurve/HEAD/favicon.ico
--------------------------------------------------------------------------------
/_site/resources/splash.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SimonAlling/kurve/HEAD/_site/resources/splash.png
--------------------------------------------------------------------------------
/docs/original-game/TSCR.CHR:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SimonAlling/kurve/HEAD/docs/original-game/TSCR.CHR
--------------------------------------------------------------------------------
/screenshots/kurve-lobby.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SimonAlling/kurve/HEAD/screenshots/kurve-lobby.png
--------------------------------------------------------------------------------
/screenshots/kurve-round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SimonAlling/kurve/HEAD/screenshots/kurve-round.png
--------------------------------------------------------------------------------
/_site/resources/konec-hry.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SimonAlling/kurve/HEAD/_site/resources/konec-hry.png
--------------------------------------------------------------------------------
/docs/original-game/EGAVGA.BGI:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SimonAlling/kurve/HEAD/docs/original-game/EGAVGA.BGI
--------------------------------------------------------------------------------
/docs/original-game/ZATACKA.EXE:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SimonAlling/kurve/HEAD/docs/original-game/ZATACKA.EXE
--------------------------------------------------------------------------------
/screenshots/kurve-splash.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SimonAlling/kurve/HEAD/screenshots/kurve-splash.png
--------------------------------------------------------------------------------
/_site/resources/digits-large.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SimonAlling/kurve/HEAD/_site/resources/digits-large.png
--------------------------------------------------------------------------------
/_site/resources/kurve-share.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SimonAlling/kurve/HEAD/_site/resources/kurve-share.png
--------------------------------------------------------------------------------
/src/Types/PlayerId.elm:
--------------------------------------------------------------------------------
1 | module Types.PlayerId exposing (PlayerId)
2 |
3 |
4 | type alias PlayerId =
5 | Int
6 |
--------------------------------------------------------------------------------
/_site/resources/icon/apple-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SimonAlling/kurve/HEAD/_site/resources/icon/apple-icon.png
--------------------------------------------------------------------------------
/src/Thickness.elm:
--------------------------------------------------------------------------------
1 | module Thickness exposing (theThickness)
2 |
3 |
4 | theThickness : number
5 | theThickness =
6 | 3
7 |
--------------------------------------------------------------------------------
/_site/resources/icon/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SimonAlling/kurve/HEAD/_site/resources/icon/favicon-16x16.png
--------------------------------------------------------------------------------
/_site/resources/icon/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SimonAlling/kurve/HEAD/_site/resources/icon/favicon-32x32.png
--------------------------------------------------------------------------------
/_site/resources/icon/favicon-96x96.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SimonAlling/kurve/HEAD/_site/resources/icon/favicon-96x96.png
--------------------------------------------------------------------------------
/_site/resources/icon/ms-icon-144x144.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SimonAlling/kurve/HEAD/_site/resources/icon/ms-icon-144x144.png
--------------------------------------------------------------------------------
/_site/resources/icon/ms-icon-150x150.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SimonAlling/kurve/HEAD/_site/resources/icon/ms-icon-150x150.png
--------------------------------------------------------------------------------
/_site/resources/icon/ms-icon-310x310.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SimonAlling/kurve/HEAD/_site/resources/icon/ms-icon-310x310.png
--------------------------------------------------------------------------------
/_site/resources/icon/ms-icon-70x70.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SimonAlling/kurve/HEAD/_site/resources/icon/ms-icon-70x70.png
--------------------------------------------------------------------------------
/tools/dosbox-linux.conf:
--------------------------------------------------------------------------------
1 | [dosbox]
2 | memsize=1 # Necessary for game state to end up at a consistent, predictable memory address.
3 |
--------------------------------------------------------------------------------
/tools/dosbox-wsl.conf:
--------------------------------------------------------------------------------
1 | [dosbox]
2 | memsize=2 # Necessary for game state to end up at a consistent, predictable memory address.
3 |
--------------------------------------------------------------------------------
/_site/resources/fonts/bgi-default-8x8.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SimonAlling/kurve/HEAD/_site/resources/fonts/bgi-default-8x8.png
--------------------------------------------------------------------------------
/_site/resources/icon/apple-icon-57x57.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SimonAlling/kurve/HEAD/_site/resources/icon/apple-icon-57x57.png
--------------------------------------------------------------------------------
/_site/resources/icon/apple-icon-60x60.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SimonAlling/kurve/HEAD/_site/resources/icon/apple-icon-60x60.png
--------------------------------------------------------------------------------
/_site/resources/icon/apple-icon-72x72.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SimonAlling/kurve/HEAD/_site/resources/icon/apple-icon-72x72.png
--------------------------------------------------------------------------------
/_site/resources/icon/apple-icon-76x76.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SimonAlling/kurve/HEAD/_site/resources/icon/apple-icon-76x76.png
--------------------------------------------------------------------------------
/_site/resources/icon/android-icon-144x144.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SimonAlling/kurve/HEAD/_site/resources/icon/android-icon-144x144.png
--------------------------------------------------------------------------------
/_site/resources/icon/android-icon-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SimonAlling/kurve/HEAD/_site/resources/icon/android-icon-192x192.png
--------------------------------------------------------------------------------
/_site/resources/icon/android-icon-36x36.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SimonAlling/kurve/HEAD/_site/resources/icon/android-icon-36x36.png
--------------------------------------------------------------------------------
/_site/resources/icon/android-icon-48x48.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SimonAlling/kurve/HEAD/_site/resources/icon/android-icon-48x48.png
--------------------------------------------------------------------------------
/_site/resources/icon/android-icon-72x72.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SimonAlling/kurve/HEAD/_site/resources/icon/android-icon-72x72.png
--------------------------------------------------------------------------------
/_site/resources/icon/android-icon-96x96.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SimonAlling/kurve/HEAD/_site/resources/icon/android-icon-96x96.png
--------------------------------------------------------------------------------
/_site/resources/icon/apple-icon-114x114.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SimonAlling/kurve/HEAD/_site/resources/icon/apple-icon-114x114.png
--------------------------------------------------------------------------------
/_site/resources/icon/apple-icon-120x120.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SimonAlling/kurve/HEAD/_site/resources/icon/apple-icon-120x120.png
--------------------------------------------------------------------------------
/_site/resources/icon/apple-icon-144x144.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SimonAlling/kurve/HEAD/_site/resources/icon/apple-icon-144x144.png
--------------------------------------------------------------------------------
/_site/resources/icon/apple-icon-152x152.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SimonAlling/kurve/HEAD/_site/resources/icon/apple-icon-152x152.png
--------------------------------------------------------------------------------
/_site/resources/icon/apple-icon-180x180.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SimonAlling/kurve/HEAD/_site/resources/icon/apple-icon-180x180.png
--------------------------------------------------------------------------------
/src/Menu.elm:
--------------------------------------------------------------------------------
1 | module Menu exposing (MenuState(..))
2 |
3 |
4 | type MenuState
5 | = SplashScreen
6 | | Lobby
7 | | GameOver
8 |
--------------------------------------------------------------------------------
/_site/resources/icon/apple-icon-precomposed.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SimonAlling/kurve/HEAD/_site/resources/icon/apple-icon-precomposed.png
--------------------------------------------------------------------------------
/elm-tooling.json:
--------------------------------------------------------------------------------
1 | {
2 | "tools": {
3 | "elm": "0.19.1",
4 | "elm-format": "0.8.5",
5 | "elm-json": "0.2.10"
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.sublime-project
2 | *.sublime-workspace
3 | node_modules
4 | *.log
5 | *.map
6 | *.js
7 | *.css
8 | elm-stuff
9 | .compiled-scenario.gdb
10 | gdb-log.txt
11 |
--------------------------------------------------------------------------------
/src/Types/TurningState.elm:
--------------------------------------------------------------------------------
1 | module Types.TurningState exposing (TurningState(..))
2 |
3 |
4 | type TurningState
5 | = TurningLeft
6 | | TurningRight
7 | | NotTurning
8 |
--------------------------------------------------------------------------------
/docs/screenshots-from-original-game/cutting-corners-01-3-pixels.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SimonAlling/kurve/HEAD/docs/screenshots-from-original-game/cutting-corners-01-3-pixels.png
--------------------------------------------------------------------------------
/src/Types/FrameTime.elm:
--------------------------------------------------------------------------------
1 | module Types.FrameTime exposing (FrameTime, LeftoverFrameTime)
2 |
3 |
4 | type alias FrameTime =
5 | Float
6 |
7 |
8 | type alias LeftoverFrameTime =
9 | FrameTime
10 |
--------------------------------------------------------------------------------
/src/Types/PlayerStatus.elm:
--------------------------------------------------------------------------------
1 | module Types.PlayerStatus exposing (PlayerStatus(..))
2 |
3 | import Types.Score exposing (Score)
4 |
5 |
6 | type PlayerStatus
7 | = Participating Score
8 | | NotParticipating
9 |
--------------------------------------------------------------------------------
/elm-watch.json:
--------------------------------------------------------------------------------
1 | {
2 | "targets": {
3 | "zatacka": {
4 | "inputs": [
5 | "src/Main.elm"
6 | ],
7 | "output": "_site/ZATACKA.js"
8 | }
9 | }
10 | }
--------------------------------------------------------------------------------
/src/Dialog.elm:
--------------------------------------------------------------------------------
1 | module Dialog exposing
2 | ( Option(..)
3 | , State(..)
4 | )
5 |
6 |
7 | type State
8 | = Open Option
9 | | NotOpen
10 |
11 |
12 | type Option
13 | = Confirm
14 | | Cancel
15 |
--------------------------------------------------------------------------------
/hooks/pre-push:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | set -eu
4 |
5 | if ! output=$(git status --porcelain) || [ -n "$output" ]; then
6 | echo "⚠️ Dirty working tree."
7 | git status
8 | exit 1
9 | fi
10 |
11 | npm run review && npm test
12 |
--------------------------------------------------------------------------------
/src/Util.elm:
--------------------------------------------------------------------------------
1 | module Util exposing
2 | ( curry
3 | , isEven
4 | )
5 |
6 |
7 | curry : (( a, b ) -> c) -> a -> b -> c
8 | curry f a b =
9 | f ( a, b )
10 |
11 |
12 | isEven : Int -> Bool
13 | isEven n =
14 | modBy 2 n == 0
15 |
--------------------------------------------------------------------------------
/src/Types/Player.elm:
--------------------------------------------------------------------------------
1 | module Types.Player exposing (Player)
2 |
3 | import Color exposing (Color)
4 | import Input exposing (Button)
5 |
6 |
7 | type alias Player =
8 | { color : Color
9 | , controls : ( List Button, List Button )
10 | }
11 |
--------------------------------------------------------------------------------
/src/Types/Score.elm:
--------------------------------------------------------------------------------
1 | module Types.Score exposing
2 | ( Score(..)
3 | , isAtLeast
4 | )
5 |
6 |
7 | type Score
8 | = Score Int
9 |
10 |
11 | isAtLeast : Score -> Score -> Bool
12 | isAtLeast (Score threshold) (Score s) =
13 | s >= threshold
14 |
--------------------------------------------------------------------------------
/src/GUI/SplashScreen.elm:
--------------------------------------------------------------------------------
1 | module GUI.SplashScreen exposing (splashScreen)
2 |
3 | import Html exposing (Html, div)
4 | import Html.Attributes as Attr
5 |
6 |
7 | splashScreen : Html msg
8 | splashScreen =
9 | div
10 | [ Attr.id "splashScreen"
11 | ]
12 | []
13 |
--------------------------------------------------------------------------------
/src/Types/Speed.elm:
--------------------------------------------------------------------------------
1 | module Types.Speed exposing
2 | ( Speed(..)
3 | , toFloat
4 | )
5 |
6 | {-| Speeds in Kurve are traditionally measured in pixels per second.
7 | -}
8 |
9 |
10 | type Speed
11 | = Speed Float
12 |
13 |
14 | toFloat : Speed -> Float
15 | toFloat (Speed s) =
16 | s
17 |
--------------------------------------------------------------------------------
/src/Types/Radius.elm:
--------------------------------------------------------------------------------
1 | module Types.Radius exposing
2 | ( Radius(..)
3 | , toFloat
4 | )
5 |
6 | {-| A (turning) radius in Kurve is traditionally measured in pixels.
7 | -}
8 |
9 |
10 | type Radius
11 | = Radius Float
12 |
13 |
14 | toFloat : Radius -> Float
15 | toFloat (Radius r) =
16 | r
17 |
--------------------------------------------------------------------------------
/src/Types/Tickrate.elm:
--------------------------------------------------------------------------------
1 | module Types.Tickrate exposing
2 | ( Tickrate(..)
3 | , toFloat
4 | )
5 |
6 | {-| Kurve runs at a fixed tickrate measured in ticks per second (Hz).
7 | -}
8 |
9 |
10 | type Tickrate
11 | = Tickrate Float
12 |
13 |
14 | toFloat : Tickrate -> Float
15 | toFloat (Tickrate r) =
16 | r
17 |
--------------------------------------------------------------------------------
/browserconfig.xml:
--------------------------------------------------------------------------------
1 |
2 | #000000
--------------------------------------------------------------------------------
/tools/show-game-state.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -euo pipefail
4 |
5 | USAGE="For example: $0 7fffd8010ff6"
6 |
7 | base_address="${1:?Please specify base address. $USAGE}"
8 |
9 | sudo scanmem `pgrep dosbox` --errexit --command "option dump_with_ascii 0;dump ${base_address} 72;exit" 2>&1 | $(dirname $0)/interpret-scanmem-output.py
10 |
--------------------------------------------------------------------------------
/tools/compile-scenario-glue.cjs:
--------------------------------------------------------------------------------
1 | const { Elm } = require("./ScenarioInOriginalGame/ScenarioCLI.js");
2 |
3 | try {
4 | const app = Elm.ScenarioCLI.init({
5 | flags: { elmFlag_commandLineArgs: process.argv.slice(2) },
6 | });
7 |
8 | app.ports.outputToOutsideWorld.subscribe(console.log);
9 | } catch (caught) {
10 | console.error("Elm initialization failed.");
11 | console.error(String(caught));
12 | process.exit(1);
13 | }
14 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:24.10.0-alpine3.22
2 |
3 | WORKDIR /app
4 |
5 | COPY package.json package-lock.json ./
6 | COPY elm-tooling.json .
7 | RUN npm ci
8 |
9 | COPY elm.json .
10 | COPY elm-watch.json .
11 | COPY review review
12 | COPY tools/ScenarioInOriginalGame tools/ScenarioInOriginalGame
13 | COPY tests tests
14 | COPY src src
15 | RUN npm run check-formatting
16 | RUN npm run review
17 | RUN npm run build
18 | RUN npm run build:scenario-in-original-game
19 | RUN npm test
20 |
--------------------------------------------------------------------------------
/src/GUI/Buttons/Mouse.elm:
--------------------------------------------------------------------------------
1 | module GUI.Buttons.Mouse exposing (mouseButtonRepresentation)
2 |
3 |
4 | mouseButtonRepresentation : Int -> String
5 | mouseButtonRepresentation n =
6 | case n of
7 | 0 ->
8 | -- From the original game.
9 | "L.Mouse"
10 |
11 | 1 ->
12 | "M.Mouse"
13 |
14 | 2 ->
15 | -- From the original game.
16 | "R.Mouse"
17 |
18 | _ ->
19 | "Mouse " ++ String.fromInt n
20 |
--------------------------------------------------------------------------------
/src/Types/Distance.elm:
--------------------------------------------------------------------------------
1 | module Types.Distance exposing
2 | ( Distance(..)
3 | , generate
4 | , toFloat
5 | )
6 |
7 | import Random
8 |
9 |
10 | {-| A distance in Kurve is traditionally measured in pixels.
11 | -}
12 | type Distance
13 | = Distance Float
14 |
15 |
16 | toFloat : Distance -> Float
17 | toFloat (Distance r) =
18 | r
19 |
20 |
21 | generate : Distance -> Distance -> Random.Generator Distance
22 | generate min max =
23 | Random.float (toFloat min) (toFloat max) |> Random.map Distance
24 |
--------------------------------------------------------------------------------
/src/App.elm:
--------------------------------------------------------------------------------
1 | module App exposing
2 | ( AppState(..)
3 | , modifyGameState
4 | )
5 |
6 | import Game exposing (GameState)
7 | import Menu exposing (MenuState)
8 | import Random
9 |
10 |
11 | type AppState
12 | = InMenu MenuState Random.Seed
13 | | InGame GameState
14 |
15 |
16 | modifyGameState : (GameState -> GameState) -> AppState -> AppState
17 | modifyGameState f appState =
18 | case appState of
19 | InGame gameState ->
20 | InGame <| f gameState
21 |
22 | _ ->
23 | appState
24 |
--------------------------------------------------------------------------------
/src/Types/Tick.elm:
--------------------------------------------------------------------------------
1 | module Types.Tick exposing
2 | ( Tick
3 | , fromInt
4 | , genesis
5 | , succ
6 | , toInt
7 | )
8 |
9 |
10 | type Tick
11 | = Tick Int
12 |
13 |
14 | {-| The tick at which Kurves are released.
15 | -}
16 | genesis : Tick
17 | genesis =
18 | Tick 0
19 |
20 |
21 | succ : Tick -> Tick
22 | succ (Tick n) =
23 | Tick (n + 1)
24 |
25 |
26 | toInt : Tick -> Int
27 | toInt (Tick n) =
28 | n
29 |
30 |
31 | fromInt : Int -> Maybe Tick
32 | fromInt n =
33 | if n < 0 then
34 | Nothing
35 |
36 | else
37 | Just (Tick n)
38 |
--------------------------------------------------------------------------------
/tools/ScenarioInOriginalGame/TheScenario.elm:
--------------------------------------------------------------------------------
1 | module TheScenario exposing (theScenario)
2 |
3 | import OriginalGamePlayers exposing (PlayerId(..))
4 | import ScenarioCore exposing (Scenario)
5 |
6 |
7 | theScenario : Scenario
8 | theScenario =
9 | [ ( Red
10 | , { x = 200
11 | , y = 50
12 | , direction = pi / 2
13 | }
14 | )
15 | , ( Yellow
16 | , { x = 200
17 | , y = 100
18 | , direction = pi / 2
19 | }
20 | )
21 | , ( Green
22 | , { x = 200
23 | , y = 150
24 | , direction = pi / 2
25 | }
26 | )
27 | ]
28 |
--------------------------------------------------------------------------------
/tools/ScenarioInOriginalGame/Hex.elm:
--------------------------------------------------------------------------------
1 | module Hex exposing (hex, parseHex)
2 |
3 | import Integer exposing (Integer)
4 |
5 |
6 | {-| Same as `hex` in Python.
7 | -}
8 | hex : Integer -> String
9 | hex =
10 | Integer.toHexString >> String.append "0x" >> String.toLower
11 |
12 |
13 | {-| Same as `lambda x: int(x, 16)` in Python.
14 | -}
15 | parseHex : String -> Maybe Integer
16 | parseHex =
17 | drop0xPrefixIfPresent >> Integer.fromHexString
18 |
19 |
20 | drop0xPrefixIfPresent : String -> String
21 | drop0xPrefixIfPresent s =
22 | case String.toList s of
23 | '0' :: 'x' :: rest ->
24 | String.fromList rest
25 |
26 | _ ->
27 | s
28 |
--------------------------------------------------------------------------------
/src/Types/Angle.elm:
--------------------------------------------------------------------------------
1 | module Types.Angle exposing
2 | ( Angle(..)
3 | , add
4 | , cos
5 | , negate
6 | , sin
7 | )
8 |
9 | {-| Angles are measured in radians. Somewhat unconventionally, 0 is down (not right), to match the original game's internal representation.
10 | -}
11 |
12 |
13 | type Angle
14 | = Angle Float
15 |
16 |
17 | toFloat : Angle -> Float
18 | toFloat (Angle a) =
19 | a
20 |
21 |
22 | add : Angle -> Angle -> Angle
23 | add (Angle a) (Angle b) =
24 | Angle (a + b)
25 |
26 |
27 | negate : Angle -> Angle
28 | negate (Angle a) =
29 | Angle -a
30 |
31 |
32 | cos : Angle -> Float
33 | cos =
34 | toFloat >> Basics.cos
35 |
36 |
37 | sin : Angle -> Float
38 | sin =
39 | toFloat >> Basics.sin
40 |
--------------------------------------------------------------------------------
/tools/ScenarioInOriginalGame/ScenarioCLI.elm:
--------------------------------------------------------------------------------
1 | port module ScenarioCLI exposing (main)
2 |
3 | import CompileScenario exposing (compileAndSerialize)
4 | import Platform
5 |
6 |
7 | port outputToOutsideWorld : String -> Cmd msg
8 |
9 |
10 | type alias Flags =
11 | { elmFlag_commandLineArgs : List String
12 | }
13 |
14 |
15 | type alias Model =
16 | ()
17 |
18 |
19 | type alias Msg =
20 | Never
21 |
22 |
23 | main : Program Flags Model Msg
24 | main =
25 | Platform.worker
26 | { init = init
27 | , update = never
28 | , subscriptions = always Sub.none
29 | }
30 |
31 |
32 | init : Flags -> ( Model, Cmd Msg )
33 | init { elmFlag_commandLineArgs } =
34 | ( (), compileAndSerialize elmFlag_commandLineArgs |> outputToOutsideWorld )
35 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Achtung, die Kurve! in Elm
2 |
3 | The classic MS-DOS game *Achtung, die Kurve!* from 1995 in the browser!
4 |
5 | ## Play
6 |
7 | * **Online:** Go to [kurve.se](http://kurve.se) (legacy JavaScript version) or [kurve.se/elm](http://kurve.se/elm) (Elm version).
8 | * **Locally:** [Download the game](/SimonAlling/kurve/archive/master.zip) (legacy JavaScript version) and open `ZATACKA.html` in your browser.
9 |
10 | Fullscreen is recommended for the best experience.
11 |
12 | ## Contribute
13 |
14 | ```shell
15 | npm ci
16 | npm start
17 | ```
18 |
19 | Then visit in your browser.
20 |
21 | The original MS-DOS game (whose author I haven't been able to determine; see #136) is included for reference.
22 | To launch it, install [DOSBox](https://www.dosbox.com) and then run `dosbox docs/original-game/ZATACKA.EXE`.
23 |
--------------------------------------------------------------------------------
/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Kurve",
3 | "icons": [
4 | {
5 | "src": "\/resources\/icon\/android-icon-36x36.png",
6 | "sizes": "36x36",
7 | "type": "image\/png",
8 | "density": "0.75"
9 | },
10 | {
11 | "src": "\/resources\/icon\/android-icon-48x48.png",
12 | "sizes": "48x48",
13 | "type": "image\/png",
14 | "density": "1.0"
15 | },
16 | {
17 | "src": "\/resources\/icon\/android-icon-72x72.png",
18 | "sizes": "72x72",
19 | "type": "image\/png",
20 | "density": "1.5"
21 | },
22 | {
23 | "src": "\/resources\/icon\/android-icon-96x96.png",
24 | "sizes": "96x96",
25 | "type": "image\/png",
26 | "density": "2.0"
27 | },
28 | {
29 | "src": "\/resources\/icon\/android-icon-144x144.png",
30 | "sizes": "144x144",
31 | "type": "image\/png",
32 | "density": "3.0"
33 | },
34 | {
35 | "src": "\/resources\/icon\/android-icon-192x192.png",
36 | "sizes": "192x192",
37 | "type": "image\/png",
38 | "density": "4.0"
39 | }
40 | ]
41 | }
--------------------------------------------------------------------------------
/elm.json:
--------------------------------------------------------------------------------
1 | {
2 | "type": "application",
3 | "source-directories": [
4 | "tools/ScenarioInOriginalGame",
5 | "src"
6 | ],
7 | "elm-version": "0.19.1",
8 | "dependencies": {
9 | "direct": {
10 | "Elm-Canvas/raster-shapes": "1.1.2",
11 | "Janiczek/elm-list-cartesian": "1.0.2",
12 | "avh4/elm-color": "1.0.0",
13 | "dwayne/elm-integer": "1.0.0",
14 | "elm/browser": "1.0.2",
15 | "elm/core": "1.0.5",
16 | "elm/html": "1.0.0",
17 | "elm/json": "1.1.3",
18 | "elm/random": "1.0.0",
19 | "elm/time": "1.0.0",
20 | "elm-community/random-extra": "3.2.0"
21 | },
22 | "indirect": {
23 | "dwayne/elm-natural": "1.1.1",
24 | "elm/url": "1.0.0",
25 | "elm/virtual-dom": "1.0.3"
26 | }
27 | },
28 | "test-dependencies": {
29 | "direct": {
30 | "elm-explorations/test": "2.2.0"
31 | },
32 | "indirect": {
33 | "elm/bytes": "1.0.8"
34 | }
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/TestScenarios/CrashIntoWallTop.elm:
--------------------------------------------------------------------------------
1 | module TestScenarios.CrashIntoWallTop exposing (expectedOutcome, spawnedKurves)
2 |
3 | import Color
4 | import TestScenarioHelpers exposing (RoundOutcome, makeZombieKurve, playerIds, tickNumber)
5 | import Types.Angle exposing (Angle(..))
6 | import Types.Kurve exposing (HoleStatus(..), Kurve)
7 |
8 |
9 | green : Kurve
10 | green =
11 | makeZombieKurve
12 | { color = Color.green
13 | , id = playerIds.green
14 | , state =
15 | { position = ( 100, 2.5 )
16 | , direction = Angle pi
17 | , holeStatus = Unholy 60000
18 | }
19 | }
20 |
21 |
22 | spawnedKurves : List Kurve
23 | spawnedKurves =
24 | [ green ]
25 |
26 |
27 | expectedOutcome : RoundOutcome
28 | expectedOutcome =
29 | { tickThatShouldEndIt = tickNumber 2
30 | , howItShouldEnd =
31 | { aliveAtTheEnd = []
32 | , deadAtTheEnd =
33 | [ { id = playerIds.green
34 | , theDrawingPositionItNeverMadeItTo = { leftEdge = 99, topEdge = -1 }
35 | }
36 | ]
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "zatacka",
3 | "description": "Achtung, die Kurve! in JavaScript",
4 | "scripts": {
5 | "postinstall": "elm-tooling install",
6 | "install-hooks": "[ -d .git/ ] && cp hooks/* .git/hooks",
7 | "sass-build": "sass --source-map src/css/:_site/css/",
8 | "build": "elm-watch make --optimize && npm run sass-build",
9 | "build:scenario-in-original-game": "elm make --optimize tools/ScenarioInOriginalGame/ScenarioCLI.elm --output tools/ScenarioInOriginalGame/ScenarioCLI.js",
10 | "check-formatting": "elm-format --validate .",
11 | "review": "elm-review",
12 | "test": "elm-test",
13 | "start": "npm run install-hooks && run-pty % elm-watch hot % esbuild --serve --servedir=./_site/ % sass --watch --source-map src/css/:_site/css/"
14 | },
15 | "author": "Simon Alling",
16 | "devDependencies": {
17 | "elm-review": "2.12.0",
18 | "elm-test": "0.19.1-revision12",
19 | "elm-tooling": "1.6.0",
20 | "elm-watch": "1.1.0",
21 | "esbuild": "0.25.12",
22 | "sass": "1.43.3"
23 | },
24 | "optionalDependencies": {
25 | "run-pty": "^5.0.0"
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/TestScenarios/CrashIntoWallBasic.elm:
--------------------------------------------------------------------------------
1 | module TestScenarios.CrashIntoWallBasic exposing (expectedOutcome, spawnedKurves)
2 |
3 | import Color
4 | import TestScenarioHelpers exposing (RoundOutcome, makeZombieKurve, playerIds, tickNumber)
5 | import Types.Angle exposing (Angle(..))
6 | import Types.Kurve exposing (HoleStatus(..), Kurve)
7 |
8 |
9 | green : Kurve
10 | green =
11 | makeZombieKurve
12 | { color = Color.green
13 | , id = playerIds.green
14 | , state =
15 | { position = ( 2.5, 100 )
16 | , direction = Angle (3 * pi / 2)
17 | , holeStatus = Unholy 60
18 | }
19 | }
20 |
21 |
22 | spawnedKurves : List Kurve
23 | spawnedKurves =
24 | [ green ]
25 |
26 |
27 | expectedOutcome : RoundOutcome
28 | expectedOutcome =
29 | { tickThatShouldEndIt = tickNumber 2
30 | , howItShouldEnd =
31 | { aliveAtTheEnd = []
32 | , deadAtTheEnd =
33 | [ { id = playerIds.green
34 | , theDrawingPositionItNeverMadeItTo = { leftEdge = -1, topEdge = 99 }
35 | }
36 | ]
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/TestScenarios/CrashIntoWallBottom.elm:
--------------------------------------------------------------------------------
1 | module TestScenarios.CrashIntoWallBottom exposing (expectedOutcome, spawnedKurves)
2 |
3 | import Color
4 | import TestScenarioHelpers exposing (RoundOutcome, makeZombieKurve, playerIds, tickNumber)
5 | import Types.Angle exposing (Angle(..))
6 | import Types.Kurve exposing (HoleStatus(..), Kurve)
7 |
8 |
9 | green : Kurve
10 | green =
11 | makeZombieKurve
12 | { color = Color.green
13 | , id = playerIds.green
14 | , state =
15 | { position = ( 100, 477.5 )
16 | , direction = Angle 0
17 | , holeStatus = Unholy 60000
18 | }
19 | }
20 |
21 |
22 | spawnedKurves : List Kurve
23 | spawnedKurves =
24 | [ green ]
25 |
26 |
27 | expectedOutcome : RoundOutcome
28 | expectedOutcome =
29 | { tickThatShouldEndIt = tickNumber 2
30 | , howItShouldEnd =
31 | { aliveAtTheEnd = []
32 | , deadAtTheEnd =
33 | [ { id = playerIds.green
34 | , theDrawingPositionItNeverMadeItTo = { leftEdge = 99, topEdge = 478 }
35 | }
36 | ]
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/TestScenarios/CrashIntoWallLeft.elm:
--------------------------------------------------------------------------------
1 | module TestScenarios.CrashIntoWallLeft exposing (expectedOutcome, spawnedKurves)
2 |
3 | import Color
4 | import TestScenarioHelpers exposing (RoundOutcome, makeZombieKurve, playerIds, tickNumber)
5 | import Types.Angle exposing (Angle(..))
6 | import Types.Kurve exposing (HoleStatus(..), Kurve)
7 |
8 |
9 | green : Kurve
10 | green =
11 | makeZombieKurve
12 | { color = Color.green
13 | , id = playerIds.green
14 | , state =
15 | { position = ( 2.5, 100 )
16 | , direction = Angle (3 * pi / 2)
17 | , holeStatus = Unholy 60000
18 | }
19 | }
20 |
21 |
22 | spawnedKurves : List Kurve
23 | spawnedKurves =
24 | [ green ]
25 |
26 |
27 | expectedOutcome : RoundOutcome
28 | expectedOutcome =
29 | { tickThatShouldEndIt = tickNumber 2
30 | , howItShouldEnd =
31 | { aliveAtTheEnd = []
32 | , deadAtTheEnd =
33 | [ { id = playerIds.green
34 | , theDrawingPositionItNeverMadeItTo = { leftEdge = -1, topEdge = 99 }
35 | }
36 | ]
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/TestScenarios/CrashIntoWallRight.elm:
--------------------------------------------------------------------------------
1 | module TestScenarios.CrashIntoWallRight exposing (expectedOutcome, spawnedKurves)
2 |
3 | import Color
4 | import TestScenarioHelpers exposing (RoundOutcome, makeZombieKurve, playerIds, tickNumber)
5 | import Types.Angle exposing (Angle(..))
6 | import Types.Kurve exposing (HoleStatus(..), Kurve)
7 |
8 |
9 | green : Kurve
10 | green =
11 | makeZombieKurve
12 | { color = Color.green
13 | , id = playerIds.green
14 | , state =
15 | { position = ( 556.5, 100 )
16 | , direction = Angle (pi / 2)
17 | , holeStatus = Unholy 60000
18 | }
19 | }
20 |
21 |
22 | spawnedKurves : List Kurve
23 | spawnedKurves =
24 | [ green ]
25 |
26 |
27 | expectedOutcome : RoundOutcome
28 | expectedOutcome =
29 | { tickThatShouldEndIt = tickNumber 2
30 | , howItShouldEnd =
31 | { aliveAtTheEnd = []
32 | , deadAtTheEnd =
33 | [ { id = playerIds.green
34 | , theDrawingPositionItNeverMadeItTo = { leftEdge = 557, topEdge = 99 }
35 | }
36 | ]
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/TestScenarios/CrashIntoWallExactTiming.elm:
--------------------------------------------------------------------------------
1 | module TestScenarios.CrashIntoWallExactTiming exposing (expectedOutcome, spawnedKurves)
2 |
3 | import Color
4 | import TestScenarioHelpers exposing (RoundOutcome, makeZombieKurve, playerIds, tickNumber)
5 | import Types.Angle exposing (Angle(..))
6 | import Types.Kurve exposing (HoleStatus(..), Kurve)
7 |
8 |
9 | green : Kurve
10 | green =
11 | makeZombieKurve
12 | { color = Color.green
13 | , id = playerIds.green
14 | , state =
15 | { position = ( 100, 3.5 )
16 | , direction = Angle (pi / 2 + 0.01)
17 | , holeStatus = Unholy 60000
18 | }
19 | }
20 |
21 |
22 | spawnedKurves : List Kurve
23 | spawnedKurves =
24 | [ green ]
25 |
26 |
27 | expectedOutcome : RoundOutcome
28 | expectedOutcome =
29 | { tickThatShouldEndIt = tickNumber 251
30 | , howItShouldEnd =
31 | { aliveAtTheEnd = []
32 | , deadAtTheEnd =
33 | [ { id = playerIds.green
34 | , theDrawingPositionItNeverMadeItTo = { leftEdge = 349, topEdge = -1 }
35 | }
36 | ]
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/TestScenarios/SpeedEffectOnGame.elm:
--------------------------------------------------------------------------------
1 | module TestScenarios.SpeedEffectOnGame exposing (expectedOutcome, spawnedKurves)
2 |
3 | import Color
4 | import TestScenarioHelpers exposing (RoundOutcome, makeZombieKurve, playerIds)
5 | import Types.Angle exposing (Angle(..))
6 | import Types.Kurve exposing (HoleStatus(..), Kurve)
7 | import Types.Tick exposing (Tick)
8 |
9 |
10 | green : Kurve
11 | green =
12 | makeZombieKurve
13 | { color = Color.green
14 | , id = playerIds.green
15 | , state =
16 | { position = ( 108, 100 )
17 | , direction = Angle (pi / 2)
18 | , holeStatus = Unholy 60000
19 | }
20 | }
21 |
22 |
23 | spawnedKurves : List Kurve
24 | spawnedKurves =
25 | [ green ]
26 |
27 |
28 | expectedOutcome : Tick -> RoundOutcome
29 | expectedOutcome expectedEndTick =
30 | { tickThatShouldEndIt = expectedEndTick
31 | , howItShouldEnd =
32 | { aliveAtTheEnd = []
33 | , deadAtTheEnd =
34 | [ { id = playerIds.green
35 | , theDrawingPositionItNeverMadeItTo = { leftEdge = 557, topEdge = 99 }
36 | }
37 | ]
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/review/elm.json:
--------------------------------------------------------------------------------
1 | {
2 | "type": "application",
3 | "source-directories": [
4 | "src"
5 | ],
6 | "elm-version": "0.19.1",
7 | "dependencies": {
8 | "direct": {
9 | "elm/core": "1.0.5",
10 | "elm/project-metadata-utils": "1.0.2",
11 | "jfmengels/elm-review": "2.14.0",
12 | "jfmengels/elm-review-common": "1.3.3",
13 | "jfmengels/elm-review-simplify": "2.1.5",
14 | "jfmengels/elm-review-unused": "1.2.3",
15 | "stil4m/elm-syntax": "7.3.6"
16 | },
17 | "indirect": {
18 | "elm/bytes": "1.0.8",
19 | "elm/html": "1.0.0",
20 | "elm/json": "1.1.3",
21 | "elm/parser": "1.1.0",
22 | "elm/random": "1.0.0",
23 | "elm/regex": "1.0.0",
24 | "elm/time": "1.0.0",
25 | "elm/virtual-dom": "1.0.3",
26 | "elm-explorations/test": "2.2.0",
27 | "pzp1997/assoc-list": "1.0.0",
28 | "rtfeldman/elm-hex": "1.0.0",
29 | "stil4m/structured-writer": "1.0.3"
30 | }
31 | },
32 | "test-dependencies": {
33 | "direct": {},
34 | "indirect": {}
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/GUI/EndScreen.elm:
--------------------------------------------------------------------------------
1 | module GUI.EndScreen exposing (endScreen)
2 |
3 | import Dict
4 | import GUI.Digits
5 | import Html exposing (Html, div)
6 | import Html.Attributes as Attr
7 | import Players exposing (AllPlayers)
8 | import Types.Player exposing (Player)
9 | import Types.PlayerStatus exposing (PlayerStatus(..))
10 | import Types.Score exposing (Score(..))
11 |
12 |
13 | endScreen : AllPlayers -> Html msg
14 | endScreen players =
15 | div
16 | [ Attr.id "endScreen"
17 | ]
18 | [ results players
19 | , Html.img [ Attr.id "KONEC_HRY", Attr.src "./resources/konec-hry.png" ] []
20 | ]
21 |
22 |
23 | results : AllPlayers -> Html msg
24 | results players =
25 | div
26 | [ Attr.id "results"
27 | ]
28 | (players |> Dict.toList |> List.map (Tuple.second >> resultsEntry))
29 |
30 |
31 | resultsEntry : ( Player, PlayerStatus ) -> Html msg
32 | resultsEntry ( player, status ) =
33 | div
34 | [ Attr.class "resultsEntry"
35 | ]
36 | (case status of
37 | Participating (Score score) ->
38 | GUI.Digits.small player.color score
39 |
40 | NotParticipating ->
41 | []
42 | )
43 |
--------------------------------------------------------------------------------
/tools/ScenarioInOriginalGame/ModMem.elm:
--------------------------------------------------------------------------------
1 | module ModMem exposing
2 | ( AbsoluteAddress(..)
3 | , ModMemCmd(..)
4 | , RelativeAddress(..)
5 | , parseAddress
6 | , resolveAddress
7 | , serializeAddress
8 | )
9 |
10 | import Hex exposing (hex, parseHex)
11 | import Integer exposing (Integer, add)
12 |
13 |
14 | type AbsoluteAddress
15 | = AbsoluteAddress Integer -- Int is too small for the addresses we usually see.
16 |
17 |
18 | type RelativeAddress
19 | = RelativeAddress Int -- Int is more than enough here because we're not exactly dealing with gigabytes of memory …
20 |
21 |
22 | type ModMemCmd
23 | = ModifyMemory Description RelativeAddress Float
24 |
25 |
26 | type alias Description =
27 | String
28 |
29 |
30 | parseAddress : String -> Maybe AbsoluteAddress
31 | parseAddress =
32 | parseHex >> Maybe.map AbsoluteAddress
33 |
34 |
35 | serializeAddress : AbsoluteAddress -> String
36 | serializeAddress (AbsoluteAddress address) =
37 | hex address
38 |
39 |
40 | resolveAddress : AbsoluteAddress -> RelativeAddress -> AbsoluteAddress
41 | resolveAddress (AbsoluteAddress base) (RelativeAddress relative) =
42 | AbsoluteAddress (add base (Integer.fromSafeInt relative))
43 |
--------------------------------------------------------------------------------
/src/GUI/Text.elm:
--------------------------------------------------------------------------------
1 | module GUI.Text exposing
2 | ( Size(..)
3 | , string
4 | )
5 |
6 | import Color exposing (Color)
7 | import Html exposing (Html, span)
8 | import Html.Attributes as Attr
9 |
10 |
11 | type Size
12 | = Size Int
13 |
14 |
15 | string : Size -> Color -> String -> List (Html msg)
16 | string size color =
17 | String.toList >> List.map (char size color)
18 |
19 |
20 | char : Size -> Color -> Char -> Html msg
21 | char (Size multiplier) color c =
22 | let
23 | scaledFontHeight : Int
24 | scaledFontHeight =
25 | 8 * multiplier
26 |
27 | scaledFontWidth : Int
28 | scaledFontWidth =
29 | 8 * multiplier
30 |
31 | maskPosition : String
32 | maskPosition =
33 | cssSize (Char.toCode c * scaledFontWidth * -1)
34 | in
35 | span
36 | [ Attr.class "character"
37 | , Attr.style "background-color" (Color.toCssString color)
38 | , Attr.style "-webkit-mask-position" maskPosition
39 | , Attr.style "mask-position" maskPosition
40 | , Attr.style "width" (cssSize scaledFontWidth)
41 | , Attr.style "height" (cssSize scaledFontHeight)
42 | ]
43 | []
44 |
45 |
46 | cssSize : Int -> String
47 | cssSize n =
48 | String.fromInt n ++ "px"
49 |
--------------------------------------------------------------------------------
/tools/ScenarioInOriginalGame/OriginalGamePlayers.elm:
--------------------------------------------------------------------------------
1 | module OriginalGamePlayers exposing (PlayerId(..), allPlayers, numberOfPlayers, playerIndex, playerName)
2 |
3 |
4 | type PlayerId
5 | = Red
6 | | Yellow
7 | | Orange
8 | | Green
9 | | Pink
10 | | Blue
11 |
12 |
13 | {-| All players in the order they appear in the original game.
14 | -}
15 | allPlayers : List PlayerId
16 | allPlayers =
17 | [ Red, Yellow, Orange, Green, Pink, Blue ]
18 |
19 |
20 | numberOfPlayers : Int
21 | numberOfPlayers =
22 | List.length allPlayers
23 |
24 |
25 | playerIndex : PlayerId -> Int
26 | playerIndex playerId =
27 | case playerId of
28 | Red ->
29 | 0
30 |
31 | Yellow ->
32 | 1
33 |
34 | Orange ->
35 | 2
36 |
37 | Green ->
38 | 3
39 |
40 | Pink ->
41 | 4
42 |
43 | Blue ->
44 | 5
45 |
46 |
47 | playerName : PlayerId -> String
48 | playerName playerId =
49 | case playerId of
50 | Red ->
51 | "Red"
52 |
53 | Yellow ->
54 | "Yellow"
55 |
56 | Orange ->
57 | "Orange"
58 |
59 | Green ->
60 | "Green"
61 |
62 | Pink ->
63 | "Pink"
64 |
65 | Blue ->
66 | "Blue"
67 |
--------------------------------------------------------------------------------
/src/GUI/Lobby.elm:
--------------------------------------------------------------------------------
1 | module GUI.Lobby exposing (lobby)
2 |
3 | import Dict
4 | import GUI.Controls
5 | import GUI.Text as Text
6 | import Html exposing (Html, div)
7 | import Html.Attributes as Attr
8 | import Players exposing (AllPlayers)
9 | import Types.Player exposing (Player)
10 | import Types.PlayerStatus exposing (PlayerStatus(..))
11 |
12 |
13 | lobby : AllPlayers -> Html msg
14 | lobby players =
15 | div
16 | [ Attr.id "lobby"
17 | ]
18 | (Dict.values players |> List.map playerEntry)
19 |
20 |
21 | playerEntry : ( Player, PlayerStatus ) -> Html msg
22 | playerEntry ( player, status ) =
23 | let
24 | ( left, right ) =
25 | GUI.Controls.showControls player
26 | in
27 | Html.div
28 | [ Attr.class "playerEntry" ]
29 | [ Html.div
30 | [ Attr.class "controls"
31 | ]
32 | (Text.string (Text.Size 1) player.color <| "(" ++ left ++ " " ++ right ++ ")")
33 | , Html.div
34 | [ Attr.style "visibility"
35 | (case status of
36 | Participating _ ->
37 | "visible"
38 |
39 | NotParticipating ->
40 | "hidden"
41 | )
42 | ]
43 | (Text.string (Text.Size 2) player.color "READY")
44 | ]
45 |
--------------------------------------------------------------------------------
/src/GUI/TextOverlay.elm:
--------------------------------------------------------------------------------
1 | module GUI.TextOverlay exposing (textOverlay)
2 |
3 | import Color
4 | import GUI.Text
5 | import Game exposing (GameState(..), LiveOrReplay(..), PausedOrNot(..))
6 | import Html exposing (Html, div, p)
7 | import Html.Attributes as Attr
8 |
9 |
10 | textOverlay : GameState -> Html msg
11 | textOverlay gameState =
12 | div
13 | [ Attr.class "overlay"
14 | , Attr.class "textOverlay"
15 | ]
16 | (content gameState)
17 |
18 |
19 | content : GameState -> List (Html msg)
20 | content gameState =
21 | case gameState of
22 | Active Live Paused _ ->
23 | [ pressSpaceToContinue ]
24 |
25 | Active Live NotPaused _ ->
26 | []
27 |
28 | Active Replay Paused _ ->
29 | -- Hint on how to continue deliberately omitted here. See the PR/commit that added this comment for details.
30 | [ replayIndicator ]
31 |
32 | Active Replay NotPaused _ ->
33 | [ replayIndicator ]
34 |
35 | RoundOver _ _ ->
36 | []
37 |
38 |
39 | pressSpaceToContinue : Html msg
40 | pressSpaceToContinue =
41 | p [] <| GUI.Text.string (GUI.Text.Size 2) Color.white "Press Space to continue"
42 |
43 |
44 | replayIndicator : Html msg
45 | replayIndicator =
46 | p
47 | [ Attr.class "textInUpperLeftCorner"
48 | ]
49 | (GUI.Text.string (GUI.Text.Size 2) Color.white "R")
50 |
--------------------------------------------------------------------------------
/src/TestScenarios/CrashIntoTipOfTailEnd.elm:
--------------------------------------------------------------------------------
1 | module TestScenarios.CrashIntoTipOfTailEnd exposing (expectedOutcome, spawnedKurves)
2 |
3 | import Color
4 | import TestScenarioHelpers exposing (RoundOutcome, makeZombieKurve, playerIds, tickNumber)
5 | import Types.Angle exposing (Angle(..))
6 | import Types.Kurve exposing (HoleStatus(..), Kurve)
7 |
8 |
9 | red : Kurve
10 | red =
11 | makeZombieKurve
12 | { color = Color.red
13 | , id = playerIds.red
14 | , state =
15 | { position = ( 60.5, 60.5 )
16 | , direction = Angle (pi / 4)
17 | , holeStatus = Unholy 60000
18 | }
19 | }
20 |
21 |
22 | green : Kurve
23 | green =
24 | makeZombieKurve
25 | { color = Color.green
26 | , id = playerIds.green
27 | , state =
28 | { position = ( 30.5, 30.5 )
29 | , direction = Angle (pi / 4)
30 | , holeStatus = Unholy 60000
31 | }
32 | }
33 |
34 |
35 | spawnedKurves : List Kurve
36 | spawnedKurves =
37 | [ red, green ]
38 |
39 |
40 | expectedOutcome : RoundOutcome
41 | expectedOutcome =
42 | { tickThatShouldEndIt = tickNumber 39
43 | , howItShouldEnd =
44 | { aliveAtTheEnd = [ { id = playerIds.red } ]
45 | , deadAtTheEnd =
46 | [ { id = playerIds.green
47 | , theDrawingPositionItNeverMadeItTo = { leftEdge = 57, topEdge = 57 }
48 | }
49 | ]
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/src/GUI/Scoreboard.elm:
--------------------------------------------------------------------------------
1 | module GUI.Scoreboard exposing (scoreboard)
2 |
3 | import Dict
4 | import GUI.Digits
5 | import Game exposing (GameState)
6 | import Html exposing (Html, div)
7 | import Html.Attributes as Attr
8 | import Players exposing (AllPlayers, includeResultsFrom, participating)
9 | import Round exposing (Round)
10 | import Types.Player exposing (Player)
11 | import Types.PlayerStatus exposing (PlayerStatus(..))
12 | import Types.Score exposing (Score(..))
13 |
14 |
15 | scoreboard : GameState -> AllPlayers -> Html msg
16 | scoreboard gameState players =
17 | div
18 | [ Attr.id "scoreboard"
19 | , Attr.class "canvasHeight"
20 | ]
21 | (content players (Game.getCurrentRound gameState))
22 |
23 |
24 | content : AllPlayers -> Round -> List (Html msg)
25 | content players currentRound =
26 | if Dict.size (participating players) > 1 then
27 | players |> includeResultsFrom currentRound |> Dict.toList |> List.map (Tuple.second >> scoreboardEntry)
28 |
29 | else
30 | -- The scoreboard should be empty in single-player mode.
31 | []
32 |
33 |
34 | scoreboardEntry : ( Player, PlayerStatus ) -> Html msg
35 | scoreboardEntry ( player, status ) =
36 | div
37 | [ Attr.class "scoreboardEntry"
38 | ]
39 | (case status of
40 | Participating (Score score) ->
41 | GUI.Digits.large player.color score
42 |
43 | NotParticipating ->
44 | []
45 | )
46 |
--------------------------------------------------------------------------------
/src/TestScenarios/CrashIntoTailEnd90Degrees.elm:
--------------------------------------------------------------------------------
1 | module TestScenarios.CrashIntoTailEnd90Degrees exposing (expectedOutcome, spawnedKurves)
2 |
3 | import Color
4 | import TestScenarioHelpers exposing (RoundOutcome, makeZombieKurve, playerIds, tickNumber)
5 | import Types.Angle exposing (Angle(..))
6 | import Types.Kurve exposing (HoleStatus(..), Kurve)
7 |
8 |
9 | red : Kurve
10 | red =
11 | makeZombieKurve
12 | { color = Color.red
13 | , id = playerIds.red
14 | , state =
15 | { position = ( 100.5, 100.5 )
16 | , direction = Angle (pi / 2)
17 | , holeStatus = Unholy 60000
18 | }
19 | }
20 |
21 |
22 | green : Kurve
23 | green =
24 | makeZombieKurve
25 | { color = Color.green
26 | , id = playerIds.green
27 | , state =
28 | { position = ( 98.5, 110.5 )
29 | , direction = Angle pi
30 | , holeStatus = Unholy 60000
31 | }
32 | }
33 |
34 |
35 | spawnedKurves : List Kurve
36 | spawnedKurves =
37 | [ red, green ]
38 |
39 |
40 | expectedOutcome : RoundOutcome
41 | expectedOutcome =
42 | { tickThatShouldEndIt = tickNumber 8
43 | , howItShouldEnd =
44 | { aliveAtTheEnd = [ { id = playerIds.red } ]
45 | , deadAtTheEnd =
46 | [ { id = playerIds.green
47 | , theDrawingPositionItNeverMadeItTo = { leftEdge = 97, topEdge = 101 }
48 | }
49 | ]
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/src/TestScenarios/CuttingCornersBasic.elm:
--------------------------------------------------------------------------------
1 | module TestScenarios.CuttingCornersBasic exposing (expectedOutcome, spawnedKurves)
2 |
3 | import Color
4 | import TestScenarioHelpers exposing (RoundOutcome, makeZombieKurve, playerIds, tickNumber)
5 | import Types.Angle exposing (Angle(..))
6 | import Types.Kurve exposing (HoleStatus(..), Kurve)
7 |
8 |
9 | red : Kurve
10 | red =
11 | makeZombieKurve
12 | { color = Color.red
13 | , id = playerIds.red
14 | , state =
15 | { position = ( 200.5, 100.5 )
16 | , direction = Angle (pi / 2)
17 | , holeStatus = Unholy 60000
18 | }
19 | }
20 |
21 |
22 | green : Kurve
23 | green =
24 | makeZombieKurve
25 | { color = Color.green
26 | , id = playerIds.green
27 | , state =
28 | { position = ( 100.5, 196.5 )
29 | , direction = Angle (3 * pi / 4)
30 | , holeStatus = Unholy 60000
31 | }
32 | }
33 |
34 |
35 | spawnedKurves : List Kurve
36 | spawnedKurves =
37 | [ red, green ]
38 |
39 |
40 | expectedOutcome : RoundOutcome
41 | expectedOutcome =
42 | { tickThatShouldEndIt = tickNumber 277
43 | , howItShouldEnd =
44 | { aliveAtTheEnd = [ { id = playerIds.red } ]
45 | , deadAtTheEnd =
46 | [ { id = playerIds.green
47 | , theDrawingPositionItNeverMadeItTo = { leftEdge = 295, topEdge = -1 }
48 | }
49 | ]
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/review/src/ReviewConfig.elm:
--------------------------------------------------------------------------------
1 | module ReviewConfig exposing (config)
2 |
3 | {-| Do not rename the ReviewConfig module or the config function, because
4 | `elm-review` will look for these.
5 |
6 | To add packages that contain rules, add them to this review project using
7 |
8 | `elm install author/packagename`
9 |
10 | when inside the directory containing this file.
11 |
12 | -}
13 |
14 | import NoConfusingPrefixOperator
15 | import NoExposingEverything
16 | import NoImportingEverything
17 | import NoMissingTypeAnnotation
18 | import NoMissingTypeAnnotationInLetIn
19 | import NoPrematureLetComputation
20 | import NoUnused.CustomTypeConstructorArgs
21 | import NoUnused.CustomTypeConstructors
22 | import NoUnused.Dependencies
23 | import NoUnused.Exports
24 | import NoUnused.Parameters
25 | import NoUnused.Patterns
26 | import NoUnused.Variables
27 | import Review.Rule exposing (Rule)
28 | import Simplify
29 |
30 |
31 | config : List Rule
32 | config =
33 | [ NoConfusingPrefixOperator.rule
34 | , NoExposingEverything.rule
35 | , NoImportingEverything.rule []
36 | , NoMissingTypeAnnotation.rule
37 | , NoMissingTypeAnnotationInLetIn.rule
38 | , NoPrematureLetComputation.rule
39 | , NoUnused.CustomTypeConstructors.rule []
40 | , NoUnused.CustomTypeConstructorArgs.rule
41 | , NoUnused.Dependencies.rule
42 | , NoUnused.Exports.rule
43 | , NoUnused.Parameters.rule
44 | , NoUnused.Patterns.rule
45 | , NoUnused.Variables.rule
46 | , Simplify.rule Simplify.defaults
47 | ]
48 |
--------------------------------------------------------------------------------
/src/TestScenarios/CrashIntoKurveTiming.elm:
--------------------------------------------------------------------------------
1 | module TestScenarios.CrashIntoKurveTiming exposing (expectedOutcome, spawnedKurves)
2 |
3 | import Color
4 | import TestScenarioHelpers exposing (RoundOutcome, makeZombieKurve, playerIds, tickNumber)
5 | import Types.Angle exposing (Angle(..))
6 | import Types.Kurve exposing (HoleStatus(..), Kurve)
7 |
8 |
9 | red : Float -> Kurve
10 | red y_red =
11 | makeZombieKurve
12 | { color = Color.red
13 | , id = playerIds.red
14 | , state =
15 | { position = ( 150, y_red )
16 | , direction = Angle (pi / 2)
17 | , holeStatus = Unholy 60000
18 | }
19 | }
20 |
21 |
22 | green : Kurve
23 | green =
24 | makeZombieKurve
25 | { color = Color.green
26 | , id = playerIds.green
27 | , state =
28 | { position = ( 100, 107.5 )
29 | , direction = Angle (pi / 2 + 0.02)
30 | , holeStatus = Unholy 60000
31 | }
32 | }
33 |
34 |
35 | spawnedKurves : Float -> List Kurve
36 | spawnedKurves y_red =
37 | [ red y_red, green ]
38 |
39 |
40 | expectedOutcome : RoundOutcome
41 | expectedOutcome =
42 | { tickThatShouldEndIt = tickNumber 226
43 | , howItShouldEnd =
44 | { aliveAtTheEnd = [ { id = playerIds.red } ]
45 | , deadAtTheEnd =
46 | [ { id = playerIds.green
47 | , theDrawingPositionItNeverMadeItTo = { leftEdge = 324, topEdge = 101 }
48 | }
49 | ]
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/src/TestScenarios/CuttingCornersThreePixelsRealExample.elm:
--------------------------------------------------------------------------------
1 | module TestScenarios.CuttingCornersThreePixelsRealExample exposing (expectedOutcome, spawnedKurves)
2 |
3 | import Color
4 | import TestScenarioHelpers exposing (RoundOutcome, makeZombieKurve, playerIds, tickNumber)
5 | import Types.Angle exposing (Angle(..))
6 | import Types.Kurve exposing (HoleStatus(..), Kurve)
7 |
8 |
9 | red : Kurve
10 | red =
11 | makeZombieKurve
12 | { color = Color.red
13 | , id = playerIds.red
14 | , state =
15 | { position = ( 299.5, 302.5 )
16 | , direction = Angle (-71 * (2 * pi / 360) + (pi / 2))
17 | , holeStatus = Unholy 60000
18 | }
19 | }
20 |
21 |
22 | green : Kurve
23 | green =
24 | makeZombieKurve
25 | { color = Color.green
26 | , id = playerIds.green
27 | , state =
28 | { position = ( 319, 269 )
29 | , direction = Angle (-123 * (2 * pi / 360) + (pi / 2))
30 | , holeStatus = Unholy 60000
31 | }
32 | }
33 |
34 |
35 | spawnedKurves : List Kurve
36 | spawnedKurves =
37 | [ red, green ]
38 |
39 |
40 | expectedOutcome : RoundOutcome
41 | expectedOutcome =
42 | { tickThatShouldEndIt = tickNumber 40
43 | , howItShouldEnd =
44 | { aliveAtTheEnd = [ { id = playerIds.red } ]
45 | , deadAtTheEnd =
46 | [ { id = playerIds.green
47 | , theDrawingPositionItNeverMadeItTo = { leftEdge = 296, topEdge = 301 }
48 | }
49 | ]
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/.github/workflows/validation.yaml:
--------------------------------------------------------------------------------
1 | name: Validation
2 |
3 | on:
4 | pull_request:
5 | branches:
6 | - main
7 |
8 | jobs:
9 | validation:
10 | name: PR Validation Build
11 | runs-on: ubuntu-24.04
12 | steps:
13 | - uses: actions/checkout@v1.2.0
14 | - name: Build Docker image
15 | run: |
16 | docker build .
17 |
18 | tools:
19 | name: Check Tools
20 | runs-on: ubuntu-latest
21 | steps:
22 | - uses: actions/checkout@v1.2.0
23 | - name: Setup Node
24 | uses: actions/setup-node@v5.0.0
25 | with:
26 | node-version: 24.10.0
27 | package-manager-cache: false # I don't know if this is needed, but: https://github.com/actions/setup-node/blob/13427813f706a0f6c9b74603b31103c40ab1c35a/README.md?plain=1#L18
28 | - name: Check Python formatting
29 | uses: astral-sh/ruff-action@v3.5.1
30 | with:
31 | version: 0.14.4
32 | args: format --check --diff
33 | src: ./tools
34 | - name: Check Python lint
35 | run: ruff check ./tools
36 | - name: Install npm dependencies
37 | run: npm ci --omit optional --no-audit
38 | - name: Install xdotool
39 | run: sudo apt-get install --yes xdotool
40 | - name: Dry-run scenario script (Linux)
41 | run: DRY_RUN=true ./tools/scenario.py docs/original-game/ZATACKA.EXE 0x7fffd8010ff6 tools/dosbox-linux.conf
42 | - name: Dry-run scenario script (WSL)
43 | run: DRY_RUN=true ./tools/scenario.py docs/original-game/ZATACKA.EXE 0x7fffc1c65ff6 tools/dosbox-wsl.conf
44 |
--------------------------------------------------------------------------------
/src/GUI/Digits.elm:
--------------------------------------------------------------------------------
1 | module GUI.Digits exposing
2 | ( large
3 | , small
4 | )
5 |
6 | import Color exposing (Color)
7 | import GUI.Text as Text
8 | import Html exposing (Html, div)
9 | import Html.Attributes as Attr
10 |
11 |
12 | type Size
13 | = Large
14 | | Small
15 |
16 |
17 | large : Color -> Int -> List (Html msg)
18 | large =
19 | digits Large
20 |
21 |
22 | small : Color -> Int -> List (Html msg)
23 | small =
24 | digits Small
25 |
26 |
27 | type Digit
28 | = Digit Int
29 |
30 |
31 | fromChar : Char -> Maybe Digit
32 | fromChar =
33 | String.fromChar >> String.toInt >> Maybe.map Digit
34 |
35 |
36 | digitsFromInt : Int -> List Digit
37 | digitsFromInt =
38 | String.fromInt >> String.toList >> List.filterMap fromChar
39 |
40 |
41 | digits : Size -> Color -> Int -> List (Html msg)
42 | digits size color =
43 | case size of
44 | Large ->
45 | digitsFromInt >> List.map (digit color)
46 |
47 | Small ->
48 | String.fromInt >> Text.string (Text.Size 2) color
49 |
50 |
51 | digit : Color -> Digit -> Html msg
52 | digit color (Digit n) =
53 | let
54 | ( class, width ) =
55 | ( "largeDigit", 28 )
56 |
57 | maskPosition : String
58 | maskPosition =
59 | String.fromInt (n * width * -1) ++ "px 0"
60 | in
61 | div
62 | [ Attr.class class
63 | , Attr.style "background-color" <| Color.toCssString color
64 | , Attr.style "-webkit-mask-position" maskPosition
65 | , Attr.style "mask-position" maskPosition
66 | ]
67 | []
68 |
--------------------------------------------------------------------------------
/tools/extract-glyphs-from-original-game.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | const { spawnSync } = require("child_process");
4 | const fs = require("fs");
5 |
6 | main(...process.argv.slice(2));
7 |
8 | function main(executable, stringToReplace) {
9 | spawnSync("git", [ "restore", executable ]);
10 | const originalFileContent = fs.readFileSync(executable);
11 | for (let i = 32; i < 127; i += stringToReplace.length) {
12 | const codePoints = [...Array(stringToReplace.length).keys()].map(x => spaceIfUnsafe(i + x));
13 | const replacement = String.fromCodePoint(...codePoints);
14 | console.log("📝 Current string:", replacement);
15 | fs.writeFileSync(executable, replace(originalFileContent, stringToReplace, replacement));
16 | spawnSync("dosbox", [ executable ], {
17 | stdio: "inherit", // To make it easy to see that screenshots are saved and where.
18 | });
19 | // Screenshot can be taken now.
20 | }
21 | spawnSync("git", [ "restore", executable ]);
22 | }
23 |
24 | function spaceIfUnsafe(codePoint) {
25 | return codePoint >= 32 && codePoint < 127 ? codePoint : 32;
26 | }
27 |
28 | // https://github.com/juliangruber/buffer-replace/blob/a5e43eb7c457d048f47712e607b13a16bf5f5bd4/index.js
29 | function replace(buf, original, replacement) {
30 | const idx = buf.indexOf(original);
31 | if (idx === -1) return buf;
32 | const b = Buffer.from(replacement);
33 | const before = buf.slice(0, idx);
34 | const after = replace(buf.slice(idx + original.length), original, b);
35 | const len = idx + b.length + after.length;
36 | return Buffer.concat([ before, b, after ], len);
37 | }
38 |
--------------------------------------------------------------------------------
/src/GUI/Controls.elm:
--------------------------------------------------------------------------------
1 | module GUI.Controls exposing (showControls)
2 |
3 | import GUI.Buttons.Keyboard exposing (keyCodeRepresentation)
4 | import GUI.Buttons.Mouse exposing (mouseButtonRepresentation)
5 | import Input exposing (Button)
6 | import Types.Player exposing (Player)
7 |
8 |
9 | showControls : Player -> ( String, String )
10 | showControls { controls } =
11 | let
12 | showFirst : List Button -> String
13 | showFirst =
14 | List.head >> Maybe.map showButton >> Maybe.withDefault "N/A"
15 | in
16 | Tuple.mapBoth showFirst showFirst controls
17 |
18 |
19 | showButton : Button -> String
20 | showButton button =
21 | let
22 | representation : String
23 | representation =
24 | case button of
25 | Input.Mouse n ->
26 | mouseButtonRepresentation n
27 |
28 | Input.Key keyCode ->
29 | keyCodeRepresentation keyCode
30 |
31 | charsToDrop : Int
32 | charsToDrop =
33 | String.length representation - maxLength
34 | in
35 | String.dropRight charsToDrop representation
36 |
37 |
38 | {-| The purpose of this limit is to prevent unexpected layout breakage caused either by
39 |
40 | - an explicitly defined representation, or
41 | - a key code that we don't have a human-readable representation for.
42 |
43 | 7 characters is the maximum in the original game (L.Arrow, D.Arrow, L.Mouse, R.Mouse).
44 |
45 | 8 characters is the maximum that is guaranteed to fit to the left of "READY" in the lobby of the original game.
46 |
47 | -}
48 | maxLength : Int
49 | maxLength =
50 | 8
51 |
--------------------------------------------------------------------------------
/src/GUI/ConfirmQuitDialog.elm:
--------------------------------------------------------------------------------
1 | module GUI.ConfirmQuitDialog exposing (confirmQuitDialog)
2 |
3 | import Color
4 | import Dialog
5 | import GUI.Text
6 | import Game exposing (GameState(..))
7 | import Html exposing (Attribute, Html, button, div, p)
8 | import Html.Attributes as Attr
9 | import Html.Events exposing (onClick)
10 |
11 |
12 | confirmQuitDialog : (Dialog.Option -> msg) -> GameState -> Html msg
13 | confirmQuitDialog makeMsg gameState =
14 | case gameState of
15 | RoundOver _ (Dialog.Open selectedOption) ->
16 | div
17 | [ Attr.class "overlay"
18 | , Attr.class "dialogOverlay"
19 | ]
20 | [ dialogBox makeMsg "Really quit?" selectedOption
21 | ]
22 |
23 | _ ->
24 | Html.text ""
25 |
26 |
27 | dialogBox : (Dialog.Option -> msg) -> String -> Dialog.Option -> Html msg
28 | dialogBox makeMsg question selectedOption =
29 | let
30 | optionButton : Dialog.Option -> String -> Html msg
31 | optionButton option label =
32 | button (onClick (makeMsg option) :: focusedIf selectedOption option) (smallWhiteText label)
33 | in
34 | div [ Attr.class "dialog" ]
35 | [ p [] (smallWhiteText question)
36 | , optionButton Dialog.Confirm "Yes"
37 | , optionButton Dialog.Cancel "No"
38 | ]
39 |
40 |
41 | focusedIf : Dialog.Option -> Dialog.Option -> List (Attribute msg)
42 | focusedIf a b =
43 | if a == b then
44 | [ Attr.class "focused" ]
45 |
46 | else
47 | []
48 |
49 |
50 | smallWhiteText : String -> List (Html msg)
51 | smallWhiteText =
52 | GUI.Text.string (GUI.Text.Size 1) Color.white
53 |
--------------------------------------------------------------------------------
/src/Types/Kurve.elm:
--------------------------------------------------------------------------------
1 | module Types.Kurve exposing
2 | ( Fate(..)
3 | , HoleStatus(..)
4 | , Kurve
5 | , State
6 | , UserInteraction(..)
7 | , modifyReversedInteractions
8 | , reset
9 | )
10 |
11 | import Color exposing (Color)
12 | import Set exposing (Set)
13 | import Types.Angle exposing (Angle)
14 | import Types.PlayerId exposing (PlayerId)
15 | import Types.Tick exposing (Tick)
16 | import Types.TurningState exposing (TurningState)
17 | import World exposing (Position)
18 |
19 |
20 | type alias Kurve =
21 | { color : Color
22 | , id : PlayerId
23 | , controls : ( Set String, Set String ) -- `Set` is exactly what we want here; `String` is not, but since Elm doesn't support user-defined typeclass instances, we have to make do with a type that already is `comparable`.
24 | , state : State
25 | , stateAtSpawn : State
26 | , reversedInteractions : List UserInteraction
27 | }
28 |
29 |
30 | type UserInteraction
31 | = HappenedBefore Tick TurningState
32 |
33 |
34 | modifyReversedInteractions : (List UserInteraction -> List UserInteraction) -> Kurve -> Kurve
35 | modifyReversedInteractions f kurve =
36 | { kurve | reversedInteractions = f kurve.reversedInteractions }
37 |
38 |
39 | type alias State =
40 | { position : Position
41 | , direction : Angle
42 | , holeStatus : HoleStatus
43 | }
44 |
45 |
46 | type Fate
47 | = Lives
48 | | Dies
49 |
50 |
51 | {-| In both cases, the integer represent the number of ticks left in the current state.
52 | -}
53 | type HoleStatus
54 | = Holy Int
55 | | Unholy Int
56 |
57 |
58 | reset : Kurve -> Kurve
59 | reset kurve =
60 | { kurve | state = kurve.stateAtSpawn }
61 |
--------------------------------------------------------------------------------
/src/Input.elm:
--------------------------------------------------------------------------------
1 | port module Input exposing (Button(..), ButtonDirection(..), inputSubscriptions, toStringSetControls, updatePressedButtons)
2 |
3 | import Set exposing (Set)
4 |
5 |
6 | port onKeydown : (String -> msg) -> Sub msg
7 |
8 |
9 | port onKeyup : (String -> msg) -> Sub msg
10 |
11 |
12 | port onMousedown : (Int -> msg) -> Sub msg
13 |
14 |
15 | port onMouseup : (Int -> msg) -> Sub msg
16 |
17 |
18 | inputSubscriptions : (ButtonDirection -> Button -> msg) -> List (Sub msg)
19 | inputSubscriptions makeMsg =
20 | [ onKeydown (Key >> makeMsg Down)
21 | , onKeyup (Key >> makeMsg Up)
22 | , onMousedown (Mouse >> makeMsg Down)
23 | , onMouseup (Mouse >> makeMsg Up)
24 | ]
25 |
26 |
27 | type ButtonDirection
28 | = Up
29 | | Down
30 |
31 |
32 | updatePressedButtons : ButtonDirection -> Button -> Set String -> Set String
33 | updatePressedButtons direction =
34 | buttonToString
35 | >> (case direction of
36 | Down ->
37 | Set.insert
38 |
39 | Up ->
40 | Set.remove
41 | )
42 |
43 |
44 | type Button
45 | = Key String
46 | | Mouse Int
47 |
48 |
49 | buttonToString : Button -> String
50 | buttonToString button =
51 | case button of
52 | Key eventCode ->
53 | eventCode
54 |
55 | Mouse buttonNumber ->
56 | "Mouse" ++ String.fromInt buttonNumber
57 |
58 |
59 | toStringSetControls : ( List Button, List Button ) -> ( Set String, Set String )
60 | toStringSetControls =
61 | Tuple.mapBoth buttonListToStringSet buttonListToStringSet
62 |
63 |
64 | buttonListToStringSet : List Button -> Set String
65 | buttonListToStringSet =
66 | List.map buttonToString >> Set.fromList
67 |
--------------------------------------------------------------------------------
/src/TestScenarios/AroundTheWorld.elm:
--------------------------------------------------------------------------------
1 | module TestScenarios.AroundTheWorld exposing (expectedOutcome, spawnedKurves)
2 |
3 | import Color
4 | import TestScenarioHelpers exposing (RoundOutcome, makeUserInteractions, makeZombieKurve, playerIds, tickNumber)
5 | import Types.Angle exposing (Angle(..))
6 | import Types.Kurve exposing (HoleStatus(..), Kurve)
7 | import Types.TurningState exposing (TurningState(..))
8 |
9 |
10 | greenZombie : Kurve
11 | greenZombie =
12 | makeZombieKurve
13 | { color = Color.green
14 | , id = playerIds.green
15 | , state =
16 | { position = ( 4.5, 1.5 )
17 | , direction = Angle (pi / 2)
18 | , holeStatus = Unholy 60000
19 | }
20 | }
21 |
22 |
23 | green : Kurve
24 | green =
25 | { greenZombie
26 | | reversedInteractions =
27 | makeUserInteractions
28 | -- Intended to make the Kurve touch each of the four walls on its way around the world.
29 | [ ( 526, TurningRight )
30 | , ( 45, NotTurning )
31 | , ( 420, TurningRight )
32 | , ( 45, NotTurning )
33 | , ( 491, TurningRight )
34 | , ( 44, NotTurning )
35 | ]
36 | }
37 |
38 |
39 | spawnedKurves : List Kurve
40 | spawnedKurves =
41 | [ green ]
42 |
43 |
44 | expectedOutcome : RoundOutcome
45 | expectedOutcome =
46 | { tickThatShouldEndIt = tickNumber 2011
47 | , howItShouldEnd =
48 | { aliveAtTheEnd = []
49 | , deadAtTheEnd =
50 | [ { id = playerIds.green
51 | , theDrawingPositionItNeverMadeItTo = { leftEdge = 0, topEdge = -1 }
52 | }
53 | ]
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/tools/ScenarioInOriginalGame/MemoryLayout.elm:
--------------------------------------------------------------------------------
1 | module MemoryLayout exposing (StateComponent(..), relativeAddressFor)
2 |
3 | import ModMem exposing (RelativeAddress(..))
4 | import OriginalGamePlayers exposing (PlayerId, numberOfPlayers, playerIndex)
5 |
6 |
7 | type StateComponent
8 | = X
9 | | Y
10 | | Dir
11 |
12 |
13 | relativeAddressFor : PlayerId -> StateComponent -> RelativeAddress
14 | relativeAddressFor playerId stateComponent =
15 | let
16 | (RelativeAddress arrayStart) =
17 | case stateComponent of
18 | X ->
19 | xsAddress
20 |
21 | Y ->
22 | ysAddress
23 |
24 | Dir ->
25 | directionsAddress
26 |
27 | index : Int
28 | index =
29 | playerIndex playerId
30 | in
31 | RelativeAddress (arrayStart + index * sizeOfFloat)
32 |
33 |
34 | {-| The x coordinates are stored first.
35 | -}
36 | xsAddress : RelativeAddress
37 | xsAddress =
38 | RelativeAddress 0
39 |
40 |
41 | {-| The y coordinates are stored after the x coordinates.
42 | -}
43 | ysAddress : RelativeAddress
44 | ysAddress =
45 | let
46 | (RelativeAddress xStart) =
47 | xsAddress
48 | in
49 | RelativeAddress (xStart + spaceForXs)
50 |
51 |
52 | {-| The directions are stored after the y coordinates.
53 | -}
54 | directionsAddress : RelativeAddress
55 | directionsAddress =
56 | let
57 | (RelativeAddress yStart) =
58 | ysAddress
59 | in
60 | RelativeAddress (yStart + spaceForYs)
61 |
62 |
63 | spaceForXs : Int
64 | spaceForXs =
65 | numberOfPlayers * sizeOfFloat
66 |
67 |
68 | spaceForYs : Int
69 | spaceForYs =
70 | numberOfPlayers * sizeOfFloat
71 |
72 |
73 | sizeOfFloat : Int
74 | sizeOfFloat =
75 | 4
76 |
--------------------------------------------------------------------------------
/src/TestScenarios/StressTestRealisticTurtleSurvivalRound.elm:
--------------------------------------------------------------------------------
1 | module TestScenarios.StressTestRealisticTurtleSurvivalRound exposing (expectedOutcome, spawnedKurves)
2 |
3 | import Color
4 | import TestScenarioHelpers exposing (CumulativeInteraction, RoundOutcome, makeUserInteractions, makeZombieKurve, playerIds, tickNumber)
5 | import Types.Angle exposing (Angle(..))
6 | import Types.Kurve exposing (HoleStatus(..), Kurve)
7 | import Types.TurningState exposing (TurningState(..))
8 |
9 |
10 | greenZombie : Kurve
11 | greenZombie =
12 | makeZombieKurve
13 | { color = Color.green
14 | , id = playerIds.green
15 | , state =
16 | { position = ( 32.5, 3.5 )
17 | , direction = Angle (pi / 2)
18 | , holeStatus = Unholy 60000
19 | }
20 | }
21 |
22 |
23 | green : Kurve
24 | green =
25 | { greenZombie
26 | | reversedInteractions =
27 | List.range 1 20
28 | |> List.concatMap makeLap
29 | |> makeUserInteractions
30 | }
31 |
32 |
33 | spawnedKurves : List Kurve
34 | spawnedKurves =
35 | [ green ]
36 |
37 |
38 | makeLap : Int -> List CumulativeInteraction
39 | makeLap i =
40 | [ ( 510 - 20 * i, TurningRight )
41 | , ( 45, NotTurning )
42 | , ( 430 - 20 * i, TurningRight )
43 | , ( 45, NotTurning )
44 | , ( 495 - 20 * i, TurningRight )
45 | , ( 44, NotTurning )
46 | , ( 414 - 20 * i, TurningRight )
47 | , ( 45, NotTurning )
48 | ]
49 |
50 |
51 | expectedOutcome : RoundOutcome
52 | expectedOutcome =
53 | { tickThatShouldEndIt = tickNumber 23875
54 | , howItShouldEnd =
55 | { aliveAtTheEnd = []
56 | , deadAtTheEnd =
57 | [ { id = playerIds.green
58 | , theDrawingPositionItNeverMadeItTo = { leftEdge = 372, topEdge = 217 }
59 | }
60 | ]
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/tools/ScenarioInOriginalGame/ScenarioComments.elm:
--------------------------------------------------------------------------------
1 | module ScenarioComments exposing (ignoreBogusWriteComment, sectionEnd, sectionStart, setStateComponentComment)
2 |
3 | import MemoryLayout exposing (StateComponent(..))
4 | import OriginalGamePlayers exposing (PlayerId(..), playerName)
5 |
6 |
7 | sectionStart : String -> String
8 | sectionStart description =
9 | String.fromChar sectionStartIcon ++ " " ++ description
10 |
11 |
12 | sectionEnd : String -> String
13 | sectionEnd description =
14 | String.fromChar sectionEndIcon ++ " " ++ description
15 |
16 |
17 | setStateComponentComment : StateComponent -> PlayerId -> String
18 | setStateComponentComment stateComponent playerId =
19 | String.fromChar (playerIcon playerId) ++ " Set " ++ playerName playerId ++ "'s " ++ showStateComponent stateComponent
20 |
21 |
22 | ignoreBogusWriteComment : StateComponent -> PlayerId -> String
23 | ignoreBogusWriteComment stateComponent playerId =
24 | String.fromChar workaroundIcon ++ " Ignore bogus write to " ++ playerName playerId ++ "'s " ++ showStateComponent stateComponent
25 |
26 |
27 | playerIcon : PlayerId -> Char
28 | playerIcon playerId =
29 | case playerId of
30 | Red ->
31 | '🟥'
32 |
33 | Yellow ->
34 | '🟨'
35 |
36 | Orange ->
37 | '🟧'
38 |
39 | Green ->
40 | '🟩'
41 |
42 | Pink ->
43 | '🟪'
44 |
45 | Blue ->
46 | '🟦'
47 |
48 |
49 | workaroundIcon : Char
50 | workaroundIcon =
51 | '🔧'
52 |
53 |
54 | sectionStartIcon : Char
55 | sectionStartIcon =
56 | '⏳'
57 |
58 |
59 | sectionEndIcon : Char
60 | sectionEndIcon =
61 | '✅'
62 |
63 |
64 | showStateComponent : StateComponent -> String
65 | showStateComponent stateComponent =
66 | case stateComponent of
67 | X ->
68 | "x"
69 |
70 | Y ->
71 | "y"
72 |
73 | Dir ->
74 | "direction"
75 |
--------------------------------------------------------------------------------
/src/Canvas.elm:
--------------------------------------------------------------------------------
1 | port module Canvas exposing (bodyDrawingCmd, clearEverything, drawSpawnIfAndOnlyIf, headDrawingCmd)
2 |
3 | import Color exposing (Color)
4 | import Config exposing (WorldConfig)
5 | import Thickness exposing (theThickness)
6 | import Types.Kurve exposing (Kurve)
7 | import World exposing (DrawingPosition)
8 |
9 |
10 | port render : List { position : DrawingPosition, thickness : Int, color : String } -> Cmd msg
11 |
12 |
13 | port clear : { x : Int, y : Int, width : Int, height : Int } -> Cmd msg
14 |
15 |
16 | port renderOverlay : List { position : DrawingPosition, thickness : Int, color : String } -> Cmd msg
17 |
18 |
19 | bodyDrawingCmd : List ( Color, DrawingPosition ) -> Cmd msg
20 | bodyDrawingCmd =
21 | render
22 | << List.map
23 | (\( color, position ) ->
24 | { position = position
25 | , thickness = theThickness
26 | , color = Color.toCssString color
27 | }
28 | )
29 |
30 |
31 | headDrawingCmd : List Kurve -> Cmd msg
32 | headDrawingCmd =
33 | renderOverlay
34 | << List.map
35 | (\kurve ->
36 | { position = World.drawingPosition kurve.state.position
37 | , thickness = theThickness
38 | , color = Color.toCssString kurve.color
39 | }
40 | )
41 |
42 |
43 | clearEverything : WorldConfig -> Cmd msg
44 | clearEverything { width, height } =
45 | Cmd.batch
46 | [ renderOverlay []
47 | , clear { x = 0, y = 0, width = width, height = height }
48 | ]
49 |
50 |
51 | drawSpawnIfAndOnlyIf : Bool -> Kurve -> Cmd msg
52 | drawSpawnIfAndOnlyIf shouldBeVisible kurve =
53 | let
54 | drawingPosition : DrawingPosition
55 | drawingPosition =
56 | World.drawingPosition kurve.state.position
57 | in
58 | if shouldBeVisible then
59 | render <|
60 | List.singleton
61 | { position = drawingPosition
62 | , thickness = theThickness
63 | , color = Color.toCssString kurve.color
64 | }
65 |
66 | else
67 | clear
68 | { x = drawingPosition.leftEdge
69 | , y = drawingPosition.topEdge
70 | , width = theThickness
71 | , height = theThickness
72 | }
73 |
--------------------------------------------------------------------------------
/.github/workflows/deployment.yaml:
--------------------------------------------------------------------------------
1 | name: Deployment
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | workflow_dispatch: # Enables running this workflow manually from the Actions tab.
8 |
9 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages:
10 | permissions:
11 | contents: read
12 | pages: write
13 | id-token: write # https://github.com/actions/deploy-pages/issues/329#issuecomment-2030341950
14 |
15 | concurrency:
16 | group: deploy-to-github-pages
17 | cancel-in-progress: false
18 |
19 | jobs:
20 | build-job:
21 | name: Build
22 | runs-on: ubuntu-24.04
23 | steps:
24 | - name: Checkout
25 | uses: actions/checkout@v5.0.0
26 | with:
27 | path: elm-version/
28 |
29 | - name: Setup Node
30 | uses: actions/setup-node@v5.0.0
31 | with:
32 | node-version: 24.10.0
33 | package-manager-cache: false # I don't know if this is needed, but: https://github.com/actions/setup-node/blob/13427813f706a0f6c9b74603b31103c40ab1c35a/README.md?plain=1#L18
34 |
35 | - name: Build
36 | working-directory: elm-version/
37 | run: |
38 | npm ci --omit optional --no-audit
39 | npm run build
40 |
41 | - name: Checkout legacy JavaScript version
42 | uses: actions/checkout@v5.0.0
43 | with:
44 | path: legacy-javascript-version/
45 | ref: master
46 | sparse-checkout: |
47 | /index.html
48 | /ZATACKA.html
49 | /favicon.ico
50 | /manifest.json
51 | /js/
52 | /css/
53 | /resources/
54 | sparse-checkout-cone-mode: false
55 |
56 | - name: Copy legacy JavaScript version
57 | run: |
58 | cp --recursive legacy-javascript-version/ website-root # Assumption: target directory doesn't already exist; created here.
59 |
60 | - name: Copy Elm version
61 | run: |
62 | cp --recursive elm-version/_site/ website-root/elm
63 |
64 | - name: Setup GitHub Pages
65 | uses: actions/configure-pages@v5.0.0
66 |
67 | - name: Upload artifact
68 | uses: actions/upload-pages-artifact@v4.0.0
69 | with:
70 | name: github-pages
71 | path: website-root/
72 |
73 | - name: Deploy to GitHub Pages
74 | id: deployment
75 | uses: actions/deploy-pages@v4.0.5
76 | with:
77 | artifact_name: github-pages
78 |
--------------------------------------------------------------------------------
/src/TestScenarios/CuttingCornersPerfectOverpainting.elm:
--------------------------------------------------------------------------------
1 | module TestScenarios.CuttingCornersPerfectOverpainting exposing (expectedOutcome, spawnedKurves)
2 |
3 | import Color
4 | import TestScenarioHelpers exposing (RoundOutcome, makeZombieKurve, playerIds, tickNumber)
5 | import Types.Angle exposing (Angle(..))
6 | import Types.Kurve exposing (HoleStatus(..), Kurve)
7 |
8 |
9 | red : Kurve
10 | red =
11 | makeZombieKurve
12 | { color = Color.red
13 | , id = playerIds.red
14 | , state =
15 | { position = ( 30.5, 30.5 )
16 | , direction = Angle (pi / 4)
17 | , holeStatus = Unholy 60000
18 | }
19 | }
20 |
21 |
22 | yellow : Kurve
23 | yellow =
24 | makeZombieKurve
25 | { color = Color.yellow
26 | , id = playerIds.yellow
27 | , state =
28 | { position = ( 88.5, 88.5 )
29 | , direction = Angle (5 * pi / 4)
30 | , holeStatus = Unholy 60000
31 | }
32 | }
33 |
34 |
35 | orange : Kurve
36 | orange =
37 | makeZombieKurve
38 | { color = Color.orange
39 | , id = playerIds.orange
40 | , state =
41 | { position = ( 100, 400 )
42 | , direction = Angle (pi / 2)
43 | , holeStatus = Unholy 60000
44 | }
45 | }
46 |
47 |
48 | green : Kurve
49 | green =
50 | makeZombieKurve
51 | { color = Color.green
52 | , id = playerIds.green
53 | , state =
54 | { position = ( 19.5, 98.5 )
55 | , direction = Angle (3 * pi / 4)
56 | , holeStatus = Unholy 60000
57 | }
58 | }
59 |
60 |
61 | spawnedKurves : List Kurve
62 | spawnedKurves =
63 | [ red, yellow, orange, green ]
64 |
65 |
66 | expectedOutcome : RoundOutcome
67 | expectedOutcome =
68 | { tickThatShouldEndIt = tickNumber 138
69 | , howItShouldEnd =
70 | { aliveAtTheEnd = [ { id = playerIds.orange } ]
71 | , deadAtTheEnd =
72 | [ { id = playerIds.green
73 | , theDrawingPositionItNeverMadeItTo = { leftEdge = 116, topEdge = -1 }
74 | }
75 | , { id = playerIds.yellow
76 | , theDrawingPositionItNeverMadeItTo = { leftEdge = 58, topEdge = 58 }
77 | }
78 | , { id = playerIds.red
79 | , theDrawingPositionItNeverMadeItTo = { leftEdge = 57, topEdge = 57 }
80 | }
81 | ]
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/src/Turning.elm:
--------------------------------------------------------------------------------
1 | module Turning exposing
2 | ( computeAngleChange
3 | , computeTurningState
4 | , turningStateFromHistory
5 | )
6 |
7 | import Config exposing (KurveConfig)
8 | import Set exposing (Set)
9 | import Types.Angle as Angle exposing (Angle(..))
10 | import Types.Kurve exposing (Kurve, UserInteraction(..))
11 | import Types.Radius as Radius
12 | import Types.Speed as Speed
13 | import Types.Tick as Tick exposing (Tick)
14 | import Types.Tickrate as Tickrate
15 | import Types.TurningState exposing (TurningState(..))
16 |
17 |
18 | computeAngleChange : KurveConfig -> TurningState -> Angle
19 | computeAngleChange kurveConfig turningState =
20 | case turningState of
21 | TurningLeft ->
22 | computedAngleChange kurveConfig
23 |
24 | TurningRight ->
25 | Angle.negate <| computedAngleChange kurveConfig
26 |
27 | NotTurning ->
28 | Angle 0
29 |
30 |
31 | computeTurningState : Set String -> Kurve -> TurningState
32 | computeTurningState pressedButtons kurve =
33 | let
34 | ( leftButtons, rightButtons ) =
35 | kurve.controls
36 |
37 | someIsPressed : Set String -> Bool
38 | someIsPressed =
39 | Set.intersect pressedButtons >> Set.isEmpty >> not
40 | in
41 | case ( someIsPressed leftButtons, someIsPressed rightButtons ) of
42 | ( True, False ) ->
43 | TurningLeft
44 |
45 | ( False, True ) ->
46 | TurningRight
47 |
48 | _ ->
49 | -- Turning left and right at the same time cancel each other out, just like in the original game.
50 | NotTurning
51 |
52 |
53 | computedAngleChange : KurveConfig -> Angle
54 | computedAngleChange { tickrate, turningRadius, speed } =
55 | Angle (Speed.toFloat speed / (Tickrate.toFloat tickrate * Radius.toFloat turningRadius))
56 |
57 |
58 | turningStateFromHistory : Tick -> Kurve -> TurningState
59 | turningStateFromHistory currentTick kurve =
60 | newestFromBefore currentTick kurve.reversedInteractions
61 |
62 |
63 | newestFromBefore : Tick -> List UserInteraction -> TurningState
64 | newestFromBefore currentTick reversedInteractions =
65 | case reversedInteractions of
66 | [] ->
67 | NotTurning
68 |
69 | (HappenedBefore tick turningState) :: rest ->
70 | if Tick.toInt tick <= Tick.toInt currentTick then
71 | turningState
72 |
73 | else
74 | newestFromBefore currentTick rest
75 |
--------------------------------------------------------------------------------
/src/MainLoop.elm:
--------------------------------------------------------------------------------
1 | module MainLoop exposing (consumeAnimationFrame, noLeftoverFrameTime)
2 |
3 | {-| Based on Isaac Sukin's `MainLoop.js`.
4 |
5 | -
6 | -
7 |
8 | -}
9 |
10 | import Config exposing (Config)
11 | import Game exposing (TickResult(..))
12 | import Round exposing (Round)
13 | import Types.FrameTime exposing (FrameTime, LeftoverFrameTime)
14 | import Types.Tick as Tick exposing (Tick)
15 | import Types.Tickrate as Tickrate
16 |
17 |
18 | consumeAnimationFrame :
19 | Config
20 | -> FrameTime
21 | -> LeftoverFrameTime
22 | -> Tick
23 | -> Round
24 | -> ( TickResult ( LeftoverFrameTime, Tick, Round ), Cmd msg )
25 | consumeAnimationFrame config delta leftoverTimeFromPreviousFrame lastTick midRoundState =
26 | let
27 | timeToConsume : FrameTime
28 | timeToConsume =
29 | delta + leftoverTimeFromPreviousFrame
30 |
31 | timestep : FrameTime
32 | timestep =
33 | 1000 / Tickrate.toFloat config.kurves.tickrate
34 |
35 | recurse :
36 | LeftoverFrameTime
37 | -> Tick
38 | -> Round
39 | -> Cmd msg
40 | -> ( TickResult ( LeftoverFrameTime, Tick, Round ), Cmd msg )
41 | recurse timeLeftToConsume lastTickReactedTo midRoundStateSoFar cmdSoFar =
42 | if timeLeftToConsume >= timestep then
43 | let
44 | incrementedTick : Tick
45 | incrementedTick =
46 | Tick.succ lastTickReactedTo
47 |
48 | ( tickResult, cmdForThisTick ) =
49 | Game.reactToTick config incrementedTick midRoundStateSoFar
50 |
51 | newCmd : Cmd msg
52 | newCmd =
53 | Cmd.batch [ cmdSoFar, cmdForThisTick ]
54 | in
55 | case tickResult of
56 | RoundKeepsGoing newMidRoundState ->
57 | recurse (timeLeftToConsume - timestep) incrementedTick newMidRoundState newCmd
58 |
59 | RoundEnds finishedRound ->
60 | ( RoundEnds finishedRound
61 | , newCmd
62 | )
63 |
64 | else
65 | ( RoundKeepsGoing ( timeLeftToConsume, lastTickReactedTo, midRoundStateSoFar )
66 | , cmdSoFar
67 | )
68 | in
69 | recurse timeToConsume lastTick midRoundState Cmd.none
70 |
71 |
72 | noLeftoverFrameTime : LeftoverFrameTime
73 | noLeftoverFrameTime =
74 | 0
75 |
--------------------------------------------------------------------------------
/src/Round.elm:
--------------------------------------------------------------------------------
1 | module Round exposing
2 | ( Kurves
3 | , Round
4 | , RoundInitialState
5 | , initialStateForReplaying
6 | , modifyAlive
7 | , modifyDead
8 | , modifyKurves
9 | , roundIsOver
10 | , scores
11 | )
12 |
13 | import Dict exposing (Dict)
14 | import Random
15 | import Set exposing (Set)
16 | import Types.Kurve as Kurve exposing (Kurve)
17 | import Types.PlayerId exposing (PlayerId)
18 | import Types.Score exposing (Score(..))
19 | import World exposing (Pixel)
20 |
21 |
22 | type alias Round =
23 | { kurves : Kurves
24 | , occupiedPixels : Set Pixel
25 | , initialState : RoundInitialState
26 | , seed : Random.Seed
27 | }
28 |
29 |
30 | type alias Kurves =
31 | { alive : List Kurve
32 | , dead : List Kurve
33 | }
34 |
35 |
36 | type alias RoundInitialState =
37 | { seedAfterSpawn : Random.Seed
38 | , spawnedKurves : List Kurve
39 | }
40 |
41 |
42 | modifyKurves : (Kurves -> Kurves) -> Round -> Round
43 | modifyKurves f round =
44 | { round | kurves = f round.kurves }
45 |
46 |
47 | modifyAlive : (List Kurve -> List Kurve) -> Kurves -> Kurves
48 | modifyAlive f kurves =
49 | { kurves | alive = f kurves.alive }
50 |
51 |
52 | modifyDead : (List Kurve -> List Kurve) -> Kurves -> Kurves
53 | modifyDead f kurves =
54 | { kurves | dead = f kurves.dead }
55 |
56 |
57 | roundIsOver : Kurves -> Bool
58 | roundIsOver kurves =
59 | let
60 | someoneHasWonInMultiPlayer : Bool
61 | someoneHasWonInMultiPlayer =
62 | List.length kurves.alive == 1 && not (List.isEmpty kurves.dead)
63 |
64 | playerHasDiedInSinglePlayer : Bool
65 | playerHasDiedInSinglePlayer =
66 | List.isEmpty kurves.alive
67 | in
68 | someoneHasWonInMultiPlayer || playerHasDiedInSinglePlayer
69 |
70 |
71 | initialStateForReplaying : Round -> RoundInitialState
72 | initialStateForReplaying round =
73 | let
74 | initialState : RoundInitialState
75 | initialState =
76 | round.initialState
77 |
78 | theKurves : List Kurve
79 | theKurves =
80 | round.kurves.alive ++ round.kurves.dead
81 | in
82 | { initialState | spawnedKurves = theKurves |> List.map Kurve.reset |> sortKurves }
83 |
84 |
85 | scores : Round -> Dict PlayerId Score
86 | scores { kurves } =
87 | let
88 | scoresOfDead : List ( Score, Kurve )
89 | scoresOfDead =
90 | List.indexedMap (Score >> Tuple.pair) (List.reverse kurves.dead)
91 |
92 | scoresOfAlive : List ( Score, Kurve )
93 | scoresOfAlive =
94 | List.map (Tuple.pair (Score <| List.length kurves.dead)) kurves.alive
95 | in
96 | scoresOfDead ++ scoresOfAlive |> List.foldl (\( score, kurve ) -> Dict.insert kurve.id score) Dict.empty
97 |
98 |
99 | sortKurves : List Kurve -> List Kurve
100 | sortKurves =
101 | List.sortBy .id
102 |
--------------------------------------------------------------------------------
/tools/ScenarioInOriginalGame/ScenarioCore.elm:
--------------------------------------------------------------------------------
1 | module ScenarioCore exposing (Scenario, checkScenario, toModMem)
2 |
3 | import MemoryLayout exposing (StateComponent(..), relativeAddressFor)
4 | import ModMem exposing (ModMemCmd(..))
5 | import OriginalGamePlayers exposing (PlayerId, allPlayers, playerIndex, playerName)
6 | import ScenarioComments exposing (setStateComponentComment)
7 |
8 |
9 | type alias Scenario =
10 | List ScenarioStep
11 |
12 |
13 | type alias ScenarioStep =
14 | ( PlayerId, PlayerState )
15 |
16 |
17 | type alias PlayerState =
18 | { x : Float, y : Float, direction : Float }
19 |
20 |
21 | toModMem : Scenario -> List ModMemCmd
22 | toModMem =
23 | List.concatMap stepToModMem
24 |
25 |
26 | stepToModMem : ScenarioStep -> List ModMemCmd
27 | stepToModMem ( playerId, { x, y, direction } ) =
28 | [ setX x playerId
29 | , setY y playerId
30 | , setDirection direction playerId
31 | ]
32 |
33 |
34 | setX : Float -> PlayerId -> ModMemCmd
35 | setX x playerId =
36 | ModifyMemory
37 | (setStateComponentComment X playerId)
38 | (relativeAddressFor playerId X)
39 | x
40 |
41 |
42 | setY : Float -> PlayerId -> ModMemCmd
43 | setY y playerId =
44 | ModifyMemory
45 | (setStateComponentComment Y playerId)
46 | (relativeAddressFor playerId Y)
47 | y
48 |
49 |
50 | setDirection : Float -> PlayerId -> ModMemCmd
51 | setDirection direction playerId =
52 | ModifyMemory
53 | (setStateComponentComment Dir playerId)
54 | (relativeAddressFor playerId Dir)
55 | direction
56 |
57 |
58 | checkScenario : Scenario -> Result String Scenario
59 | checkScenario scenario =
60 | let
61 | participatingCount : Int
62 | participatingCount =
63 | List.length scenario
64 | in
65 | if participatingCount < 2 then
66 | Err <| "Scenario must have at least 2 players, but had " ++ String.fromInt participatingCount ++ "."
67 |
68 | else
69 | let
70 | checkStep : ScenarioStep -> Result String Scenario -> Result String Scenario
71 | checkStep step =
72 | Result.andThen
73 | (\stepsSoFar ->
74 | let
75 | playerId : PlayerId
76 | playerId =
77 | Tuple.first step
78 |
79 | seenPlayerIds : List PlayerId
80 | seenPlayerIds =
81 | List.map Tuple.first stepsSoFar
82 | in
83 | if List.member playerId seenPlayerIds then
84 | Err <| playerName playerId ++ " specified more than once."
85 |
86 | else if List.any (\seenPlayerId -> playerIndex seenPlayerId > playerIndex playerId) seenPlayerIds then
87 | Err <| "Players must be specified in this order: " ++ (List.map playerName allPlayers |> String.join ", ") ++ "."
88 |
89 | else
90 | Ok (stepsSoFar ++ [ step ])
91 | )
92 | in
93 | List.foldl checkStep (Ok []) scenario
94 |
--------------------------------------------------------------------------------
/tools/ScenarioInOriginalGame/CompileScenario.elm:
--------------------------------------------------------------------------------
1 | module CompileScenario exposing (CompilationResult(..), compileAndSerialize, compileScenario)
2 |
3 | import GDB
4 | import Json.Encode as Encode
5 | import ModMem exposing (AbsoluteAddress, parseAddress)
6 | import OriginalGamePlayers exposing (PlayerId, playerIndex)
7 | import ScenarioCore exposing (Scenario, checkScenario, toModMem)
8 | import TheScenario exposing (theScenario)
9 |
10 |
11 | type CompilationResult
12 | = CompilationSuccess CompiledScenario
13 | | CompilationFailure String
14 |
15 |
16 | type alias CompiledScenario =
17 | { participating : List PlayerId
18 | , compiledProgram : String
19 | }
20 |
21 |
22 | compileAndSerialize : List String -> String
23 | compileAndSerialize commandLineArgs =
24 | compileScenario commandLineArgs theScenario |> encodeCompilationResultAsJson |> Encode.encode 0
25 |
26 |
27 | compileScenario : List String -> Scenario -> CompilationResult
28 | compileScenario commandLineArgs scenario =
29 | case parseArguments commandLineArgs of
30 | Accepted baseAddress ->
31 | case checkScenario scenario of
32 | Ok checkedScenario ->
33 | CompilationSuccess
34 | { participating = participatingPlayers scenario
35 | , compiledProgram = checkedScenario |> toModMem |> GDB.compile baseAddress
36 | }
37 |
38 | Err reason ->
39 | CompilationFailure reason
40 |
41 | Rejected reason ->
42 | CompilationFailure reason
43 |
44 |
45 | type ParsedArguments
46 | = Accepted AbsoluteAddress
47 | | Rejected String
48 |
49 |
50 | parseArguments : List String -> ParsedArguments
51 | parseArguments commandLineArgs =
52 | case commandLineArgs of
53 | [ rawBaseAddress ] ->
54 | case parseAddress rawBaseAddress of
55 | Just baseAddress ->
56 | Accepted baseAddress
57 |
58 | Nothing ->
59 | Rejected <| "Cannot parse base address: " ++ rawBaseAddress ++ " (must be hexadecimal, with or without '0x' prefix)."
60 |
61 | _ ->
62 | Rejected <| "Unexpected number of arguments. Expected 1, but got " ++ (List.length commandLineArgs |> String.fromInt) ++ "."
63 |
64 |
65 | {-| This is the external API.
66 |
67 | The keys are deliberately long to make them unique and therefore searchable.
68 |
69 | -}
70 | encodeCompilationResultAsJson : CompilationResult -> Encode.Value
71 | encodeCompilationResultAsJson result =
72 | case result of
73 | CompilationSuccess { participating, compiledProgram } ->
74 | Encode.object
75 | [ ( "compilationSuccess", Encode.bool True )
76 | , ( "compiledScenario"
77 | , Encode.object
78 | [ ( "participatingPlayersById", Encode.list Encode.int (List.map playerIndex participating) )
79 | , ( "gdbProgram", Encode.string compiledProgram )
80 | ]
81 | )
82 | ]
83 |
84 | CompilationFailure errorMessage ->
85 | Encode.object
86 | [ ( "compilationSuccess", Encode.bool False )
87 | , ( "compilationErrorMessage", Encode.string errorMessage )
88 | ]
89 |
90 |
91 | participatingPlayers : Scenario -> List PlayerId
92 | participatingPlayers =
93 | List.map Tuple.first
94 |
--------------------------------------------------------------------------------
/src/Config.elm:
--------------------------------------------------------------------------------
1 | module Config exposing
2 | ( Config
3 | , GameConfig
4 | , HoleConfig
5 | , KurveConfig
6 | , SpawnConfig
7 | , WorldConfig
8 | , default
9 | )
10 |
11 | import Dict
12 | import Players exposing (ParticipatingPlayers)
13 | import Types.Distance exposing (Distance(..))
14 | import Types.Radius exposing (Radius(..))
15 | import Types.Score exposing (Score(..), isAtLeast)
16 | import Types.Speed exposing (Speed(..))
17 | import Types.Tickrate exposing (Tickrate(..))
18 |
19 |
20 | default : Config
21 | default =
22 | { kurves =
23 | { tickrate = Tickrate 60
24 | , turningRadius = Radius 28.5
25 | , speed = Speed 60
26 | , holes =
27 | { minInterval = Distance 90
28 | , maxInterval = Distance 300
29 | , minSize = Distance 5
30 | , maxSize = Distance 9
31 | }
32 | }
33 | , spawn =
34 | { margin = 100 -- The minimum distance from the wall that a Kurve can spawn.
35 | , desiredMinimumDistanceTurningRadiusFactor = 1
36 | , protectionAudacity = 0.25 -- Closer to 1 ⇔ less risk of spawn kills but higher risk of no solution
37 | , flickerTicksPerSecond = 20 -- At each tick, the spawning Kurve is toggled between visible and invisible.
38 | , numberOfFlickerTicks = 5
39 | , angleInterval = ( 0, pi )
40 | }
41 | , world =
42 | { width = 559
43 | , height = 480
44 | }
45 | , game =
46 | { isGameOver = defaultGameOverCondition
47 | }
48 | , replay =
49 | { skipStepInMs = 5000
50 | }
51 | }
52 |
53 |
54 | defaultGameOverCondition : ParticipatingPlayers -> Bool
55 | defaultGameOverCondition participatingPlayers =
56 | let
57 | numberOfPlayers : Int
58 | numberOfPlayers =
59 | Dict.size participatingPlayers
60 |
61 | targetScore : Score
62 | targetScore =
63 | Score ((numberOfPlayers - 1) * 10)
64 |
65 | someoneHasReachedTargetScore : Bool
66 | someoneHasReachedTargetScore =
67 | not <|
68 | Dict.isEmpty <|
69 | Dict.filter (always (Tuple.second >> isAtLeast targetScore)) participatingPlayers
70 | in
71 | numberOfPlayers > 1 && someoneHasReachedTargetScore
72 |
73 |
74 | type alias Config =
75 | { kurves : KurveConfig
76 | , spawn : SpawnConfig
77 | , world : WorldConfig
78 | , game : GameConfig
79 | , replay : ReplayConfig
80 | }
81 |
82 |
83 | type alias KurveConfig =
84 | { tickrate : Tickrate
85 | , speed : Speed
86 | , turningRadius : Radius
87 | , holes : HoleConfig
88 | }
89 |
90 |
91 | type alias SpawnConfig =
92 | { margin : Float
93 | , desiredMinimumDistanceTurningRadiusFactor : Float
94 | , protectionAudacity : Float
95 | , flickerTicksPerSecond : Float
96 | , numberOfFlickerTicks : Int
97 | , angleInterval : ( Float, Float )
98 | }
99 |
100 |
101 | type alias WorldConfig =
102 | { width : Int
103 | , height : Int
104 | }
105 |
106 |
107 | type alias GameConfig =
108 | { isGameOver : ParticipatingPlayers -> Bool
109 | }
110 |
111 |
112 | type alias ReplayConfig =
113 | { skipStepInMs : Int
114 | }
115 |
116 |
117 | type alias HoleConfig =
118 | { minInterval : Distance
119 | , maxInterval : Distance
120 | , minSize : Distance
121 | , maxSize : Distance
122 | }
123 |
--------------------------------------------------------------------------------
/src/World.elm:
--------------------------------------------------------------------------------
1 | module World exposing
2 | ( DrawingPosition
3 | , Pixel
4 | , Position
5 | , desiredDrawingPositions
6 | , distanceBetween
7 | , distanceToTicks
8 | , drawingPosition
9 | , hitbox
10 | , pixelsToOccupy
11 | )
12 |
13 | import List.Cartesian
14 | import RasterShapes
15 | import Set exposing (Set)
16 | import Thickness exposing (theThickness)
17 | import Types.Distance as Distance exposing (Distance(..))
18 | import Types.Speed as Speed exposing (Speed)
19 | import Types.Tickrate as Tickrate exposing (Tickrate)
20 |
21 |
22 | type alias Position =
23 | ( Float, Float )
24 |
25 |
26 | type alias DrawingPosition =
27 | { leftEdge : Int, topEdge : Int }
28 |
29 |
30 | type alias Pixel =
31 | ( Int, Int )
32 |
33 |
34 | distanceBetween : Position -> Position -> Distance
35 | distanceBetween ( x1, y1 ) ( x2, y2 ) =
36 | Distance <| sqrt ((x2 - x1) ^ 2 + (y2 - y1) ^ 2)
37 |
38 |
39 | distanceToTicks : Tickrate -> Speed -> Distance -> Int
40 | distanceToTicks tickrate speed distance =
41 | round <| Tickrate.toFloat tickrate * Distance.toFloat distance / Speed.toFloat speed
42 |
43 |
44 | toBresenham : DrawingPosition -> RasterShapes.Position
45 | toBresenham { leftEdge, topEdge } =
46 | { x = leftEdge, y = topEdge }
47 |
48 |
49 | fromBresenham : RasterShapes.Position -> DrawingPosition
50 | fromBresenham { x, y } =
51 | { leftEdge = x, topEdge = y }
52 |
53 |
54 | drawingPosition : Position -> DrawingPosition
55 | drawingPosition ( x, y ) =
56 | { leftEdge = edgeOfSquare x, topEdge = edgeOfSquare y }
57 |
58 |
59 | edgeOfSquare : Float -> Int
60 | edgeOfSquare xOrY =
61 | round (xOrY - (theThickness / 2))
62 |
63 |
64 | pixelsToOccupy : DrawingPosition -> Set Pixel
65 | pixelsToOccupy { leftEdge, topEdge } =
66 | let
67 | rangeFrom : Int -> List Int
68 | rangeFrom start =
69 | List.range start (start + theThickness - 1)
70 |
71 | xs : List Int
72 | xs =
73 | rangeFrom leftEdge
74 |
75 | ys : List Int
76 | ys =
77 | rangeFrom topEdge
78 | in
79 | List.Cartesian.map2 Tuple.pair xs ys
80 | |> Set.fromList
81 |
82 |
83 | desiredDrawingPositions : Position -> Position -> List DrawingPosition
84 | desiredDrawingPositions position1 position2 =
85 | RasterShapes.line
86 | (drawingPosition position1 |> toBresenham)
87 | (drawingPosition position2 |> toBresenham)
88 | -- The RasterShapes library returns the positions in reverse order.
89 | |> List.reverse
90 | -- The first element in the list is the starting position, which is assumed to already have been drawn.
91 | |> List.drop 1
92 | |> List.map fromBresenham
93 |
94 |
95 | hitbox : DrawingPosition -> DrawingPosition -> Set Pixel
96 | hitbox oldPosition newPosition =
97 | let
98 | is45DegreeDraw : Bool
99 | is45DegreeDraw =
100 | oldPosition.leftEdge /= newPosition.leftEdge && oldPosition.topEdge /= newPosition.topEdge
101 |
102 | oldPixels : Set Pixel
103 | oldPixels =
104 | pixelsToOccupy oldPosition
105 |
106 | newPixels : Set Pixel
107 | newPixels =
108 | pixelsToOccupy newPosition
109 | in
110 | if is45DegreeDraw then
111 | let
112 | oldXs : Set Int
113 | oldXs =
114 | Set.map Tuple.first oldPixels
115 |
116 | oldYs : Set Int
117 | oldYs =
118 | Set.map Tuple.second oldPixels
119 | in
120 | Set.filter (\( x, y ) -> not (Set.member x oldXs) && not (Set.member y oldYs)) newPixels
121 |
122 | else
123 | Set.diff newPixels oldPixels
124 |
--------------------------------------------------------------------------------
/tests/TestHelpers.elm:
--------------------------------------------------------------------------------
1 | module TestHelpers exposing
2 | ( defaultConfigWithSpeed
3 | , expectRoundOutcome
4 | )
5 |
6 | import Config exposing (Config, KurveConfig)
7 | import Expect
8 | import Game
9 | exposing
10 | ( TickResult(..)
11 | , prepareRoundFromKnownInitialState
12 | , reactToTick
13 | )
14 | import Round exposing (Round, RoundInitialState)
15 | import TestScenarioHelpers exposing (RoundEndingInterpretation, RoundOutcome)
16 | import Types.Speed exposing (Speed)
17 | import Types.Tick as Tick exposing (Tick)
18 | import World
19 |
20 |
21 | expectRoundOutcome : Config -> RoundOutcome -> RoundInitialState -> Expect.Expectation
22 | expectRoundOutcome config { tickThatShouldEndIt, howItShouldEnd } initialState =
23 | let
24 | ( actualEndTick, actualRoundResult ) =
25 | playOutRound config initialState
26 | in
27 | Expect.all
28 | [ \_ ->
29 | if actualEndTick == tickThatShouldEndIt then
30 | Expect.pass
31 |
32 | else
33 | Expect.fail <| "Expected round to end on tick " ++ showTick tickThatShouldEndIt ++ " but it ended on tick " ++ showTick actualEndTick ++ "."
34 | , \_ ->
35 | interpretRoundEnding actualRoundResult
36 | |> Expect.equal howItShouldEnd
37 | ]
38 | ()
39 |
40 |
41 | interpretRoundEnding : Round -> RoundEndingInterpretation
42 | interpretRoundEnding { kurves } =
43 | { aliveAtTheEnd =
44 | kurves.alive
45 | |> List.map
46 | (\kurve ->
47 | { id = kurve.id
48 | }
49 | )
50 | , deadAtTheEnd =
51 | kurves.dead
52 | |> List.map
53 | (\kurve ->
54 | { id = kurve.id
55 | , theDrawingPositionItNeverMadeItTo = World.drawingPosition kurve.state.position
56 | }
57 | )
58 | }
59 |
60 |
61 | playOutRound : Config -> RoundInitialState -> ( Tick, Round )
62 | playOutRound config initialState =
63 | let
64 | recurse : Tick -> Round -> ( Tick, Round )
65 | recurse tick midRoundState =
66 | let
67 | nextTick : Tick
68 | nextTick =
69 | Tick.succ tick
70 |
71 | tickResult : TickResult Round
72 | tickResult =
73 | reactToTick config nextTick midRoundState |> Tuple.first
74 | in
75 | case tickResult of
76 | RoundKeepsGoing nextMidRoundState ->
77 | recurse nextTick nextMidRoundState
78 |
79 | RoundEnds actualRoundResult ->
80 | let
81 | actualEndTick : Tick
82 | actualEndTick =
83 | Tick.succ tick
84 | in
85 | ( actualEndTick, actualRoundResult )
86 |
87 | round : Round
88 | round =
89 | prepareRoundFromKnownInitialState initialState
90 | in
91 | recurse Tick.genesis round
92 |
93 |
94 | showTick : Tick -> String
95 | showTick =
96 | Tick.toInt >> String.fromInt
97 |
98 |
99 | defaultConfigWithSpeed : Speed -> Config
100 | defaultConfigWithSpeed speed =
101 | let
102 | defaultConfig : Config
103 | defaultConfig =
104 | Config.default
105 |
106 | defaultKurveConfig : KurveConfig
107 | defaultKurveConfig =
108 | defaultConfig.kurves
109 | in
110 | { defaultConfig
111 | | kurves =
112 | { defaultKurveConfig
113 | | speed = speed
114 | }
115 | }
116 |
--------------------------------------------------------------------------------
/src/TestScenarioHelpers.elm:
--------------------------------------------------------------------------------
1 | module TestScenarioHelpers exposing
2 | ( CumulativeInteraction
3 | , RoundEndingInterpretation
4 | , RoundOutcome
5 | , makeUserInteractions
6 | , makeZombieKurve
7 | , playerIds
8 | , roundWith
9 | , tickNumber
10 | )
11 |
12 | import Color
13 | import Random
14 | import Round exposing (RoundInitialState)
15 | import Set
16 | import Types.Angle exposing (Angle(..))
17 | import Types.Kurve as Kurve exposing (HoleStatus(..), Kurve, UserInteraction(..))
18 | import Types.PlayerId exposing (PlayerId)
19 | import Types.Tick as Tick exposing (Tick)
20 | import Types.TurningState exposing (TurningState)
21 | import World exposing (DrawingPosition)
22 |
23 |
24 | playerIds :
25 | { red : PlayerId
26 | , yellow : PlayerId
27 | , orange : PlayerId
28 | , green : PlayerId
29 | , pink : PlayerId
30 | , blue : PlayerId
31 | }
32 | playerIds =
33 | { red = 0
34 | , yellow = 1
35 | , orange = 2
36 | , green = 3
37 | , pink = 4
38 | , blue = 5
39 | }
40 |
41 |
42 | {-| Creates a Kurve that just moves forward.
43 | -}
44 | makeZombieKurve : { color : Color.Color, id : PlayerId, state : Kurve.State } -> Kurve
45 | makeZombieKurve { color, id, state } =
46 | { color = color
47 | , id = id
48 | , controls = ( Set.empty, Set.empty )
49 | , state = state
50 | , stateAtSpawn =
51 | { position = ( 0, 0 )
52 | , direction = Angle (pi / 2)
53 | , holeStatus = Unholy 0
54 | }
55 | , reversedInteractions = []
56 | }
57 |
58 |
59 | roundWith : List Kurve -> RoundInitialState
60 | roundWith spawnedKurves =
61 | { seedAfterSpawn = Random.initialSeed 0
62 | , spawnedKurves = spawnedKurves
63 | }
64 |
65 |
66 | {-| How many ticks to wait before performing some action, and that action.
67 |
68 | The number of ticks to wait is counted from the previous action (or, for the first action, from the beginning of the round).
69 |
70 | -}
71 | type alias CumulativeInteraction =
72 | ( Int, TurningState )
73 |
74 |
75 | {-| Makes it easy for a human to "program" a Kurve.
76 |
77 | The input is a chronologically ordered list representing how a human will typically think about a Kurve's actions.
78 |
79 | -}
80 | makeUserInteractions : List CumulativeInteraction -> List UserInteraction
81 | makeUserInteractions cumulativeInteractions =
82 | let
83 | accumulate : CumulativeInteraction -> ( List CumulativeInteraction, Int ) -> ( List CumulativeInteraction, Int )
84 | accumulate ( ticksBeforeAction, turningState ) ( soFar, previousTickNumber ) =
85 | let
86 | thisTickNumber : Int
87 | thisTickNumber =
88 | previousTickNumber + ticksBeforeAction
89 | in
90 | ( ( thisTickNumber, turningState ) :: soFar, thisTickNumber )
91 |
92 | toUserInteraction : CumulativeInteraction -> UserInteraction
93 | toUserInteraction ( n, turningState ) =
94 | HappenedBefore (tickNumber n) turningState
95 | in
96 | List.foldl accumulate ( [], 0 ) cumulativeInteractions |> Tuple.first |> List.map toUserInteraction
97 |
98 |
99 | tickNumber : Int -> Tick
100 | tickNumber n =
101 | case Tick.fromInt n of
102 | Nothing ->
103 | Tick.genesis
104 |
105 | Just tick ->
106 | tick
107 |
108 |
109 | {-| A description of when and how a round should end.
110 | -}
111 | type alias RoundOutcome =
112 | { tickThatShouldEndIt : Tick
113 | , howItShouldEnd : RoundEndingInterpretation
114 | }
115 |
116 |
117 | type alias RoundEndingInterpretation =
118 | { aliveAtTheEnd : List AliveKurve
119 | , deadAtTheEnd : List DeadKurve
120 | }
121 |
122 |
123 | type alias AliveKurve =
124 | { id : PlayerId
125 | }
126 |
127 |
128 | type alias DeadKurve =
129 | { id : PlayerId
130 | , theDrawingPositionItNeverMadeItTo : DrawingPosition
131 | }
132 |
--------------------------------------------------------------------------------
/tools/interpret-scanmem-output.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 |
3 | from math import pi
4 | import re
5 | import struct
6 | import sys
7 |
8 | HEX_BYTE_RE = re.compile(r"\b[0-9A-Fa-f]{2}\b")
9 |
10 | PLAYERS = [
11 | ("🟥", "Red"),
12 | ("🟨", "Yellow"),
13 | ("🟧", "Orange"),
14 | ("🟩", "Green"),
15 | ("🟪", "Pink"),
16 | ("🟦", "Blue"),
17 | ]
18 | NUMBER_OF_PLAYERS = len(PLAYERS)
19 |
20 | COLUMN_WIDTH = 20
21 |
22 |
23 | def parse_dump(lines: list[str]) -> bytes:
24 | all_bytes: list[int] = []
25 |
26 | for line in lines:
27 | if line.startswith("0x"):
28 | for token in HEX_BYTE_RE.findall(line):
29 | all_bytes.append(int(token, 16))
30 |
31 | return bytes(all_bytes)
32 |
33 |
34 | def is_process_not_found_error(lines: list[str]) -> bool:
35 | return any(
36 | [line.startswith("error: ") and "No such process" in line for line in lines]
37 | )
38 |
39 |
40 | def is_operation_not_permitted_error(lines: list[str]) -> bool:
41 | return any(
42 | [
43 | line.startswith("error: ") and "Operation not permitted" in line
44 | for line in lines
45 | ]
46 | )
47 |
48 |
49 | def is_some_scanmem_error(lines: list[str]) -> bool:
50 | return any(
51 | [line.startswith("error: ") and "read memory failed" in line for line in lines]
52 | )
53 |
54 |
55 | def to_float32_le(b: bytes) -> float:
56 | return struct.unpack(" list[bytes]:
60 | return [bs[i : i + n] for i in range(0, len(bs), n) if len(bs[i : i + n]) == n]
61 |
62 |
63 | def chunk_floats(bs: list[float], n: int) -> list[list[float]]:
64 | return [bs[i : i + n] for i in range(0, len(bs), n) if len(bs[i : i + n]) == n]
65 |
66 |
67 | ARROWS = [
68 | "↓",
69 | "↘",
70 | "→",
71 | "↗",
72 | "↑",
73 | "↖",
74 | "←",
75 | "↙",
76 | ]
77 | NUMBER_OF_ARROWS = len(ARROWS)
78 |
79 |
80 | def arrow_for_dir(
81 | raw_direction: float,
82 | ) -> str:
83 | aligned_direction = (
84 | # The angle "almost 2π" should be represented by the downward arrow, i.e. index 0, not index n - 1. This addition "pushes it over the edge". The addition will be negated below by rounding down.
85 | raw_direction + pi / NUMBER_OF_ARROWS
86 | )
87 | cycle = 2 * pi
88 | arrow_index = int((aligned_direction % cycle) / cycle * NUMBER_OF_ARROWS)
89 | return ARROWS[arrow_index]
90 |
91 |
92 | def main():
93 | text: str = sys.stdin.read()
94 |
95 | lines = text.splitlines()
96 |
97 | if is_process_not_found_error(lines):
98 | print("⚠️ Process not found. Is the game running?")
99 | sys.exit(1)
100 |
101 | if is_operation_not_permitted_error(lines):
102 | print("⚠️ Read memory failed. Maybe it is currently being modified.")
103 | sys.exit(1)
104 |
105 | if is_some_scanmem_error(lines):
106 | print("⚠️ Read memory failed. Maybe the game is currently starting.")
107 | sys.exit(1)
108 |
109 | raw_bytes: bytes = parse_dump(lines)
110 |
111 | values: list[float] = []
112 | offset: int = 0
113 |
114 | for quad in chunk_bytes(raw_bytes, 4):
115 | try:
116 | f = to_float32_le(quad)
117 | except Exception:
118 | f = float("nan")
119 | values.append(f)
120 | offset += 4
121 |
122 | [xs, ys, dirs] = chunk_floats(values, NUMBER_OF_PLAYERS)
123 |
124 | # Table head:
125 | print(
126 | " ",
127 | "x".ljust(COLUMN_WIDTH),
128 | "y".ljust(COLUMN_WIDTH),
129 | "Direction (0 = down)".ljust(COLUMN_WIDTH),
130 | )
131 |
132 | # Table body:
133 | for player_id in range(0, NUMBER_OF_PLAYERS):
134 | x = xs[player_id]
135 | y = ys[player_id]
136 | dir = dirs[player_id]
137 | print(
138 | PLAYERS[player_id][0],
139 | PLAYERS[player_id][1].ljust(7),
140 | str(x).ljust(COLUMN_WIDTH),
141 | str(y).ljust(COLUMN_WIDTH),
142 | arrow_for_dir(dir),
143 | str(dir).ljust(COLUMN_WIDTH),
144 | )
145 |
146 |
147 | if __name__ == "__main__":
148 | main()
149 |
--------------------------------------------------------------------------------
/tools/ScenarioInOriginalGame/GDB.elm:
--------------------------------------------------------------------------------
1 | module GDB exposing (compile)
2 |
3 | import MemoryLayout exposing (StateComponent(..), relativeAddressFor)
4 | import ModMem exposing (AbsoluteAddress, ModMemCmd(..), resolveAddress, serializeAddress)
5 | import OriginalGamePlayers exposing (PlayerId(..))
6 | import ScenarioComments exposing (ignoreBogusWriteComment, sectionEnd, sectionStart)
7 |
8 |
9 | type alias GdbCommand =
10 | String
11 |
12 |
13 | compile : AbsoluteAddress -> List ModMemCmd -> String
14 | compile baseAddress core =
15 | let
16 | coreCommands : List GdbCommand
17 | coreCommands =
18 | compileCore baseAddress core
19 | in
20 | List.concat
21 | [ setupCommands
22 | , coreCommands
23 | , teardownCommands
24 | ]
25 | |> String.join "\n"
26 |
27 |
28 | compileCore : AbsoluteAddress -> List ModMemCmd -> List GdbCommand
29 | compileCore baseAddress =
30 | let
31 | whatToDoAfterHittingLastWatchpoint : List GdbCommand
32 | whatToDoAfterHittingLastWatchpoint =
33 | [ "exit" -- Otherwise gdb remains attached until DOSBox is closed. See the PR/commit that added this comment for details about why that's problematic.
34 | ]
35 | in
36 | List.foldr
37 | (\(ModifyMemory description relativeAddress newValue) compiledContinuation ->
38 | let
39 | serializedAddress : String
40 | serializedAddress =
41 | resolveAddress baseAddress relativeAddress |> serializeAddress
42 |
43 | applyWorkaroundForRedYIfApplicable : List GdbCommand -> List GdbCommand
44 | applyWorkaroundForRedYIfApplicable =
45 | if relativeAddress == relativeAddressFor Red Y then
46 | applyWorkaroundForRedY serializedAddress
47 |
48 | else
49 | identity
50 | in
51 | [ emptyLineForVisualSeparation
52 | , print (sectionStart description)
53 | , "watch *(float*)" ++ serializedAddress
54 | , "commands"
55 | , "set {float}" ++ serializedAddress ++ " = " ++ String.fromFloat newValue
56 | , "delete $bpnum"
57 | , print (sectionEnd description)
58 | ]
59 | ++ compiledContinuation
60 | ++ closeWatchBlock
61 | |> applyWorkaroundForRedYIfApplicable
62 | )
63 | whatToDoAfterHittingLastWatchpoint
64 |
65 |
66 | setupCommands : List GdbCommand
67 | setupCommands =
68 | [ "set pagination off"
69 | , "set logging file gdb-log.txt"
70 | , "set logging overwrite on" -- Otherwise gdb appends to the log file, instead of overwriting it.
71 | , "set logging enabled on" -- Must be after the other `set logging` commands for them to have effect.
72 | ]
73 |
74 |
75 | teardownCommands : List GdbCommand
76 | teardownCommands =
77 | [ "continue"
78 | ]
79 |
80 |
81 | {-| The original game writes a couple of times to Red's y address before writing the actual value. We have to wait for the "real" write before we write our value; otherwise it's just immediately overwritten.
82 | -}
83 | applyWorkaroundForRedY : String -> List GdbCommand -> List GdbCommand
84 | applyWorkaroundForRedY serializedAddress compiledGdbCommands =
85 | let
86 | numberOfBogusWritesToRedYAddress : Int
87 | numberOfBogusWritesToRedYAddress =
88 | 2
89 |
90 | ignoreBogusWrite : List GdbCommand
91 | ignoreBogusWrite =
92 | [ emptyLineForVisualSeparation
93 | , print (sectionStart (ignoreBogusWriteComment Y Red))
94 | , "watch *(float*)" ++ serializedAddress
95 | , "commands"
96 | , "x/4bx " ++ serializedAddress -- (just print the bytes)
97 | , "delete $bpnum"
98 | , print (sectionEnd (ignoreBogusWriteComment Y Red))
99 | ]
100 |
101 | workaroundOpening : List GdbCommand
102 | workaroundOpening =
103 | ignoreBogusWrite |> List.repeat numberOfBogusWritesToRedYAddress |> List.concat
104 |
105 | workaroundClosing : List GdbCommand
106 | workaroundClosing =
107 | closeWatchBlock |> List.repeat numberOfBogusWritesToRedYAddress |> List.concat
108 | in
109 | workaroundOpening ++ compiledGdbCommands ++ workaroundClosing
110 |
111 |
112 | emptyLineForVisualSeparation : String
113 | emptyLineForVisualSeparation =
114 | ""
115 |
116 |
117 | {-| Creates a command that prints the given string, as long as it doesn't contain characters with special meaning in C, like `"` and `\`.
118 | -}
119 | print : String -> GdbCommand
120 | print s =
121 | "print \"" ++ s ++ "\""
122 |
123 |
124 | closeWatchBlock : List GdbCommand
125 | closeWatchBlock =
126 | [ "continue"
127 | , "end"
128 | , emptyLineForVisualSeparation
129 | ]
130 |
--------------------------------------------------------------------------------
/src/Players.elm:
--------------------------------------------------------------------------------
1 | module Players exposing
2 | ( AllPlayers
3 | , ParticipatingPlayers
4 | , atLeastOneIsParticipating
5 | , everyoneLeaves
6 | , handlePlayerJoiningOrLeaving
7 | , includeResultsFrom
8 | , initialPlayers
9 | , participating
10 | )
11 |
12 | import Color exposing (Color)
13 | import Dict exposing (Dict)
14 | import Input exposing (Button(..))
15 | import Round exposing (Round)
16 | import Types.Player exposing (Player)
17 | import Types.PlayerId exposing (PlayerId)
18 | import Types.PlayerStatus exposing (PlayerStatus(..))
19 | import Types.Score exposing (Score(..))
20 |
21 |
22 | type alias AllPlayers =
23 | Dict PlayerId ( Player, PlayerStatus )
24 |
25 |
26 | type alias ParticipatingPlayers =
27 | Dict PlayerId ( Player, Score )
28 |
29 |
30 | handlePlayerJoiningOrLeaving : Button -> AllPlayers -> AllPlayers
31 | handlePlayerJoiningOrLeaving button =
32 | Dict.map
33 | (\_ ( player, status ) ->
34 | let
35 | ( leftButtons, rightButtons ) =
36 | player.controls
37 |
38 | newStatus : PlayerStatus
39 | newStatus =
40 | case ( List.member button leftButtons, List.member button rightButtons ) of
41 | ( True, False ) ->
42 | Participating (Score 0)
43 |
44 | ( False, True ) ->
45 | NotParticipating
46 |
47 | _ ->
48 | -- This case either represents that the pressed button isn't used by the player in question at all, or the absurd scenario that it's used by said player for turning _both_ left and right.
49 | status
50 | in
51 | ( player, newStatus )
52 | )
53 |
54 |
55 | everyoneLeaves : AllPlayers -> AllPlayers
56 | everyoneLeaves =
57 | Dict.map (always (Tuple.mapSecond (always NotParticipating)))
58 |
59 |
60 | participating : AllPlayers -> ParticipatingPlayers
61 | participating =
62 | let
63 | includeIfParticipating : PlayerId -> ( Player, PlayerStatus ) -> ParticipatingPlayers -> ParticipatingPlayers
64 | includeIfParticipating id ( player, status ) =
65 | case status of
66 | Participating score ->
67 | Dict.insert id ( player, score )
68 |
69 | NotParticipating ->
70 | identity
71 | in
72 | Dict.foldl includeIfParticipating Dict.empty
73 |
74 |
75 | atLeastOneIsParticipating : AllPlayers -> Bool
76 | atLeastOneIsParticipating =
77 | participating >> Dict.isEmpty >> not
78 |
79 |
80 | {-| Merges the results from the given (ongoing or finished) round with the existing ones. Players are assumed to be represented in both sets of results.
81 | -}
82 | includeResultsFrom : Round -> AllPlayers -> AllPlayers
83 | includeResultsFrom round =
84 | Dict.map (\id -> Tuple.mapSecond (Dict.get id (Round.scores round) |> combineScores))
85 |
86 |
87 | combineScores : Maybe Score -> PlayerStatus -> PlayerStatus
88 | combineScores scoreInRound status =
89 | case ( status, scoreInRound ) of
90 | ( Participating (Score fromBefore), Just (Score inRound) ) ->
91 | Participating <| Score <| fromBefore + inRound
92 |
93 | _ ->
94 | NotParticipating
95 |
96 |
97 | initialPlayers : AllPlayers
98 | initialPlayers =
99 | players |> List.indexedMap (\id player -> ( id, ( player, NotParticipating ) )) |> Dict.fromList
100 |
101 |
102 | players : List Player
103 | players =
104 | let
105 | rgb : Int -> Int -> Int -> Color
106 | rgb =
107 | Color.rgb255
108 |
109 | red : Player
110 | red =
111 | { color = rgb 255 40 0
112 | , controls = ( [ Key "Digit1" ], [ Key "KeyQ" ] )
113 | }
114 |
115 | yellow : Player
116 | yellow =
117 | { color = rgb 195 195 0
118 | , controls = ( [ Key "ControlLeft", Key "KeyZ" ], [ Key "AltLeft", Key "KeyX" ] )
119 | }
120 |
121 | orange : Player
122 | orange =
123 | { color = rgb 255 121 0
124 | , controls = ( [ Key "KeyM" ], [ Key "Comma" ] )
125 | }
126 |
127 | green : Player
128 | green =
129 | { color = rgb 0 203 0
130 | , controls = ( [ Key "ArrowLeft" ], [ Key "ArrowDown" ] )
131 | }
132 |
133 | pink : Player
134 | pink =
135 | { color = rgb 223 81 182
136 | , controls = ( [ Key "NumpadDivide", Key "End", Key "PageDown" ], [ Key "NumpadMultiply", Key "PageUp" ] )
137 | }
138 |
139 | blue : Player
140 | blue =
141 | { color = rgb 0 162 203
142 | , controls = ( [ Mouse 0 ], [ Mouse 2 ] )
143 | }
144 | in
145 | [ red
146 | , yellow
147 | , orange
148 | , green
149 | , pink
150 | , blue
151 | ]
152 |
--------------------------------------------------------------------------------
/_site/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Achtung, die Kurve!
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 | Achtung, die Kurve!
43 |
44 |
45 |
46 |
112 |
113 |
114 |
115 |
116 |
--------------------------------------------------------------------------------
/src/Spawn.elm:
--------------------------------------------------------------------------------
1 | module Spawn exposing
2 | ( generateHoleSize
3 | , generateHoleSpacing
4 | , generateKurves
5 | )
6 |
7 | import Config exposing (Config, HoleConfig, KurveConfig, SpawnConfig, WorldConfig)
8 | import Dict
9 | import Input exposing (toStringSetControls)
10 | import Players exposing (ParticipatingPlayers)
11 | import Random
12 | import Random.Extra as Random
13 | import Thickness exposing (theThickness)
14 | import Types.Angle exposing (Angle(..))
15 | import Types.Distance as Distance exposing (Distance)
16 | import Types.Kurve as Kurve exposing (Kurve)
17 | import Types.Player exposing (Player)
18 | import Types.PlayerId exposing (PlayerId)
19 | import Types.Radius as Radius
20 | import Util exposing (curry)
21 | import World exposing (Position, distanceBetween, distanceToTicks)
22 |
23 |
24 | generateKurves : Config -> ParticipatingPlayers -> Random.Generator (List Kurve)
25 | generateKurves config players =
26 | let
27 | numberOfPlayers : Int
28 | numberOfPlayers =
29 | Dict.size players
30 |
31 | generateNewAndPrepend : ( PlayerId, Player ) -> List Kurve -> Random.Generator (List Kurve)
32 | generateNewAndPrepend ( id, player ) precedingKurves =
33 | generateKurve config id numberOfPlayers (List.map (.state >> .position) precedingKurves) player
34 | |> Random.map (\kurve -> kurve :: precedingKurves)
35 | in
36 | Dict.foldr
37 | (\id ( player, _ ) -> curry (Random.andThen << generateNewAndPrepend) id player)
38 | (Random.constant [])
39 | players
40 |
41 |
42 | isSafeNewPosition : Config -> Int -> List Position -> Position -> Bool
43 | isSafeNewPosition config numberOfPlayers existingPositions newPosition =
44 | List.all (not << isTooCloseFor numberOfPlayers config newPosition) existingPositions
45 |
46 |
47 | isTooCloseFor : Int -> Config -> Position -> Position -> Bool
48 | isTooCloseFor numberOfPlayers config point1 point2 =
49 | let
50 | desiredMinimumDistance : Float
51 | desiredMinimumDistance =
52 | theThickness + Radius.toFloat config.kurves.turningRadius * config.spawn.desiredMinimumDistanceTurningRadiusFactor
53 |
54 | ( ( left, top ), ( right, bottom ) ) =
55 | spawnArea config.spawn config.world
56 |
57 | availableArea : Float
58 | availableArea =
59 | (right - left) * (bottom - top)
60 |
61 | -- Derived from:
62 | -- audacity × total available area > number of players × ( max allowed minimum distance / 2 )² × pi
63 | maxAllowedMinimumDistance : Float
64 | maxAllowedMinimumDistance =
65 | 2 * sqrt (config.spawn.protectionAudacity * availableArea / (toFloat numberOfPlayers * pi))
66 | in
67 | Distance.toFloat (distanceBetween point1 point2) < min desiredMinimumDistance maxAllowedMinimumDistance
68 |
69 |
70 | generateKurve : Config -> PlayerId -> Int -> List Position -> Player -> Random.Generator Kurve
71 | generateKurve config id numberOfPlayers existingPositions player =
72 | let
73 | safeSpawnPosition : Random.Generator Position
74 | safeSpawnPosition =
75 | generateSpawnPosition config.spawn config.world |> Random.filter (isSafeNewPosition config numberOfPlayers existingPositions)
76 | in
77 | Random.map3
78 | (\generatedPosition generatedAngle generatedHoleStatus ->
79 | let
80 | state : Kurve.State
81 | state =
82 | { position = generatedPosition
83 | , direction = generatedAngle
84 | , holeStatus = generatedHoleStatus
85 | }
86 | in
87 | { color = player.color
88 | , id = id
89 | , controls = toStringSetControls player.controls
90 | , state = state
91 | , stateAtSpawn = state
92 | , reversedInteractions = []
93 | }
94 | )
95 | safeSpawnPosition
96 | (generateSpawnAngle config.spawn.angleInterval)
97 | (generateInitialHoleStatus config.kurves)
98 |
99 |
100 | spawnArea : SpawnConfig -> WorldConfig -> ( Position, Position )
101 | spawnArea { margin } { width, height } =
102 | let
103 | topLeft : ( Float, Float )
104 | topLeft =
105 | ( margin
106 | , margin
107 | )
108 |
109 | bottomRight : ( Float, Float )
110 | bottomRight =
111 | ( toFloat width - margin
112 | , toFloat height - margin
113 | )
114 | in
115 | ( topLeft, bottomRight )
116 |
117 |
118 | generateSpawnPosition : SpawnConfig -> WorldConfig -> Random.Generator Position
119 | generateSpawnPosition spawnConfig worldConfig =
120 | let
121 | ( ( left, top ), ( right, bottom ) ) =
122 | spawnArea spawnConfig worldConfig
123 | in
124 | Random.pair (Random.float left right) (Random.float top bottom)
125 |
126 |
127 | generateSpawnAngle : ( Float, Float ) -> Random.Generator Angle
128 | generateSpawnAngle ( min, max ) =
129 | Random.float min max |> Random.map Angle
130 |
131 |
132 | generateHoleSpacing : HoleConfig -> Random.Generator Distance
133 | generateHoleSpacing holeConfig =
134 | Distance.generate holeConfig.minInterval holeConfig.maxInterval
135 |
136 |
137 | generateHoleSize : HoleConfig -> Random.Generator Distance
138 | generateHoleSize holeConfig =
139 | Distance.generate holeConfig.minSize holeConfig.maxSize
140 |
141 |
142 | generateInitialHoleStatus : KurveConfig -> Random.Generator Kurve.HoleStatus
143 | generateInitialHoleStatus { tickrate, speed, holes } =
144 | generateHoleSpacing holes |> Random.map (distanceToTicks tickrate speed >> Kurve.Unholy)
145 |
--------------------------------------------------------------------------------
/src/css/Zatacka.scss:
--------------------------------------------------------------------------------
1 | $nativeWidth: 640px;
2 | $nativeHeight: 480px;
3 |
4 | * {
5 | margin: 0;
6 | padding: 0;
7 | font-family: "Consolas", monospace;
8 | image-rendering: pixelated;
9 | }
10 |
11 | html, body {
12 | width: 100%;
13 | height: 100%;
14 | }
15 |
16 | p {
17 | line-height: 0; // Prevents extra "padding" below the actual letters (which are effectively 8 × 8 bitmap images).
18 | }
19 |
20 | body {
21 | background-color: black;
22 | overflow: hidden;
23 | color: white;
24 | font-size: 12px;
25 | }
26 |
27 | #elm-root {
28 | min-width: $nativeWidth;
29 | min-height: $nativeHeight;
30 | width: 100%;
31 | height: 100%;
32 | display: flex;
33 | align-items: center;
34 | justify-content: center;
35 |
36 | &.in-game {
37 | background-color: #3C3C3C;
38 | }
39 | }
40 |
41 | h1 {
42 | display: none;
43 | }
44 |
45 | a:link, a:visited {
46 | color: inherit;
47 | text-decoration: none;
48 | }
49 |
50 | a:hover, a:focus, a:active {
51 | text-decoration: none;
52 | }
53 |
54 | #wrapper {
55 | align-items: center;
56 | display: flex;
57 | -moz-user-select: none;
58 | -webkit-user-select: none;
59 | user-select: none;
60 | }
61 |
62 | $canvasWidth: 559px;
63 | $borderWidth: 4px;
64 | $scoreboardWidth: 77px;
65 | $leftMargin: (
66 | $scoreboardWidth // Keeps the canvas horizontally centered.
67 | - 1px // Makes the total width of the wrapper even, so it can be trivially centered when in fullscreen. (Monitors are assumed to always have an even width in pixels.)
68 | );
69 |
70 | #border {
71 | width: $canvasWidth;
72 | height: 480px;
73 | background-color: black;
74 | display: flex; /* to prevent weird extra space at the bottom */
75 | position: relative; /* to allow absolute positioning of descendants*/
76 | box-shadow: (
77 | 0 0 0 1px #828282,
78 | 0 0 0 2px #717171,
79 | 0 0 0 3px #616161,
80 | 0 0 0 4px #515151,
81 | );
82 | margin: $borderWidth;
83 | margin-left: $leftMargin + $borderWidth;
84 | }
85 |
86 | $minWidthForCenteredCanvas: (
87 | // The canvas should typically be centered, but not at the expense of scoreboard visibility.
88 | $leftMargin + $borderWidth + $canvasWidth + $borderWidth + $scoreboardWidth
89 | );
90 |
91 | @media (max-width: $minWidthForCenteredCanvas) {
92 | #elm-root.in-game {
93 | justify-content: flex-end; // Prioritizes visible scoreboard over centered canvas.
94 | }
95 | }
96 |
97 | @media (min-width: $minWidthForCenteredCanvas) {
98 | #elm-root.in-game {
99 | justify-content: center;
100 | }
101 | }
102 |
103 | .overlay {
104 | left: 0;
105 | position: absolute;
106 | top: 0;
107 | width: 100%;
108 | height: 100%;
109 | }
110 |
111 | .textOverlay {
112 | display: flex;
113 | align-items: center;
114 | justify-content: center;
115 | flex-direction: column;
116 | opacity: 0.5;
117 | }
118 |
119 | .textInUpperLeftCorner {
120 | position: absolute;
121 | top: 16px;
122 | left: 16px;
123 | }
124 |
125 | .largeDigit {
126 | width: 28px;
127 | height: 43px;
128 | display: inline-block;
129 | -webkit-mask-image: url("../resources/digits-large.png");
130 | mask-image: url("../resources/digits-large.png");
131 | }
132 |
133 | .character {
134 | display: inline-block;
135 | -webkit-mask-image: url("../resources/fonts/bgi-default-8x8.png");
136 | mask-image: url("../resources/fonts/bgi-default-8x8.png");
137 | -webkit-mask-repeat: no-repeat;
138 | mask-repeat: no-repeat; // Prevents unsupported characters from wrapping around and being displayed as some seemingly arbitrary supported character.
139 | -webkit-mask-size: auto 100%;
140 | mask-size: auto 100%;
141 | }
142 |
143 | .dialogOverlay {
144 | display: flex;
145 | align-items: center;
146 | justify-content: center;
147 | background-color: rgba(0, 0, 0, 0.5);
148 | }
149 |
150 | .dialog {
151 | border: 1px solid white;
152 | $padding: 12px;
153 | padding: $padding;
154 | background-color: black;
155 |
156 | p {
157 | line-height: 12px;
158 | margin-bottom: 16px;
159 | }
160 |
161 | button {
162 | width: calc(50% - $padding/2);
163 | min-width: 96px;
164 | }
165 |
166 | button:not(:last-child) {
167 | margin-right: 8px;
168 | }
169 | }
170 |
171 | button {
172 | background-color: black;
173 | border: 1px solid rgba(255, 255, 255, 0.5);
174 | color: white;
175 | height: 32px;
176 | cursor: pointer;
177 |
178 | &.focused {
179 | border-color: white;
180 | }
181 |
182 | &:hover {
183 | border-color: white;
184 | background-color: rgba(255, 255, 255, 0.1);
185 | }
186 | }
187 |
188 | #splashScreen {
189 | width: $nativeWidth;
190 | height: $nativeHeight;
191 | background-image: url("../resources/splash.png");
192 | }
193 |
194 | #lobby {
195 | width: $nativeWidth;
196 | height: $nativeHeight;
197 | padding-top: 50px;
198 | padding-left: 80px;
199 | box-sizing: border-box;
200 |
201 | .playerEntry {
202 | width: 100%;
203 | height: 50px;
204 |
205 | > div {
206 | height: 100%;
207 | display: inline-block;
208 | line-height: 0;
209 | vertical-align: top;
210 | }
211 |
212 | .controls {
213 | width: 160px;
214 | }
215 | }
216 | }
217 |
218 | #scoreboard {
219 | width: $scoreboardWidth;
220 | box-sizing: border-box;
221 | padding: 20px 12px 0 9px;
222 |
223 | .scoreboardEntry {
224 | height: 80px;
225 | }
226 | }
227 |
228 | #endScreen {
229 | position: relative;
230 | width: $nativeWidth;
231 | height: $nativeHeight;
232 |
233 | #results {
234 | margin-top: 80px;
235 | margin-left: 250px;
236 | }
237 |
238 | .resultsEntry {
239 | height: 40px;
240 | }
241 |
242 | #KONEC_HRY {
243 | position: absolute;
244 | left: 180px;
245 | bottom: 17px;
246 | }
247 | }
248 |
249 | #canvas_main {
250 | background-color: black;
251 | overflow: hidden;
252 | }
253 |
254 | .canvasHeight {
255 | height: $nativeHeight;
256 | }
257 |
258 | #left {
259 | box-sizing: border-box;
260 | width: 1px; /* so width of #wrapper is an even number */
261 | }
262 |
--------------------------------------------------------------------------------
/src/GUI/Buttons/Keyboard.elm:
--------------------------------------------------------------------------------
1 | module GUI.Buttons.Keyboard exposing (keyCodeRepresentation)
2 |
3 | {-| This module contains logic for representing a key code (the `code` property of a `KeyboardEvent`) in a human-readable way.
4 |
5 | There are three major aspects that make this non-trivial:
6 |
7 | - The `code` property represents a physical key, without any keyboard layout settings taken into consideration. For example, we cannot distinguish between "[" on an English keyboard, "Å" on a Swedish keyboard, and "Ü" on a German keyboard – all of them appear as `BracketLeft` to us.
8 | - There are differences between operating systems and browsers. For example, `OSLeft`/`MetaLeft` (depending on browser) is typically referred to as "Win" on Windows, "Super" on Linux and "Cmd" on macOS.
9 | - We can only render ASCII characters using the current GUI text implementation.
10 |
11 | 👉
12 | 👉
13 |
14 | -}
15 |
16 |
17 | keyCodeRepresentation : String -> String
18 | keyCodeRepresentation keyCode =
19 | case interpret keyCode of
20 | Character c ->
21 | String.fromChar c
22 |
23 | NumpadChar c ->
24 | "Num " ++ String.fromChar c
25 |
26 | FKey n ->
27 | "F" ++ String.fromInt n
28 |
29 | Named name ->
30 | name
31 |
32 | Unknown ->
33 | keyCode
34 |
35 |
36 | type Interpretation
37 | = Character Char
38 | | NumpadChar Char
39 | | FKey Int
40 | | Named String
41 | | Unknown
42 |
43 |
44 | interpret : String -> Interpretation
45 | interpret keyCode =
46 | case String.toList keyCode of
47 | 'D' :: 'i' :: 'g' :: 'i' :: 't' :: c :: [] ->
48 | -- DigitX
49 | Character c
50 |
51 | 'K' :: 'e' :: 'y' :: c :: [] ->
52 | -- KeyX
53 | Character c
54 |
55 | 'N' :: 'u' :: 'm' :: 'p' :: 'a' :: 'd' :: c :: [] ->
56 | -- NumpadX, where X is presumably a digit
57 | NumpadChar c
58 |
59 | _ ->
60 | parseFKey keyCode |> Maybe.withDefault (misc keyCode)
61 |
62 |
63 | parseFKey : String -> Maybe Interpretation
64 | parseFKey keyCode =
65 | case String.uncons keyCode of
66 | Just ( 'F', n ) ->
67 | -- FX, where X is a presumably positive integer
68 | String.toInt n |> Maybe.map FKey
69 |
70 | _ ->
71 | Nothing
72 |
73 |
74 | {-| Miscellaneous keys.
75 |
76 | Note: No button description in the original game exceeds 7 characters in length, and 8 is the maximum that's guaranteed to fit to the left of "READY".
77 |
78 | -}
79 | misc : String -> Interpretation
80 | misc keyCode =
81 | case keyCode of
82 | {- Alphanumeric Section
83 | https://www.w3.org/TR/uievents-code/#key-alphanumeric-section
84 | -}
85 | "Backquote" ->
86 | Character '`'
87 |
88 | "Backslash" ->
89 | Character '\\'
90 |
91 | "Backspace" ->
92 | Named "Bksp"
93 |
94 | "BracketLeft" ->
95 | Character '['
96 |
97 | "BracketRight" ->
98 | Character ']'
99 |
100 | "Comma" ->
101 | -- From the original game.
102 | Character ','
103 |
104 | "Equal" ->
105 | Character '='
106 |
107 | "IntlBackslash" ->
108 | -- Unclear what this key can be said to represent, but it produces a '\' in the English (UK) layout.
109 | Character '\\'
110 |
111 | "Minus" ->
112 | Character '-'
113 |
114 | "Period" ->
115 | Character '.'
116 |
117 | "Quote" ->
118 | Character '\''
119 |
120 | "Semicolon" ->
121 | Character ';'
122 |
123 | "Slash" ->
124 | Character '/'
125 |
126 | {- Functional Keys
127 | https://www.w3.org/TR/uievents-code/#key-alphanumeric-functional
128 | -}
129 | "AltLeft" ->
130 | Named "L.Alt"
131 |
132 | "AltRight" ->
133 | -- Could have been "AltGr", but this is nicely dual to "L.Alt"; they are in turn analogous to "L.Ctrl" (from the original game) and "R.Ctrl".
134 | Named "R.Alt"
135 |
136 | "CapsLock" ->
137 | Named "CapsLk"
138 |
139 | "ContextMenu" ->
140 | Named "Menu"
141 |
142 | "ControlLeft" ->
143 | Named "L.Ctrl"
144 |
145 | "ControlRight" ->
146 | Named "R.Ctrl"
147 |
148 | "Enter" ->
149 | Named "Enter"
150 |
151 | "ShiftLeft" ->
152 | Named "L.Shift"
153 |
154 | "ShiftRight" ->
155 | Named "R.Shift"
156 |
157 | "Space" ->
158 | Named "Space"
159 |
160 | "Tab" ->
161 | Named "Tab"
162 |
163 | {- Control Pad Section
164 | https://www.w3.org/TR/uievents-code/#key-controlpad-section
165 | -}
166 | "Delete" ->
167 | Named "Del"
168 |
169 | "End" ->
170 | Named "End"
171 |
172 | "Home" ->
173 | Named "Home"
174 |
175 | "Insert" ->
176 | Named "Ins"
177 |
178 | "PageDown" ->
179 | Named "PgDn"
180 |
181 | "PageUp" ->
182 | Named "PgUp"
183 |
184 | {- Arrow Pad Section
185 | https://www.w3.org/TR/uievents-code/#key-arrowpad-section
186 | -}
187 | "ArrowDown" ->
188 | -- From the original game.
189 | Named "D.Arrow"
190 |
191 | "ArrowLeft" ->
192 | -- From the original game.
193 | Named "L.Arrow"
194 |
195 | "ArrowRight" ->
196 | Named "R.Arrow"
197 |
198 | "ArrowUp" ->
199 | Named "U.Arrow"
200 |
201 | {- Numpad Section
202 | https://www.w3.org/TR/uievents-code/#key-numpad-section
203 | -}
204 | "NumLock" ->
205 | Named "NumLk"
206 |
207 | "NumpadAdd" ->
208 | -- Just the character without any prefix to maintain consistency between the mathematical operators; 'NumpadMultiply' is rendered as '*' in the original game.
209 | Character '+'
210 |
211 | "NumpadComma" ->
212 | NumpadChar ','
213 |
214 | "NumpadDecimal" ->
215 | NumpadChar '.'
216 |
217 | "NumpadDivide" ->
218 | -- Just the character without any prefix to maintain consistency between the mathematical operators; 'NumpadMultiply' is rendered as '*' in the original game.
219 | Character '/'
220 |
221 | "NumpadEnter" ->
222 | Named "Enter"
223 |
224 | "NumpadEqual" ->
225 | -- Just the character without any prefix to maintain consistency between the mathematical operators; 'NumpadMultiply' is rendered as '*' in the original game.
226 | Character '='
227 |
228 | "NumpadMultiply" ->
229 | -- From the original game.
230 | Character '*'
231 |
232 | "NumpadParenLeft" ->
233 | NumpadChar '('
234 |
235 | "NumpadParenRight" ->
236 | NumpadChar ')'
237 |
238 | "NumpadSubtract" ->
239 | -- Just the character without any prefix to maintain consistency between the mathematical operators; 'NumpadMultiply' is rendered as '*' in the original game.
240 | Character '-'
241 |
242 | {- Function Section
243 | https://www.w3.org/TR/uievents-code/#key-function-section
244 | -}
245 | "Escape" ->
246 | Named "Esc"
247 |
248 | "Pause" ->
249 | Named "Pause"
250 |
251 | "PrintScreen" ->
252 | Named "PrtSc"
253 |
254 | "ScrollLock" ->
255 | Named "ScrLk"
256 |
257 | {- Media Keys
258 | https://www.w3.org/TR/uievents-code/#key-media
259 | -}
260 | "AudioVolumeDown" ->
261 | Named "VolDn"
262 |
263 | "AudioVolumeMute" ->
264 | Named "Mute"
265 |
266 | "AudioVolumeUp" ->
267 | Named "VolUp"
268 |
269 | "VolumeDown" ->
270 | Named "VolDn"
271 |
272 | "VolumeMute" ->
273 | Named "Mute"
274 |
275 | "VolumeUp" ->
276 | Named "VolUp"
277 |
278 | _ ->
279 | Unknown
280 |
--------------------------------------------------------------------------------
/tools/scenario.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # Usage: see the Git history for this script.
3 |
4 | from enum import Enum
5 | import json
6 | import os
7 | import subprocess
8 | import sys
9 | import time
10 | from typing import Callable, Literal, TypedDict
11 |
12 | path_to_original_game = sys.argv[1]
13 | raw_base_address = sys.argv[2] # e.g. 7fffd8010ff6
14 | additional_dosbox_config_file = sys.argv[3] # e.g. tools/dosbox-linux.conf
15 |
16 | ENV_VAR_DRY_RUN = "DRY_RUN"
17 |
18 |
19 | class PlayerId(Enum):
20 | RED = 0
21 | YELLOW = 1
22 | ORANGE = 2
23 | GREEN = 3
24 | PINK = 4
25 | BLUE = 5
26 |
27 |
28 | JOIN_PLAYER: dict[PlayerId, Callable[[], None]] = {
29 | PlayerId.RED: lambda: press_key("1"),
30 | PlayerId.YELLOW: lambda: press_key("Ctrl"),
31 | PlayerId.ORANGE: lambda: press_key("M"),
32 | PlayerId.GREEN: lambda: press_key("Left"),
33 | PlayerId.PINK: lambda: press_key("KP_Divide"),
34 | PlayerId.BLUE: lambda: click_mouse_button(),
35 | }
36 |
37 |
38 | def check_that_dosbox_config_file_exists() -> None:
39 | if not os.path.isfile(additional_dosbox_config_file):
40 | print(f"❌ DOSBox config file '{additional_dosbox_config_file}' not found.")
41 | exit(1)
42 |
43 |
44 | def check_that_dosbox_is_not_already_open() -> None:
45 | window_id = find_dosbox(have_just_launched_it=False)
46 | if window_id is not None:
47 | print("❌ DOSBox seems to already be open. Please close it.")
48 | exit(1)
49 |
50 |
51 | def check_address_space_layout_randomization() -> None:
52 | try:
53 | aslr_file = open(file="/proc/sys/kernel/randomize_va_space", mode="r")
54 | content = aslr_file.read(1) # We expect a single digit (0, 1 or 2).
55 | if content != "0":
56 | print(
57 | "⚠️ Address space layout randomization (ASLR) seems to be enabled, which may cause this script not to work.",
58 | )
59 | print("💡 Solution: temporarily disable ASLR while using this script.")
60 | except OSError as err:
61 | print(
62 | f"⚠️ Could not check if address space layout randomization (ASLR) is disabled. Reason: {str(err)}"
63 | )
64 |
65 |
66 | def find_and_focus_dosbox() -> str:
67 | window_id = find_dosbox(have_just_launched_it=True)
68 | if window_id is None:
69 | print("❌ Couldn't find the DOSBox window.")
70 | exit(1)
71 | subprocess.run(["xdotool", "windowactivate", "--sync", window_id])
72 | subprocess.run(["xdotool", "windowfocus", window_id])
73 | return window_id
74 |
75 |
76 | def find_dosbox(have_just_launched_it: bool) -> str | None:
77 | res = subprocess.run(
78 | [
79 | "xdotool",
80 | "search",
81 | ]
82 | + (
83 | [
84 | "--sync",
85 | "--onlyvisible",
86 | ]
87 | if have_just_launched_it
88 | else []
89 | )
90 | + [
91 | "--name",
92 | "DOSBox.+ZATACKA",
93 | ],
94 | capture_output=True,
95 | text=True,
96 | )
97 | if res.returncode == 0 and res.stdout.strip():
98 | window_id: str = res.stdout.strip().splitlines()[-1] # (most recent window ID)
99 | return window_id
100 | return None
101 |
102 |
103 | def stage_scenario(process_id: int, gdb_program_file: str) -> None:
104 | subprocess.Popen(
105 | ["sudo", "gdb", "--pid", str(process_id), "--command", gdb_program_file],
106 | # We have to suppress stdio, otherwise gdb kind of takes over the terminal permanently and makes everything typed into it invisible (in WSL on my Windows PC as well as on my Ubuntu laptop).
107 | stdin=subprocess.DEVNULL,
108 | stdout=subprocess.DEVNULL,
109 | stderr=subprocess.DEVNULL,
110 | )
111 |
112 |
113 | def press_key(key: str) -> None:
114 | subprocess.run(["xdotool", "key", key])
115 |
116 |
117 | def click_mouse_button() -> None:
118 | mouse_button = 1 # (left click)
119 | subprocess.run(["xdotool", "mousedown", str(mouse_button)])
120 | subprocess.run(["xdotool", "mouseup", str(mouse_button)])
121 |
122 |
123 | def launch_original_game_and_stage_scenario(
124 | path_to_original_game: str,
125 | participating_players: list[PlayerId],
126 | gdb_program_file: str,
127 | ) -> None:
128 | print(f"🚀 Launching original game at {path_to_original_game} …")
129 |
130 | proc = subprocess.Popen(
131 | [
132 | "dosbox",
133 | "-userconf",
134 | "-conf",
135 | additional_dosbox_config_file,
136 | path_to_original_game,
137 | ],
138 | stdout=subprocess.DEVNULL,
139 | stderr=subprocess.DEVNULL,
140 | )
141 |
142 | time.sleep(2) # Prevents intermittent failure to find/focus DOSBox.
143 |
144 | find_and_focus_dosbox()
145 |
146 | stage_scenario(proc.pid, gdb_program_file)
147 |
148 | time.sleep(2)
149 | press_key("space")
150 | time.sleep(0.5)
151 | for player_id in participating_players:
152 | JOIN_PLAYER[player_id]()
153 | time.sleep(0.5)
154 | press_key("space")
155 |
156 |
157 | class CompiledScenario(TypedDict):
158 | participatingPlayersById: list[int]
159 | gdbProgram: str
160 |
161 |
162 | type CompilationResultAsJson = CompilationSuccess | CompilationFailure
163 |
164 |
165 | class CompilationSuccess(TypedDict):
166 | compilationSuccess: Literal[True]
167 | compiledScenario: CompiledScenario
168 |
169 |
170 | class CompilationFailure(TypedDict):
171 | compilationSuccess: Literal[False]
172 | compilationErrorMessage: str
173 |
174 |
175 | def compile_scenario() -> CompiledScenario:
176 | npm_process = subprocess.run(["npm", "run", "build:scenario-in-original-game"])
177 | npm_exit_code = npm_process.returncode
178 | if npm_exit_code != 0:
179 | print("❌ Elm compilation failed.")
180 | exit(1)
181 |
182 | path_to_glue_javascript = os.path.join(
183 | os.path.dirname(sys.argv[0]), "compile-scenario-glue.cjs"
184 | )
185 |
186 | node_process = subprocess.run(
187 | ["node", path_to_glue_javascript, raw_base_address],
188 | encoding="utf-8",
189 | capture_output=True,
190 | )
191 | node_exit_code = node_process.returncode
192 | if node_exit_code != 0:
193 | print(
194 | f"❌ Unexpected exit code from {path_to_glue_javascript}: {node_exit_code}"
195 | )
196 | print(node_process.stderr)
197 | exit(1)
198 |
199 | try:
200 | result: CompilationResultAsJson = json.loads(
201 | node_process.stdout
202 | ) # This is blind trust. 👀
203 | except json.JSONDecodeError as e:
204 | print("❌ Scenario compilation result could not be parsed.")
205 | print(e)
206 | exit(1)
207 |
208 | if result["compilationSuccess"] is True:
209 | return result["compiledScenario"]
210 | else:
211 | print("❌ Scenario compilation failed.")
212 | print(result["compilationErrorMessage"])
213 | exit(1)
214 |
215 |
216 | def main() -> None:
217 | is_dry_run = bool(os.environ.get(ENV_VAR_DRY_RUN))
218 |
219 | subprocess.run(
220 | ["sudo", "true"]
221 | ) # Fail early if password hasn't been entered recently.
222 |
223 | check_that_dosbox_config_file_exists() # DOSBox 0.74.3 silently ignores if the specified config file doesn't exist.
224 |
225 | check_that_dosbox_is_not_already_open()
226 |
227 | check_address_space_layout_randomization()
228 |
229 | compiled_scenario = compile_scenario()
230 |
231 | gdb_program: str = compiled_scenario["gdbProgram"]
232 |
233 | participating_players = [
234 | PlayerId(i) for i in compiled_scenario["participatingPlayersById"]
235 | ]
236 |
237 | if is_dry_run:
238 | print("BEGIN gdb program")
239 | print()
240 | print(gdb_program)
241 | print()
242 | print("END gdb program")
243 | print()
244 | print(
245 | f"💡 Environment variable {ENV_VAR_DRY_RUN} specified. Not launching original game."
246 | )
247 | return
248 |
249 | if PlayerId.BLUE in participating_players:
250 | print(
251 | "💡 Blue is participating; make sure to keep the cursor within the DOSBox window."
252 | )
253 |
254 | GDB_PROGRAM_FILE = ".compiled-scenario.gdb"
255 | with open(GDB_PROGRAM_FILE, "+w") as f:
256 | print(f"📝 Writing {GDB_PROGRAM_FILE} …")
257 | f.write(gdb_program)
258 |
259 | launch_original_game_and_stage_scenario(
260 | path_to_original_game,
261 | participating_players,
262 | GDB_PROGRAM_FILE,
263 | )
264 |
265 |
266 | if __name__ == "__main__":
267 | main()
268 |
--------------------------------------------------------------------------------
/tests/AchtungTest.elm:
--------------------------------------------------------------------------------
1 | module AchtungTest exposing (tests)
2 |
3 | import Config
4 | import String
5 | import Test exposing (Test, describe, test)
6 | import TestHelpers exposing (defaultConfigWithSpeed, expectRoundOutcome)
7 | import TestScenarioHelpers exposing (roundWith, tickNumber)
8 | import TestScenarios.AroundTheWorld
9 | import TestScenarios.CrashIntoKurveTiming
10 | import TestScenarios.CrashIntoTailEnd90Degrees
11 | import TestScenarios.CrashIntoTipOfTailEnd
12 | import TestScenarios.CrashIntoWallBasic
13 | import TestScenarios.CrashIntoWallBottom
14 | import TestScenarios.CrashIntoWallExactTiming
15 | import TestScenarios.CrashIntoWallLeft
16 | import TestScenarios.CrashIntoWallRight
17 | import TestScenarios.CrashIntoWallTop
18 | import TestScenarios.CuttingCornersBasic
19 | import TestScenarios.CuttingCornersPerfectOverpainting
20 | import TestScenarios.CuttingCornersThreePixelsRealExample
21 | import TestScenarios.SpeedEffectOnGame
22 | import TestScenarios.StressTestRealisticTurtleSurvivalRound
23 | import Types.Speed as Speed exposing (Speed(..))
24 |
25 |
26 | tests : Test
27 | tests =
28 | Test.concat
29 | [ basicTests
30 | , crashingIntoKurveTests
31 | , crashingIntoWallTests
32 | , crashTimingTests
33 | , cuttingCornersTests
34 | , speedTests
35 | , stressTests
36 | ]
37 |
38 |
39 | basicTests : Test
40 | basicTests =
41 | describe "Basic tests"
42 | [ test "A Kurve that crashes into the wall dies" <|
43 | \_ ->
44 | roundWith TestScenarios.CrashIntoWallBasic.spawnedKurves
45 | |> expectRoundOutcome
46 | Config.default
47 | TestScenarios.CrashIntoWallBasic.expectedOutcome
48 | , test "Around the world, touching each wall" <|
49 | \_ ->
50 | roundWith TestScenarios.AroundTheWorld.spawnedKurves
51 | |> expectRoundOutcome
52 | Config.default
53 | TestScenarios.AroundTheWorld.expectedOutcome
54 | ]
55 |
56 |
57 | crashingIntoKurveTests : Test
58 | crashingIntoKurveTests =
59 | describe "Crashing into a Kurve"
60 | [ test "Hitting a Kurve's tail end is a crash" <|
61 | \_ ->
62 | roundWith TestScenarios.CrashIntoTailEnd90Degrees.spawnedKurves
63 | |> expectRoundOutcome
64 | Config.default
65 | TestScenarios.CrashIntoTailEnd90Degrees.expectedOutcome
66 | , test "Hitting a Kurve's tail end at a 45-degree angle is a crash" <|
67 | \_ ->
68 | roundWith TestScenarios.CrashIntoTipOfTailEnd.spawnedKurves
69 | |> expectRoundOutcome
70 | Config.default
71 | TestScenarios.CrashIntoTipOfTailEnd.expectedOutcome
72 | ]
73 |
74 |
75 | crashingIntoWallTests : Test
76 | crashingIntoWallTests =
77 | describe "Crashing into a wall"
78 | [ test "Top wall" <|
79 | \_ ->
80 | roundWith TestScenarios.CrashIntoWallTop.spawnedKurves
81 | |> expectRoundOutcome
82 | Config.default
83 | TestScenarios.CrashIntoWallTop.expectedOutcome
84 | , test "Right wall" <|
85 | \_ ->
86 | roundWith TestScenarios.CrashIntoWallRight.spawnedKurves
87 | |> expectRoundOutcome
88 | Config.default
89 | TestScenarios.CrashIntoWallRight.expectedOutcome
90 | , test "Bottom wall" <|
91 | \_ ->
92 | roundWith TestScenarios.CrashIntoWallBottom.spawnedKurves
93 | |> expectRoundOutcome
94 | Config.default
95 | TestScenarios.CrashIntoWallBottom.expectedOutcome
96 | , test "Left wall" <|
97 | \_ ->
98 | roundWith TestScenarios.CrashIntoWallLeft.spawnedKurves
99 | |> expectRoundOutcome
100 | Config.default
101 | TestScenarios.CrashIntoWallLeft.expectedOutcome
102 | ]
103 |
104 |
105 | {-|
106 |
107 |
108 | ## Crash timing predictability
109 |
110 | When a Kurve is traveling almost horizontally or vertically, it very obviously "snaps over to the next pixel row/column" at regular intervals.
111 | When approaching a horizontal or vertical obstacle (wall or Kurve) from a shallow angle, an experienced player can easily tell based on the "snaps" exactly when they need to turn away to avoid crashing, because that will always happen at a "snap", never in the middle of a continuous "segment".
112 |
113 | For example, the illustration below shows the exact moment when Green crashes into Red.
114 |
115 | Notably, Green enjoys a full "segment" right next to Red before dying.
116 | It would be highly surprising (at least to an experienced player) if Green would crash any earlier, because that would never happen in the original game.
117 |
118 | ⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛
119 | ⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛
120 | ⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛
121 | ⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛
122 | 🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥⬛⬛⬛⬛
123 | 🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥⬛⬛⬛⬛
124 | 🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥⬛⬛⬛⬛
125 | ⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛
126 | ⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛
127 | ⬛⬛⬛⬛⬛⬛⬛🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛
128 | 🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛
129 | 🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛
130 | 🟩🟩🟩🟩🟩🟩🟩🟩🟩⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛
131 | ⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛
132 | ⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛
133 | ⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛
134 | ⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛
135 |
136 | -}
137 | crashTimingTests : Test
138 | crashTimingTests =
139 | describe "Crash timing"
140 | [ crashingIntoWallTimingTest
141 | , crashingIntoKurveTimingTests
142 | ]
143 |
144 |
145 | crashingIntoWallTimingTest : Test
146 | crashingIntoWallTimingTest =
147 | test "The exact timing of a crash into the wall is predictable for the player" <|
148 | \_ ->
149 | roundWith TestScenarios.CrashIntoWallExactTiming.spawnedKurves
150 | |> expectRoundOutcome
151 | Config.default
152 | TestScenarios.CrashIntoWallExactTiming.expectedOutcome
153 |
154 |
155 | crashingIntoKurveTimingTests : Test
156 | crashingIntoKurveTimingTests =
157 | describe "The exact timing of a crash into a Kurve is predictable for the player"
158 | (List.range 0 9
159 | |> List.map
160 | (\decimal ->
161 | let
162 | y_red : Float
163 | y_red =
164 | 100 + toFloat decimal / 10
165 | in
166 | test
167 | ("When Red's vertical position is " ++ String.fromFloat y_red)
168 | (\_ ->
169 | roundWith (TestScenarios.CrashIntoKurveTiming.spawnedKurves y_red)
170 | |> expectRoundOutcome
171 | Config.default
172 | TestScenarios.CrashIntoKurveTiming.expectedOutcome
173 | )
174 | )
175 | )
176 |
177 |
178 | cuttingCornersTests : Test
179 | cuttingCornersTests =
180 | describe "Cutting corners (by painting over them)"
181 | [ test "It is possible to cut the corner of a Kurve's tail end" <|
182 | \_ ->
183 | roundWith TestScenarios.CuttingCornersBasic.spawnedKurves
184 | |> expectRoundOutcome
185 | Config.default
186 | TestScenarios.CuttingCornersBasic.expectedOutcome
187 | , test "It is possible to paint over three pixels when cutting a corner (real example from original game)" <|
188 | \_ ->
189 | roundWith TestScenarios.CuttingCornersThreePixelsRealExample.spawnedKurves
190 | |> expectRoundOutcome
191 | Config.default
192 | TestScenarios.CuttingCornersThreePixelsRealExample.expectedOutcome
193 | , test "The perfect overpainting (squeezing through a non-existent gap)" <|
194 | \_ ->
195 | roundWith TestScenarios.CuttingCornersPerfectOverpainting.spawnedKurves
196 | |> expectRoundOutcome
197 | Config.default
198 | TestScenarios.CuttingCornersPerfectOverpainting.expectedOutcome
199 | ]
200 |
201 |
202 | speedTests : Test
203 | speedTests =
204 | describe "Kurve speed"
205 | ([ ( Speed 60, tickNumber 450 )
206 | , ( Speed 120, tickNumber 225 )
207 | , ( Speed 180, tickNumber 150 )
208 | ]
209 | |> List.map
210 | (\( speed, expectedEndTick ) ->
211 | test ("Round ends as expected when speed is " ++ String.fromFloat (Speed.toFloat speed)) <|
212 | \_ ->
213 | roundWith TestScenarios.SpeedEffectOnGame.spawnedKurves
214 | |> expectRoundOutcome
215 | (defaultConfigWithSpeed speed)
216 | (TestScenarios.SpeedEffectOnGame.expectedOutcome expectedEndTick)
217 | )
218 | )
219 |
220 |
221 | stressTests : Test
222 | stressTests =
223 | describe "Stress tests"
224 | [ test "Realistic single-player turtle survival round" <|
225 | \_ ->
226 | roundWith TestScenarios.StressTestRealisticTurtleSurvivalRound.spawnedKurves
227 | |> expectRoundOutcome
228 | Config.default
229 | TestScenarios.StressTestRealisticTurtleSurvivalRound.expectedOutcome
230 | ]
231 |
--------------------------------------------------------------------------------
/tests/ScenarioInOriginalGameTest.elm:
--------------------------------------------------------------------------------
1 | module ScenarioInOriginalGameTest exposing (tests)
2 |
3 | import CompileScenario exposing (CompilationResult(..), compileScenario)
4 | import Expect
5 | import OriginalGamePlayers exposing (PlayerId(..))
6 | import ScenarioCore exposing (Scenario)
7 | import Test exposing (Test, describe, test)
8 |
9 |
10 | tests : Test
11 | tests =
12 | describe "Scenario compilation"
13 | [ test "Scenario with Red and Green in parallel on my laptop" <|
14 | \_ ->
15 | compileScenario
16 | [ "7fffd8010ff6" ]
17 | scenario_RedAndGreenInParallel
18 | |> Expect.equal expectedResult_RedAndGreenInParallel
19 | , test "Scenario with all players in WSL on my main PC" <|
20 | \_ ->
21 | compileScenario
22 | [ "7fffc1c65ff6" ]
23 | scenario_AllPlayers
24 | |> Expect.equal expectedResult_AllPlayers
25 | , test "Base address with '0x' prefix and capital letters" <|
26 | \_ ->
27 | compileScenario
28 | [ "0x7FFFD8010FF6" ]
29 | scenario_RedAndGreenInParallel
30 | |> Expect.equal expectedResult_RedAndGreenInParallel
31 | , test "Invalid base address" <|
32 | \_ ->
33 | compileScenario
34 | [ "LOL" ]
35 | scenario_Empty
36 | |> Expect.equal (CompilationFailure "Cannot parse base address: LOL (must be hexadecimal, with or without '0x' prefix).")
37 | , test "Too few arguments" <|
38 | \_ ->
39 | compileScenario
40 | []
41 | scenario_Empty
42 | |> Expect.equal (CompilationFailure "Unexpected number of arguments. Expected 1, but got 0.")
43 | , test "Too many arguments" <|
44 | \_ ->
45 | compileScenario
46 | [ "foo", "bar" ]
47 | scenario_Empty
48 | |> Expect.equal (CompilationFailure "Unexpected number of arguments. Expected 1, but got 2.")
49 | , test "No players" <|
50 | \_ ->
51 | compileScenario
52 | [ "0xdeadbeef" ]
53 | scenario_Empty
54 | |> Expect.equal (CompilationFailure "Scenario must have at least 2 players, but had 0.")
55 | , test "Only one player" <|
56 | \_ ->
57 | compileScenario
58 | [ "0xdeadbeef" ]
59 | scenario_OnlyOnePlayer
60 | |> Expect.equal (CompilationFailure "Scenario must have at least 2 players, but had 1.")
61 | , test "Duplicate player" <|
62 | \_ ->
63 | compileScenario
64 | [ "0xdeadbeef" ]
65 | scenario_DuplicatePlayer
66 | |> Expect.equal (CompilationFailure "Red specified more than once.")
67 | , test "Players in wrong order" <|
68 | \_ ->
69 | compileScenario
70 | [ "0xdeadbeef" ]
71 | scenario_WrongOrder
72 | |> Expect.equal (CompilationFailure "Players must be specified in this order: Red, Yellow, Orange, Green, Pink, Blue.")
73 | ]
74 |
75 |
76 | scenario_Empty : Scenario
77 | scenario_Empty =
78 | []
79 |
80 |
81 | scenario_OnlyOnePlayer : Scenario
82 | scenario_OnlyOnePlayer =
83 | [ ( Red
84 | , { x = 0
85 | , y = 0
86 | , direction = 0
87 | }
88 | )
89 | ]
90 |
91 |
92 | scenario_DuplicatePlayer : Scenario
93 | scenario_DuplicatePlayer =
94 | [ ( Red
95 | , { x = 0
96 | , y = 0
97 | , direction = 0
98 | }
99 | )
100 | , ( Red
101 | , { x = 5
102 | , y = 5
103 | , direction = 5
104 | }
105 | )
106 | ]
107 |
108 |
109 | scenario_WrongOrder : Scenario
110 | scenario_WrongOrder =
111 | [ ( Red
112 | , { x = 0
113 | , y = 0
114 | , direction = 0
115 | }
116 | )
117 | , ( Green
118 | , { x = 5
119 | , y = 5
120 | , direction = 5
121 | }
122 | )
123 | , ( Yellow
124 | , { x = 10
125 | , y = 10
126 | , direction = 10
127 | }
128 | )
129 | ]
130 |
131 |
132 | scenario_RedAndGreenInParallel : Scenario
133 | scenario_RedAndGreenInParallel =
134 | [ ( Red
135 | , { x = 10
136 | , y = 10
137 | , direction = pi / 2
138 | }
139 | )
140 | , ( Green
141 | , { x = 200
142 | , y = 150
143 | , direction = pi / 2
144 | }
145 | )
146 | ]
147 |
148 |
149 | expectedResult_RedAndGreenInParallel : CompilationResult
150 | expectedResult_RedAndGreenInParallel =
151 | CompilationSuccess
152 | { participating = [ Red, Green ]
153 | , compiledProgram =
154 | String.trim <|
155 | """
156 | set pagination off
157 | set logging file gdb-log.txt
158 | set logging overwrite on
159 | set logging enabled on
160 |
161 | print "⏳ 🟥 Set Red's x"
162 | watch *(float*)0x7fffd8010ff6
163 | commands
164 | set {float}0x7fffd8010ff6 = 10
165 | delete $bpnum
166 | print "✅ 🟥 Set Red's x"
167 |
168 | print "⏳ 🔧 Ignore bogus write to Red's y"
169 | watch *(float*)0x7fffd801100e
170 | commands
171 | x/4bx 0x7fffd801100e
172 | delete $bpnum
173 | print "✅ 🔧 Ignore bogus write to Red's y"
174 |
175 | print "⏳ 🔧 Ignore bogus write to Red's y"
176 | watch *(float*)0x7fffd801100e
177 | commands
178 | x/4bx 0x7fffd801100e
179 | delete $bpnum
180 | print "✅ 🔧 Ignore bogus write to Red's y"
181 |
182 | print "⏳ 🟥 Set Red's y"
183 | watch *(float*)0x7fffd801100e
184 | commands
185 | set {float}0x7fffd801100e = 10
186 | delete $bpnum
187 | print "✅ 🟥 Set Red's y"
188 |
189 | print "⏳ 🟥 Set Red's direction"
190 | watch *(float*)0x7fffd8011026
191 | commands
192 | set {float}0x7fffd8011026 = 1.5707963267948966
193 | delete $bpnum
194 | print "✅ 🟥 Set Red's direction"
195 |
196 | print "⏳ 🟩 Set Green's x"
197 | watch *(float*)0x7fffd8011002
198 | commands
199 | set {float}0x7fffd8011002 = 200
200 | delete $bpnum
201 | print "✅ 🟩 Set Green's x"
202 |
203 | print "⏳ 🟩 Set Green's y"
204 | watch *(float*)0x7fffd801101a
205 | commands
206 | set {float}0x7fffd801101a = 150
207 | delete $bpnum
208 | print "✅ 🟩 Set Green's y"
209 |
210 | print "⏳ 🟩 Set Green's direction"
211 | watch *(float*)0x7fffd8011032
212 | commands
213 | set {float}0x7fffd8011032 = 1.5707963267948966
214 | delete $bpnum
215 | print "✅ 🟩 Set Green's direction"
216 | exit
217 | continue
218 | end
219 |
220 | continue
221 | end
222 |
223 | continue
224 | end
225 |
226 | continue
227 | end
228 |
229 | continue
230 | end
231 |
232 | continue
233 | end
234 |
235 | continue
236 | end
237 |
238 | continue
239 | end
240 |
241 | continue
242 | """
243 | }
244 |
245 |
246 | scenario_AllPlayers : Scenario
247 | scenario_AllPlayers =
248 | [ ( Red
249 | , { x = 10
250 | , y = 10
251 | , direction = pi / 2
252 | }
253 | )
254 | , ( Yellow
255 | , { x = 10
256 | , y = 50
257 | , direction = 0
258 | }
259 | )
260 | , ( Orange
261 | , { x = 200
262 | , y = 200
263 | , direction = 2.5
264 | }
265 | )
266 | , ( Green
267 | , { x = 200
268 | , y = 250
269 | , direction = 3 * pi / 4
270 | }
271 | )
272 | , ( Pink
273 | , { x = 500
274 | , y = 477
275 | , direction = -pi / 2
276 | }
277 | )
278 | , ( Blue
279 | , { x = 400
280 | , y = 234.5
281 | , direction = 0.01
282 | }
283 | )
284 | ]
285 |
286 |
287 | expectedResult_AllPlayers : CompilationResult
288 | expectedResult_AllPlayers =
289 | CompilationSuccess
290 | { participating = [ Red, Yellow, Orange, Green, Pink, Blue ]
291 | , compiledProgram =
292 | String.trim <|
293 | """
294 | set pagination off
295 | set logging file gdb-log.txt
296 | set logging overwrite on
297 | set logging enabled on
298 |
299 | print "⏳ 🟥 Set Red's x"
300 | watch *(float*)0x7fffc1c65ff6
301 | commands
302 | set {float}0x7fffc1c65ff6 = 10
303 | delete $bpnum
304 | print "✅ 🟥 Set Red's x"
305 |
306 | print "⏳ 🔧 Ignore bogus write to Red's y"
307 | watch *(float*)0x7fffc1c6600e
308 | commands
309 | x/4bx 0x7fffc1c6600e
310 | delete $bpnum
311 | print "✅ 🔧 Ignore bogus write to Red's y"
312 |
313 | print "⏳ 🔧 Ignore bogus write to Red's y"
314 | watch *(float*)0x7fffc1c6600e
315 | commands
316 | x/4bx 0x7fffc1c6600e
317 | delete $bpnum
318 | print "✅ 🔧 Ignore bogus write to Red's y"
319 |
320 | print "⏳ 🟥 Set Red's y"
321 | watch *(float*)0x7fffc1c6600e
322 | commands
323 | set {float}0x7fffc1c6600e = 10
324 | delete $bpnum
325 | print "✅ 🟥 Set Red's y"
326 |
327 | print "⏳ 🟥 Set Red's direction"
328 | watch *(float*)0x7fffc1c66026
329 | commands
330 | set {float}0x7fffc1c66026 = 1.5707963267948966
331 | delete $bpnum
332 | print "✅ 🟥 Set Red's direction"
333 |
334 | print "⏳ 🟨 Set Yellow's x"
335 | watch *(float*)0x7fffc1c65ffa
336 | commands
337 | set {float}0x7fffc1c65ffa = 10
338 | delete $bpnum
339 | print "✅ 🟨 Set Yellow's x"
340 |
341 | print "⏳ 🟨 Set Yellow's y"
342 | watch *(float*)0x7fffc1c66012
343 | commands
344 | set {float}0x7fffc1c66012 = 50
345 | delete $bpnum
346 | print "✅ 🟨 Set Yellow's y"
347 |
348 | print "⏳ 🟨 Set Yellow's direction"
349 | watch *(float*)0x7fffc1c6602a
350 | commands
351 | set {float}0x7fffc1c6602a = 0
352 | delete $bpnum
353 | print "✅ 🟨 Set Yellow's direction"
354 |
355 | print "⏳ 🟧 Set Orange's x"
356 | watch *(float*)0x7fffc1c65ffe
357 | commands
358 | set {float}0x7fffc1c65ffe = 200
359 | delete $bpnum
360 | print "✅ 🟧 Set Orange's x"
361 |
362 | print "⏳ 🟧 Set Orange's y"
363 | watch *(float*)0x7fffc1c66016
364 | commands
365 | set {float}0x7fffc1c66016 = 200
366 | delete $bpnum
367 | print "✅ 🟧 Set Orange's y"
368 |
369 | print "⏳ 🟧 Set Orange's direction"
370 | watch *(float*)0x7fffc1c6602e
371 | commands
372 | set {float}0x7fffc1c6602e = 2.5
373 | delete $bpnum
374 | print "✅ 🟧 Set Orange's direction"
375 |
376 | print "⏳ 🟩 Set Green's x"
377 | watch *(float*)0x7fffc1c66002
378 | commands
379 | set {float}0x7fffc1c66002 = 200
380 | delete $bpnum
381 | print "✅ 🟩 Set Green's x"
382 |
383 | print "⏳ 🟩 Set Green's y"
384 | watch *(float*)0x7fffc1c6601a
385 | commands
386 | set {float}0x7fffc1c6601a = 250
387 | delete $bpnum
388 | print "✅ 🟩 Set Green's y"
389 |
390 | print "⏳ 🟩 Set Green's direction"
391 | watch *(float*)0x7fffc1c66032
392 | commands
393 | set {float}0x7fffc1c66032 = 2.356194490192345
394 | delete $bpnum
395 | print "✅ 🟩 Set Green's direction"
396 |
397 | print "⏳ 🟪 Set Pink's x"
398 | watch *(float*)0x7fffc1c66006
399 | commands
400 | set {float}0x7fffc1c66006 = 500
401 | delete $bpnum
402 | print "✅ 🟪 Set Pink's x"
403 |
404 | print "⏳ 🟪 Set Pink's y"
405 | watch *(float*)0x7fffc1c6601e
406 | commands
407 | set {float}0x7fffc1c6601e = 477
408 | delete $bpnum
409 | print "✅ 🟪 Set Pink's y"
410 |
411 | print "⏳ 🟪 Set Pink's direction"
412 | watch *(float*)0x7fffc1c66036
413 | commands
414 | set {float}0x7fffc1c66036 = -1.5707963267948966
415 | delete $bpnum
416 | print "✅ 🟪 Set Pink's direction"
417 |
418 | print "⏳ 🟦 Set Blue's x"
419 | watch *(float*)0x7fffc1c6600a
420 | commands
421 | set {float}0x7fffc1c6600a = 400
422 | delete $bpnum
423 | print "✅ 🟦 Set Blue's x"
424 |
425 | print "⏳ 🟦 Set Blue's y"
426 | watch *(float*)0x7fffc1c66022
427 | commands
428 | set {float}0x7fffc1c66022 = 234.5
429 | delete $bpnum
430 | print "✅ 🟦 Set Blue's y"
431 |
432 | print "⏳ 🟦 Set Blue's direction"
433 | watch *(float*)0x7fffc1c6603a
434 | commands
435 | set {float}0x7fffc1c6603a = 0.01
436 | delete $bpnum
437 | print "✅ 🟦 Set Blue's direction"
438 | exit
439 | continue
440 | end
441 |
442 | continue
443 | end
444 |
445 | continue
446 | end
447 |
448 | continue
449 | end
450 |
451 | continue
452 | end
453 |
454 | continue
455 | end
456 |
457 | continue
458 | end
459 |
460 | continue
461 | end
462 |
463 | continue
464 | end
465 |
466 | continue
467 | end
468 |
469 | continue
470 | end
471 |
472 | continue
473 | end
474 |
475 | continue
476 | end
477 |
478 | continue
479 | end
480 |
481 | continue
482 | end
483 |
484 | continue
485 | end
486 |
487 | continue
488 | end
489 |
490 | continue
491 | end
492 |
493 | continue
494 | end
495 |
496 | continue
497 | end
498 |
499 | continue
500 | """
501 | }
502 |
--------------------------------------------------------------------------------
/src/Game.elm:
--------------------------------------------------------------------------------
1 | module Game exposing
2 | ( ActiveGameState(..)
3 | , GameState(..)
4 | , LiveOrReplay(..)
5 | , PausedOrNot(..)
6 | , SpawnState
7 | , TickResult(..)
8 | , firstUpdateTick
9 | , getActiveRound
10 | , getCurrentRound
11 | , modifyMidRoundState
12 | , prepareLiveRound
13 | , prepareReplayRound
14 | , prepareRoundFromKnownInitialState
15 | , reactToTick
16 | , recordUserInteraction
17 | , tickResultToGameState
18 | )
19 |
20 | import Canvas exposing (bodyDrawingCmd, headDrawingCmd)
21 | import Color exposing (Color)
22 | import Config exposing (Config, KurveConfig)
23 | import Dialog
24 | import Players exposing (ParticipatingPlayers)
25 | import Random
26 | import Round exposing (Kurves, Round, RoundInitialState, modifyAlive, modifyDead, roundIsOver)
27 | import Set exposing (Set)
28 | import Spawn exposing (generateHoleSize, generateHoleSpacing, generateKurves)
29 | import Thickness exposing (theThickness)
30 | import Turning exposing (computeAngleChange, computeTurningState, turningStateFromHistory)
31 | import Types.Angle as Angle exposing (Angle)
32 | import Types.Distance as Distance exposing (Distance(..))
33 | import Types.FrameTime exposing (LeftoverFrameTime)
34 | import Types.Kurve as Kurve exposing (Kurve, UserInteraction(..), modifyReversedInteractions)
35 | import Types.Speed as Speed
36 | import Types.Tick as Tick exposing (Tick)
37 | import Types.Tickrate as Tickrate
38 | import Types.TurningState exposing (TurningState)
39 | import World exposing (DrawingPosition, Pixel, Position, distanceToTicks)
40 |
41 |
42 | type GameState
43 | = Active LiveOrReplay PausedOrNot ActiveGameState
44 | | RoundOver Round Dialog.State
45 |
46 |
47 | type PausedOrNot
48 | = Paused
49 | | NotPaused
50 |
51 |
52 | type ActiveGameState
53 | = Spawning SpawnState Round
54 | | Moving LeftoverFrameTime Tick Round
55 |
56 |
57 | type TickResult a
58 | = RoundKeepsGoing a
59 | | RoundEnds Round
60 |
61 |
62 | getCurrentRound : GameState -> Round
63 | getCurrentRound gameState =
64 | case gameState of
65 | Active _ _ activeGameState ->
66 | getActiveRound activeGameState
67 |
68 | RoundOver round _ ->
69 | round
70 |
71 |
72 | getActiveRound : ActiveGameState -> Round
73 | getActiveRound activeGameState =
74 | case activeGameState of
75 | Spawning _ round ->
76 | round
77 |
78 | Moving _ _ round ->
79 | round
80 |
81 |
82 | modifyMidRoundState : (Round -> Round) -> GameState -> GameState
83 | modifyMidRoundState f gameState =
84 | case gameState of
85 | Active p liveOrReplay (Moving t leftoverFrameTime midRoundState) ->
86 | Active p liveOrReplay <| Moving t leftoverFrameTime <| f midRoundState
87 |
88 | Active p liveOrReplay (Spawning s midRoundState) ->
89 | Active p liveOrReplay <| Spawning s <| f midRoundState
90 |
91 | _ ->
92 | gameState
93 |
94 |
95 | type LiveOrReplay
96 | = Live
97 | | Replay
98 |
99 |
100 | type alias SpawnState =
101 | { kurvesLeft : List Kurve
102 | , ticksLeft : Int
103 | }
104 |
105 |
106 | firstUpdateTick : Tick
107 | firstUpdateTick =
108 | -- Any buttons already pressed at round start are treated as having been pressed right before this tick.
109 | Tick.succ Tick.genesis
110 |
111 |
112 | prepareLiveRound : Config -> Random.Seed -> ParticipatingPlayers -> Set String -> Round
113 | prepareLiveRound config seed players pressedButtons =
114 | let
115 | recordInitialInteractions : List Kurve -> List Kurve
116 | recordInitialInteractions =
117 | List.map (recordUserInteraction pressedButtons firstUpdateTick)
118 |
119 | ( theKurves, seedAfterSpawn ) =
120 | Random.step (generateKurves config players) seed |> Tuple.mapFirst recordInitialInteractions
121 | in
122 | prepareRoundFromKnownInitialState { seedAfterSpawn = seedAfterSpawn, spawnedKurves = theKurves }
123 |
124 |
125 | prepareReplayRound : RoundInitialState -> Round
126 | prepareReplayRound initialState =
127 | prepareRoundFromKnownInitialState initialState
128 |
129 |
130 | prepareRoundFromKnownInitialState : RoundInitialState -> Round
131 | prepareRoundFromKnownInitialState initialState =
132 | let
133 | theKurves : List Kurve
134 | theKurves =
135 | initialState.spawnedKurves
136 |
137 | round : Round
138 | round =
139 | { kurves = { alive = theKurves, dead = [] }
140 | , occupiedPixels = List.foldl (.state >> .position >> World.drawingPosition >> World.pixelsToOccupy >> Set.union) Set.empty theKurves
141 | , initialState = initialState
142 | , seed = initialState.seedAfterSpawn
143 | }
144 | in
145 | round
146 |
147 |
148 | reactToTick : Config -> Tick -> Round -> ( TickResult Round, Cmd msg )
149 | reactToTick config tick currentRound =
150 | let
151 | ( newKurvesGenerator, newOccupiedPixels, newColoredDrawingPositions ) =
152 | List.foldr
153 | (checkIndividualKurve config tick)
154 | ( Random.constant
155 | { alive = [] -- We start with the empty list because the new one we'll create may not include all the Kurves from the old one.
156 | , dead = currentRound.kurves.dead -- Dead Kurves, however, will not spring to life again.
157 | }
158 | , currentRound.occupiedPixels
159 | , []
160 | )
161 | currentRound.kurves.alive
162 |
163 | ( newKurves, newSeed ) =
164 | Random.step newKurvesGenerator currentRound.seed
165 |
166 | newCurrentRound : Round
167 | newCurrentRound =
168 | { kurves = newKurves
169 | , occupiedPixels = newOccupiedPixels
170 | , initialState = currentRound.initialState
171 | , seed = newSeed
172 | }
173 |
174 | tickResult : TickResult Round
175 | tickResult =
176 | if roundIsOver newKurves then
177 | RoundEnds newCurrentRound
178 |
179 | else
180 | RoundKeepsGoing newCurrentRound
181 | in
182 | ( tickResult
183 | , [ headDrawingCmd newKurves.alive
184 | , bodyDrawingCmd newColoredDrawingPositions
185 | ]
186 | |> Cmd.batch
187 | )
188 |
189 |
190 | tickResultToGameState : LiveOrReplay -> TickResult ( LeftoverFrameTime, Tick, Round ) -> GameState
191 | tickResultToGameState liveOrReplay tickResult =
192 | case tickResult of
193 | RoundKeepsGoing ( leftoverFrameTime, tick, midRoundState ) ->
194 | Active liveOrReplay NotPaused (Moving leftoverFrameTime tick midRoundState)
195 |
196 | RoundEnds finishedRound ->
197 | RoundOver finishedRound Dialog.NotOpen
198 |
199 |
200 | {-| Takes the distance between the _edges_ of two drawn squares and returns the distance between their _centers_.
201 | -}
202 | computeDistanceBetweenCenters : Distance -> Distance
203 | computeDistanceBetweenCenters distanceBetweenEdges =
204 | Distance <| Distance.toFloat distanceBetweenEdges + theThickness
205 |
206 |
207 | checkIndividualKurve :
208 | Config
209 | -> Tick
210 | -> Kurve
211 | -> ( Random.Generator Kurves, Set World.Pixel, List ( Color, DrawingPosition ) )
212 | ->
213 | ( Random.Generator Kurves
214 | , Set World.Pixel
215 | , List ( Color, DrawingPosition )
216 | )
217 | checkIndividualKurve config tick kurve ( checkedKurvesGenerator, occupiedPixels, coloredDrawingPositions ) =
218 | let
219 | turningState : TurningState
220 | turningState =
221 | turningStateFromHistory tick kurve
222 |
223 | ( newKurveDrawingPositions, checkedKurveGenerator, fate ) =
224 | updateKurve config turningState occupiedPixels kurve
225 |
226 | occupiedPixelsAfterCheckingThisKurve : Set Pixel
227 | occupiedPixelsAfterCheckingThisKurve =
228 | List.foldl
229 | (World.pixelsToOccupy >> Set.union)
230 | occupiedPixels
231 | newKurveDrawingPositions
232 |
233 | coloredDrawingPositionsAfterCheckingThisKurve : List ( Color, DrawingPosition )
234 | coloredDrawingPositionsAfterCheckingThisKurve =
235 | coloredDrawingPositions ++ List.map (Tuple.pair kurve.color) newKurveDrawingPositions
236 |
237 | kurvesAfterCheckingThisKurve : Kurve -> Kurves -> Kurves
238 | kurvesAfterCheckingThisKurve checkedKurve =
239 | case fate of
240 | Kurve.Dies ->
241 | modifyDead ((::) checkedKurve)
242 |
243 | Kurve.Lives ->
244 | modifyAlive ((::) checkedKurve)
245 | in
246 | ( Random.map2 kurvesAfterCheckingThisKurve checkedKurveGenerator checkedKurvesGenerator
247 | , occupiedPixelsAfterCheckingThisKurve
248 | , coloredDrawingPositionsAfterCheckingThisKurve
249 | )
250 |
251 |
252 | evaluateMove : Config -> Position -> Position -> Set Pixel -> Kurve.HoleStatus -> ( List DrawingPosition, Kurve.Fate )
253 | evaluateMove config startingPoint desiredEndPoint occupiedPixels holeStatus =
254 | let
255 | startingPointAsDrawingPosition : DrawingPosition
256 | startingPointAsDrawingPosition =
257 | World.drawingPosition startingPoint
258 |
259 | positionsToCheck : List DrawingPosition
260 | positionsToCheck =
261 | World.desiredDrawingPositions startingPoint desiredEndPoint
262 |
263 | checkPositions : List DrawingPosition -> DrawingPosition -> List DrawingPosition -> ( List DrawingPosition, Kurve.Fate )
264 | checkPositions checked lastChecked remaining =
265 | case remaining of
266 | [] ->
267 | ( checked, Kurve.Lives )
268 |
269 | current :: rest ->
270 | let
271 | theHitbox : Set Pixel
272 | theHitbox =
273 | World.hitbox lastChecked current
274 |
275 | crashesIntoWall : Bool
276 | crashesIntoWall =
277 | List.member True
278 | [ current.leftEdge < 0
279 | , current.topEdge < 0
280 | , current.leftEdge > config.world.width - theThickness
281 | , current.topEdge > config.world.height - theThickness
282 | ]
283 |
284 | crashesIntoKurve : Bool
285 | crashesIntoKurve =
286 | not <| Set.isEmpty <| Set.intersect theHitbox occupiedPixels
287 |
288 | dies : Bool
289 | dies =
290 | crashesIntoWall || crashesIntoKurve
291 | in
292 | if dies then
293 | ( checked, Kurve.Dies )
294 |
295 | else
296 | checkPositions (current :: checked) current rest
297 |
298 | isHoly : Bool
299 | isHoly =
300 | case holeStatus of
301 | Kurve.Holy _ ->
302 | True
303 |
304 | Kurve.Unholy _ ->
305 | False
306 |
307 | ( checkedPositionsReversed, evaluatedStatus ) =
308 | checkPositions [] startingPointAsDrawingPosition positionsToCheck
309 |
310 | positionsToDraw : List DrawingPosition
311 | positionsToDraw =
312 | if isHoly then
313 | case evaluatedStatus of
314 | Kurve.Lives ->
315 | []
316 |
317 | Kurve.Dies ->
318 | -- The Kurve's head must always be drawn when they die, even if they are in the middle of a hole.
319 | -- If the Kurve couldn't draw at all in this tick, then the last position where the Kurve could draw before dying (and therefore the one to draw to represent the Kurve's death) is this tick's starting point.
320 | -- Otherwise, the last position where the Kurve could draw is the last checked position before death occurred.
321 | List.singleton <| Maybe.withDefault startingPointAsDrawingPosition <| List.head checkedPositionsReversed
322 |
323 | else
324 | checkedPositionsReversed
325 | in
326 | ( positionsToDraw |> List.reverse, evaluatedStatus )
327 |
328 |
329 | updateKurve : Config -> TurningState -> Set Pixel -> Kurve -> ( List DrawingPosition, Random.Generator Kurve, Kurve.Fate )
330 | updateKurve config turningState occupiedPixels kurve =
331 | let
332 | distanceTraveledSinceLastTick : Float
333 | distanceTraveledSinceLastTick =
334 | Speed.toFloat config.kurves.speed / Tickrate.toFloat config.kurves.tickrate
335 |
336 | newDirection : Angle
337 | newDirection =
338 | Angle.add kurve.state.direction <| computeAngleChange config.kurves turningState
339 |
340 | ( x, y ) =
341 | kurve.state.position
342 |
343 | newPosition : Position
344 | newPosition =
345 | -- This is based on how the original MS-DOS game works:
346 | --
347 | -- * The coordinate system is "flipped" (wrt standard math) such that the Y axis points downwards.
348 | -- * Directions are zeroed around down, not right as in standard math.
349 | --
350 | ( x + distanceTraveledSinceLastTick * Angle.sin newDirection
351 | , y + distanceTraveledSinceLastTick * Angle.cos newDirection
352 | )
353 |
354 | ( confirmedDrawingPositions, fate ) =
355 | evaluateMove
356 | config
357 | kurve.state.position
358 | newPosition
359 | occupiedPixels
360 | kurve.state.holeStatus
361 |
362 | newHoleStatusGenerator : Random.Generator Kurve.HoleStatus
363 | newHoleStatusGenerator =
364 | updateHoleStatus config.kurves kurve.state.holeStatus
365 |
366 | newKurveState : Random.Generator Kurve.State
367 | newKurveState =
368 | newHoleStatusGenerator
369 | |> Random.map
370 | (\newHoleStatus ->
371 | { position = newPosition
372 | , direction = newDirection
373 | , holeStatus = newHoleStatus
374 | }
375 | )
376 |
377 | newKurve : Random.Generator Kurve
378 | newKurve =
379 | newKurveState |> Random.map (\s -> { kurve | state = s })
380 | in
381 | ( confirmedDrawingPositions
382 | , newKurve
383 | , fate
384 | )
385 |
386 |
387 | updateHoleStatus : KurveConfig -> Kurve.HoleStatus -> Random.Generator Kurve.HoleStatus
388 | updateHoleStatus kurveConfig holeStatus =
389 | case holeStatus of
390 | Kurve.Holy 0 ->
391 | generateHoleSpacing kurveConfig.holes |> Random.map (distanceToTicks kurveConfig.tickrate kurveConfig.speed >> Kurve.Unholy)
392 |
393 | Kurve.Holy ticksLeft ->
394 | Random.constant <| Kurve.Holy (ticksLeft - 1)
395 |
396 | Kurve.Unholy 0 ->
397 | generateHoleSize kurveConfig.holes |> Random.map (computeDistanceBetweenCenters >> distanceToTicks kurveConfig.tickrate kurveConfig.speed >> Kurve.Holy)
398 |
399 | Kurve.Unholy ticksLeft ->
400 | Random.constant <| Kurve.Unholy (ticksLeft - 1)
401 |
402 |
403 | recordUserInteraction : Set String -> Tick -> Kurve -> Kurve
404 | recordUserInteraction pressedButtons nextTick kurve =
405 | let
406 | newTurningState : TurningState
407 | newTurningState =
408 | computeTurningState pressedButtons kurve
409 | in
410 | modifyReversedInteractions ((::) (HappenedBefore nextTick newTurningState)) kurve
411 |
--------------------------------------------------------------------------------
/src/Main.elm:
--------------------------------------------------------------------------------
1 | port module Main exposing (Model, Msg(..), main)
2 |
3 | import App exposing (AppState(..), modifyGameState)
4 | import Browser
5 | import Browser.Events
6 | import Canvas exposing (clearEverything, drawSpawnIfAndOnlyIf)
7 | import Config exposing (Config)
8 | import Dialog
9 | import GUI.ConfirmQuitDialog exposing (confirmQuitDialog)
10 | import GUI.EndScreen exposing (endScreen)
11 | import GUI.Lobby exposing (lobby)
12 | import GUI.Scoreboard exposing (scoreboard)
13 | import GUI.SplashScreen exposing (splashScreen)
14 | import GUI.TextOverlay exposing (textOverlay)
15 | import Game
16 | exposing
17 | ( ActiveGameState(..)
18 | , GameState(..)
19 | , LiveOrReplay(..)
20 | , PausedOrNot(..)
21 | , SpawnState
22 | , firstUpdateTick
23 | , getActiveRound
24 | , modifyMidRoundState
25 | , prepareLiveRound
26 | , prepareReplayRound
27 | , recordUserInteraction
28 | , tickResultToGameState
29 | )
30 | import Html exposing (Html, canvas, div)
31 | import Html.Attributes as Attr
32 | import Input exposing (Button(..), ButtonDirection(..), inputSubscriptions, updatePressedButtons)
33 | import MainLoop
34 | import Menu exposing (MenuState(..))
35 | import Players
36 | exposing
37 | ( AllPlayers
38 | , atLeastOneIsParticipating
39 | , everyoneLeaves
40 | , handlePlayerJoiningOrLeaving
41 | , includeResultsFrom
42 | , initialPlayers
43 | , participating
44 | )
45 | import Random
46 | import Round exposing (Round, initialStateForReplaying, modifyAlive, modifyKurves)
47 | import Set exposing (Set)
48 | import Time
49 | import Types.FrameTime exposing (FrameTime, LeftoverFrameTime)
50 | import Types.Tick as Tick exposing (Tick)
51 | import Util exposing (isEven)
52 |
53 |
54 | type alias Model =
55 | { pressedButtons : Set String
56 | , appState : AppState
57 | , config : Config
58 | , players : AllPlayers
59 | }
60 |
61 |
62 | port focusLost : (() -> msg) -> Sub msg
63 |
64 |
65 | init : () -> ( Model, Cmd Msg )
66 | init _ =
67 | ( { pressedButtons = Set.empty
68 | , appState = InMenu SplashScreen (Random.initialSeed 1337)
69 | , config = Config.default
70 | , players = initialPlayers
71 | }
72 | , Cmd.none
73 | )
74 |
75 |
76 | startRound : LiveOrReplay -> Model -> Round -> ( Model, Cmd msg )
77 | startRound liveOrReplay model midRoundState =
78 | let
79 | ( gameState, cmd ) =
80 | newRoundGameStateAndCmd model.config liveOrReplay midRoundState
81 | in
82 | ( { model | appState = InGame gameState }, cmd )
83 |
84 |
85 | newRoundGameStateAndCmd : Config -> LiveOrReplay -> Round -> ( GameState, Cmd msg )
86 | newRoundGameStateAndCmd config liveOrReplay plannedMidRoundState =
87 | ( Active liveOrReplay NotPaused <|
88 | Spawning
89 | { kurvesLeft = plannedMidRoundState |> .kurves |> .alive
90 | , ticksLeft = config.spawn.numberOfFlickerTicks
91 | }
92 | plannedMidRoundState
93 | , clearEverything config.world
94 | )
95 |
96 |
97 | type Msg
98 | = SpawnTick LiveOrReplay SpawnState Round
99 | | AnimationFrame
100 | LiveOrReplay
101 | { delta : FrameTime
102 | , leftoverTimeFromPreviousFrame : LeftoverFrameTime
103 | , lastTick : Tick
104 | }
105 | Round
106 | | ButtonUsed ButtonDirection Button
107 | | DialogChoiceMade Dialog.Option
108 | | FocusLost
109 |
110 |
111 | stepSpawnState : Config -> SpawnState -> ( Maybe SpawnState, Cmd msg )
112 | stepSpawnState config { kurvesLeft, ticksLeft } =
113 | case kurvesLeft of
114 | [] ->
115 | -- All Kurves have spawned.
116 | ( Nothing, Cmd.none )
117 |
118 | spawning :: waiting ->
119 | let
120 | newSpawnState : SpawnState
121 | newSpawnState =
122 | if ticksLeft == 0 then
123 | { kurvesLeft = waiting, ticksLeft = config.spawn.numberOfFlickerTicks }
124 |
125 | else
126 | { kurvesLeft = spawning :: waiting, ticksLeft = ticksLeft - 1 }
127 | in
128 | ( Just newSpawnState, drawSpawnIfAndOnlyIf (isEven ticksLeft) spawning )
129 |
130 |
131 | update : Msg -> Model -> ( Model, Cmd Msg )
132 | update msg ({ config, pressedButtons } as model) =
133 | case msg of
134 | FocusLost ->
135 | case model.appState of
136 | InGame (Active liveOrReplay _ s) ->
137 | case liveOrReplay of
138 | Live ->
139 | ( { model | appState = InGame (Active liveOrReplay Paused s) }, Cmd.none )
140 |
141 | Replay ->
142 | -- Not important to pause on focus lost when replaying.
143 | ( model, Cmd.none )
144 |
145 | _ ->
146 | ( model, Cmd.none )
147 |
148 | SpawnTick liveOrReplay spawnState plannedMidRoundState ->
149 | let
150 | ( maybeSpawnState, cmd ) =
151 | stepSpawnState config spawnState
152 |
153 | activeGameState : ActiveGameState
154 | activeGameState =
155 | case maybeSpawnState of
156 | Just newSpawnState ->
157 | Spawning newSpawnState plannedMidRoundState
158 |
159 | Nothing ->
160 | Moving MainLoop.noLeftoverFrameTime Tick.genesis plannedMidRoundState
161 | in
162 | ( { model | appState = InGame <| Active liveOrReplay NotPaused activeGameState }
163 | , cmd
164 | )
165 |
166 | AnimationFrame liveOrReplay { delta, leftoverTimeFromPreviousFrame, lastTick } midRoundState ->
167 | let
168 | ( tickResult, cmd ) =
169 | MainLoop.consumeAnimationFrame config delta leftoverTimeFromPreviousFrame lastTick midRoundState
170 | in
171 | ( { model | appState = InGame (tickResultToGameState liveOrReplay tickResult) }
172 | , cmd
173 | )
174 |
175 | ButtonUsed Down button ->
176 | case model.appState of
177 | InMenu SplashScreen seed ->
178 | case button of
179 | Key "Space" ->
180 | goToLobby seed model
181 |
182 | _ ->
183 | ( handleUserInteraction Down button model, Cmd.none )
184 |
185 | InMenu Lobby seed ->
186 | case ( button, atLeastOneIsParticipating model.players ) of
187 | ( Key "Space", True ) ->
188 | startRound Live model <| prepareLiveRound config seed (participating model.players) pressedButtons
189 |
190 | _ ->
191 | ( handleUserInteraction Down button { model | players = handlePlayerJoiningOrLeaving button model.players }, Cmd.none )
192 |
193 | InGame (RoundOver finishedRound dialogState) ->
194 | case dialogState of
195 | Dialog.NotOpen ->
196 | let
197 | newModel : Model
198 | newModel =
199 | { model | players = includeResultsFrom finishedRound model.players }
200 |
201 | gameIsOver : Bool
202 | gameIsOver =
203 | config.game.isGameOver (participating newModel.players)
204 | in
205 | case button of
206 | Key "KeyR" ->
207 | startRound Replay model <| prepareReplayRound (initialStateForReplaying finishedRound)
208 |
209 | Key "Escape" ->
210 | -- Quitting after the final round is not allowed in the original game.
211 | if not gameIsOver then
212 | ( { model | appState = InGame (RoundOver finishedRound (Dialog.Open Dialog.Cancel)) }, Cmd.none )
213 |
214 | else
215 | ( handleUserInteraction Down button model, Cmd.none )
216 |
217 | Key "Space" ->
218 | if gameIsOver then
219 | gameOver finishedRound.seed newModel
220 |
221 | else
222 | startRound Live newModel <| prepareLiveRound config finishedRound.seed (participating newModel.players) pressedButtons
223 |
224 | _ ->
225 | ( handleUserInteraction Down button model, Cmd.none )
226 |
227 | Dialog.Open selectedOption ->
228 | let
229 | cancel : ( Model, Cmd msg )
230 | cancel =
231 | ( { model | appState = InGame (RoundOver finishedRound Dialog.NotOpen) }, Cmd.none )
232 |
233 | confirm : ( Model, Cmd msg )
234 | confirm =
235 | goToLobby finishedRound.seed model
236 |
237 | select : Dialog.Option -> ( Model, Cmd msg )
238 | select option =
239 | ( { model | appState = InGame (RoundOver finishedRound (Dialog.Open option)) }, Cmd.none )
240 | in
241 | case ( button, selectedOption ) of
242 | ( Key "Escape", _ ) ->
243 | cancel
244 |
245 | ( Key "Enter", Dialog.Cancel ) ->
246 | cancel
247 |
248 | ( Key "Space", Dialog.Cancel ) ->
249 | cancel
250 |
251 | ( Key "Enter", Dialog.Confirm ) ->
252 | confirm
253 |
254 | ( Key "Space", Dialog.Confirm ) ->
255 | confirm
256 |
257 | ( Key "ArrowLeft", _ ) ->
258 | select Dialog.Confirm
259 |
260 | ( Key "ArrowRight", _ ) ->
261 | select Dialog.Cancel
262 |
263 | ( Key "Tab", _ ) ->
264 | let
265 | isShift : Bool
266 | isShift =
267 | Set.member "ShiftLeft" model.pressedButtons || Set.member "ShiftRight" model.pressedButtons
268 | in
269 | select <|
270 | if isShift then
271 | Dialog.Confirm
272 |
273 | else
274 | Dialog.Cancel
275 |
276 | _ ->
277 | ( handleUserInteraction Down button model, Cmd.none )
278 |
279 | InGame (Active liveOrReplay Paused s) ->
280 | case button of
281 | Key "Space" ->
282 | ( { model | appState = InGame (Active liveOrReplay NotPaused s) }, Cmd.none )
283 |
284 | _ ->
285 | ( handleUserInteraction Down button model, Cmd.none )
286 |
287 | InGame (Active Live NotPaused _) ->
288 | ( handleUserInteraction Down button model, Cmd.none )
289 |
290 | InGame (Active Replay NotPaused s) ->
291 | case button of
292 | Key "ArrowRight" ->
293 | case s of
294 | Spawning _ _ ->
295 | ( model, Cmd.none )
296 |
297 | Moving leftoverTimeFromPreviousFrame lastTick midRoundState ->
298 | let
299 | ( tickResult, cmd ) =
300 | MainLoop.consumeAnimationFrame config (toFloat config.replay.skipStepInMs) leftoverTimeFromPreviousFrame lastTick midRoundState
301 | in
302 | ( { model | appState = InGame (tickResultToGameState Replay tickResult) }
303 | , cmd
304 | )
305 |
306 | Key "KeyR" ->
307 | startRound Replay model <| prepareReplayRound (initialStateForReplaying (getActiveRound s))
308 |
309 | Key "Space" ->
310 | ( { model | appState = InGame (Active Replay Paused s) }, Cmd.none )
311 |
312 | _ ->
313 | ( handleUserInteraction Down button model, Cmd.none )
314 |
315 | InMenu GameOver seed ->
316 | case button of
317 | Key "Space" ->
318 | goToLobby seed model
319 |
320 | _ ->
321 | ( handleUserInteraction Down button model, Cmd.none )
322 |
323 | ButtonUsed Up key ->
324 | ( handleUserInteraction Up key model, Cmd.none )
325 |
326 | DialogChoiceMade option ->
327 | case model.appState of
328 | InGame (RoundOver finishedRound (Dialog.Open _)) ->
329 | case option of
330 | Dialog.Confirm ->
331 | goToLobby finishedRound.seed model
332 |
333 | Dialog.Cancel ->
334 | ( { model | appState = InGame (RoundOver finishedRound Dialog.NotOpen) }, Cmd.none )
335 |
336 | _ ->
337 | -- Not expected to ever happen.
338 | ( model, Cmd.none )
339 |
340 |
341 | gameOver : Random.Seed -> Model -> ( Model, Cmd msg )
342 | gameOver seed model =
343 | ( { model | appState = InMenu GameOver seed }, Cmd.none )
344 |
345 |
346 | goToLobby : Random.Seed -> Model -> ( Model, Cmd msg )
347 | goToLobby seed model =
348 | ( { model | appState = InMenu Lobby seed, players = everyoneLeaves model.players }, Cmd.none )
349 |
350 |
351 | handleUserInteraction : ButtonDirection -> Button -> Model -> Model
352 | handleUserInteraction direction button model =
353 | let
354 | newPressedButtons : Set String
355 | newPressedButtons =
356 | updatePressedButtons direction button model.pressedButtons
357 |
358 | howToModifyRound : Round -> Round
359 | howToModifyRound =
360 | case model.appState of
361 | InGame (Active Live _ (Spawning _ _)) ->
362 | recordInteractionBefore firstUpdateTick
363 |
364 | InGame (Active Live _ (Moving _ lastTick _)) ->
365 | recordInteractionBefore (Tick.succ lastTick)
366 |
367 | _ ->
368 | identity
369 |
370 | recordInteractionBefore : Tick -> Round -> Round
371 | recordInteractionBefore tick =
372 | modifyKurves <| modifyAlive <| List.map (recordUserInteraction newPressedButtons tick)
373 | in
374 | { model
375 | | pressedButtons = newPressedButtons
376 | , appState = modifyGameState (modifyMidRoundState howToModifyRound) model.appState
377 | }
378 |
379 |
380 | subscriptions : Model -> Sub Msg
381 | subscriptions model =
382 | Sub.batch <|
383 | (case model.appState of
384 | InMenu SplashScreen _ ->
385 | Sub.none
386 |
387 | InMenu Lobby _ ->
388 | Sub.none
389 |
390 | InGame (Active liveOrReplay NotPaused (Spawning spawnState plannedMidRoundState)) ->
391 | Time.every (1000 / model.config.spawn.flickerTicksPerSecond) (always <| SpawnTick liveOrReplay spawnState plannedMidRoundState)
392 |
393 | InGame (Active liveOrReplay NotPaused (Moving leftoverTimeFromPreviousFrame lastTick midRoundState)) ->
394 | Browser.Events.onAnimationFrameDelta
395 | (\delta ->
396 | AnimationFrame
397 | liveOrReplay
398 | { delta = delta
399 | , leftoverTimeFromPreviousFrame = leftoverTimeFromPreviousFrame
400 | , lastTick = lastTick
401 | }
402 | midRoundState
403 | )
404 |
405 | InGame (Active _ Paused _) ->
406 | Sub.none
407 |
408 | InGame (RoundOver _ _) ->
409 | Sub.none
410 |
411 | InMenu GameOver _ ->
412 | Sub.none
413 | )
414 | :: focusLost (always FocusLost)
415 | :: inputSubscriptions ButtonUsed
416 |
417 |
418 | view : Model -> Html Msg
419 | view model =
420 | case model.appState of
421 | InMenu Lobby _ ->
422 | elmRoot [] [ lobby model.players ]
423 |
424 | InMenu GameOver _ ->
425 | elmRoot [] [ endScreen model.players ]
426 |
427 | InMenu SplashScreen _ ->
428 | elmRoot [] [ splashScreen ]
429 |
430 | InGame gameState ->
431 | elmRoot
432 | [ Attr.class "in-game"
433 | ]
434 | [ div
435 | [ Attr.id "wrapper"
436 | ]
437 | [ div
438 | [ Attr.id "border"
439 | ]
440 | [ canvas
441 | [ Attr.id "canvas_main"
442 | , Attr.width 559
443 | , Attr.height 480
444 | ]
445 | []
446 | , canvas
447 | [ Attr.id "canvas_overlay"
448 | , Attr.width 559
449 | , Attr.height 480
450 | , Attr.class "overlay"
451 | ]
452 | []
453 | , textOverlay gameState
454 | , confirmQuitDialog DialogChoiceMade gameState
455 | ]
456 | , scoreboard gameState model.players
457 | ]
458 | ]
459 |
460 |
461 | elmRoot : List (Html.Attribute msg) -> List (Html msg) -> Html msg
462 | elmRoot attrs =
463 | div (Attr.id "elm-root" :: attrs)
464 |
465 |
466 | main : Program () Model Msg
467 | main =
468 | Browser.element
469 | { init = init
470 | , update = update
471 | , subscriptions = subscriptions
472 | , view = view
473 | }
474 |
--------------------------------------------------------------------------------