├── 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 | --------------------------------------------------------------------------------