├── extras ├── README.md ├── windows │ ├── README.txt │ └── LICENSE.txt ├── macos-dmg │ ├── README.txt │ └── LICENSE.txt └── unix │ ├── README.md │ └── LICENSE ├── web ├── index.js ├── .gitignore ├── static_web │ ├── PxPlus_IBM_CGA-with-quadrant-blocks.ttf │ └── PxPlus_IBM_CGAthin-with-quadrant-blocks.ttf ├── Cargo.toml ├── README.md ├── package.json ├── index.html ├── src │ └── lib.rs └── webpack.config.js ├── app ├── src │ ├── images │ │ ├── boat.bin │ │ ├── beast.bin │ │ ├── ghost.bin │ │ ├── grave.bin │ │ ├── ocean.bin │ │ ├── thief.bin │ │ ├── innkeeper.bin │ │ ├── physicist.bin │ │ ├── soldier.bin │ │ ├── surgeon.bin │ │ ├── surveyor.bin │ │ └── townsfolk1.bin │ ├── colour.rs │ ├── mist.rs │ ├── lib.rs │ ├── controls.rs │ ├── audio.rs │ ├── image.rs │ ├── text.rs │ ├── game_instance.rs │ └── game_loop.rs └── Cargo.toml ├── .gitignore ├── ggez ├── src │ ├── fonts │ │ ├── PxPlus_IBM_CGA-with-quadrant-blocks.ttf │ │ └── PxPlus_IBM_CGAthin-with-quadrant-blocks.ttf │ └── main.rs └── Cargo.toml ├── sdl2 ├── src │ ├── fonts │ │ ├── PxPlus_IBM_CGA-with-quadrant-blocks.ttf │ │ └── PxPlus_IBM_CGAthin-with-quadrant-blocks.ttf │ └── main.rs └── Cargo.toml ├── wgpu ├── src │ ├── fonts │ │ ├── PxPlus_IBM_CGA-with-quadrant-blocks.ttf │ │ └── PxPlus_IBM_CGAthin-with-quadrant-blocks.ttf │ └── main.rs └── Cargo.toml ├── image-to-text ├── Cargo.toml └── src │ └── main.rs ├── util ├── rational │ ├── Cargo.toml │ └── src │ │ └── lib.rs ├── rand-range │ ├── Cargo.toml │ └── src │ │ └── lib.rs └── vector │ ├── Cargo.toml │ └── src │ └── lib.rs ├── game ├── src │ ├── island.txt │ ├── world │ │ ├── spatial.rs │ │ ├── mod.rs │ │ ├── spawn.rs │ │ └── data.rs │ ├── witness.rs │ └── terrain.rs └── Cargo.toml ├── shell.nix ├── ansi-terminal ├── Cargo.toml └── src │ └── main.rs ├── native ├── Cargo.toml └── src │ └── lib.rs ├── Cargo.toml ├── scripts ├── make_zip_web.sh ├── build_and_upload_web_version.sh ├── make_dmg_macos.sh ├── macos_run_app.sh └── make_archives_unix.sh ├── procgen ├── Cargo.toml ├── examples │ └── run.rs └── src │ └── rooms_and_corridors.rs ├── README.md ├── LICENSE ├── .github └── workflows │ ├── test.yml │ └── release.yml ├── flake.nix └── flake.lock /extras/README.md: -------------------------------------------------------------------------------- 1 | # Extra files to include in packages 2 | -------------------------------------------------------------------------------- /web/index.js: -------------------------------------------------------------------------------- 1 | import('./pkg/index').catch(console.error) 2 | -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | **/*.rs.bk 3 | Cargo.lock 4 | node_modules 5 | pkg 6 | dist 7 | -------------------------------------------------------------------------------- /app/src/images/boat.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gridbugs/boat-journey/HEAD/app/src/images/boat.bin -------------------------------------------------------------------------------- /app/src/images/beast.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gridbugs/boat-journey/HEAD/app/src/images/beast.bin -------------------------------------------------------------------------------- /app/src/images/ghost.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gridbugs/boat-journey/HEAD/app/src/images/ghost.bin -------------------------------------------------------------------------------- /app/src/images/grave.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gridbugs/boat-journey/HEAD/app/src/images/grave.bin -------------------------------------------------------------------------------- /app/src/images/ocean.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gridbugs/boat-journey/HEAD/app/src/images/ocean.bin -------------------------------------------------------------------------------- /app/src/images/thief.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gridbugs/boat-journey/HEAD/app/src/images/thief.bin -------------------------------------------------------------------------------- /app/src/images/innkeeper.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gridbugs/boat-journey/HEAD/app/src/images/innkeeper.bin -------------------------------------------------------------------------------- /app/src/images/physicist.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gridbugs/boat-journey/HEAD/app/src/images/physicist.bin -------------------------------------------------------------------------------- /app/src/images/soldier.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gridbugs/boat-journey/HEAD/app/src/images/soldier.bin -------------------------------------------------------------------------------- /app/src/images/surgeon.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gridbugs/boat-journey/HEAD/app/src/images/surgeon.bin -------------------------------------------------------------------------------- /app/src/images/surveyor.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gridbugs/boat-journey/HEAD/app/src/images/surveyor.bin -------------------------------------------------------------------------------- /app/src/images/townsfolk1.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gridbugs/boat-journey/HEAD/app/src/images/townsfolk1.bin -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/target/ 2 | **/*.rs.bk 3 | **/*.sw? 4 | **/*.wasm 5 | Cargo.lock 6 | .direnv 7 | .envrc 8 | *.zip 9 | *.dmg 10 | -------------------------------------------------------------------------------- /ggez/src/fonts/PxPlus_IBM_CGA-with-quadrant-blocks.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gridbugs/boat-journey/HEAD/ggez/src/fonts/PxPlus_IBM_CGA-with-quadrant-blocks.ttf -------------------------------------------------------------------------------- /sdl2/src/fonts/PxPlus_IBM_CGA-with-quadrant-blocks.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gridbugs/boat-journey/HEAD/sdl2/src/fonts/PxPlus_IBM_CGA-with-quadrant-blocks.ttf -------------------------------------------------------------------------------- /web/static_web/PxPlus_IBM_CGA-with-quadrant-blocks.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gridbugs/boat-journey/HEAD/web/static_web/PxPlus_IBM_CGA-with-quadrant-blocks.ttf -------------------------------------------------------------------------------- /wgpu/src/fonts/PxPlus_IBM_CGA-with-quadrant-blocks.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gridbugs/boat-journey/HEAD/wgpu/src/fonts/PxPlus_IBM_CGA-with-quadrant-blocks.ttf -------------------------------------------------------------------------------- /app/src/colour.rs: -------------------------------------------------------------------------------- 1 | use rgb_int::Rgb24; 2 | 3 | pub const MURKY_GREEN: Rgb24 = Rgb24::new(0, 0x40, 0x40); 4 | pub const MISTY_GREY: Rgb24 = Rgb24::new(0x1f, 0x26, 0x26); 5 | -------------------------------------------------------------------------------- /ggez/src/fonts/PxPlus_IBM_CGAthin-with-quadrant-blocks.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gridbugs/boat-journey/HEAD/ggez/src/fonts/PxPlus_IBM_CGAthin-with-quadrant-blocks.ttf -------------------------------------------------------------------------------- /sdl2/src/fonts/PxPlus_IBM_CGAthin-with-quadrant-blocks.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gridbugs/boat-journey/HEAD/sdl2/src/fonts/PxPlus_IBM_CGAthin-with-quadrant-blocks.ttf -------------------------------------------------------------------------------- /web/static_web/PxPlus_IBM_CGAthin-with-quadrant-blocks.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gridbugs/boat-journey/HEAD/web/static_web/PxPlus_IBM_CGAthin-with-quadrant-blocks.ttf -------------------------------------------------------------------------------- /wgpu/src/fonts/PxPlus_IBM_CGAthin-with-quadrant-blocks.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gridbugs/boat-journey/HEAD/wgpu/src/fonts/PxPlus_IBM_CGAthin-with-quadrant-blocks.ttf -------------------------------------------------------------------------------- /image-to-text/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "image-to-text" 3 | version = "0.1.0" 4 | authors = ["Stephen Sherratt "] 5 | edition = "2021" 6 | 7 | [dependencies] 8 | image = "0.24" 9 | meap = "0.5" 10 | -------------------------------------------------------------------------------- /extras/windows/README.txt: -------------------------------------------------------------------------------- 1 | # Boat Journey 2 | 3 | ## Package Contents 4 | 5 | - boat_journey.exe: Graphical version of the game 6 | - boat-journey-compatibility.exe: Graphical version of the game (slower but possibly more compatible) 7 | -------------------------------------------------------------------------------- /util/rational/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rational" 3 | version = "0.1.0" 4 | authors = ["Stephen Sherratt "] 5 | edition = "2021" 6 | 7 | [dependencies] 8 | serde = { version = "1.0", features = ["serde_derive"] } 9 | rand = "0.8" 10 | -------------------------------------------------------------------------------- /util/rand-range/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rand_range" 3 | version = "0.1.0" 4 | authors = ["Stephen Sherratt "] 5 | edition = "2021" 6 | 7 | [dependencies] 8 | serde = { version = "1.0", features = ["serde_derive"] } 9 | rand = "0.8" 10 | -------------------------------------------------------------------------------- /extras/macos-dmg/README.txt: -------------------------------------------------------------------------------- 1 | # Boat Journey 2 | 3 | ## Package Contents 4 | 5 | - BoatJourney.app: Graphical version of the game 6 | 7 | ## Notes 8 | 9 | You may have to right click the app and select "Open", then choose "Open" at 10 | the prompt in order to run this app. 11 | -------------------------------------------------------------------------------- /game/src/island.txt: -------------------------------------------------------------------------------- 1 | 2 | 3 | %%%%%%%% 4 | %%......%% 5 | ........%% 6 | ...####...%% 7 | ...+..#..%% 8 | ...#..#.%% 9 | ...#..#..% 10 | ...#..#.% 11 | %..####.%% 12 | %%........% 13 | %%%%........ 14 | ....... 15 | -------------------------------------------------------------------------------- /util/vector/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "vector" 3 | version = "0.1.0" 4 | authors = ["Stephen Sherratt "] 5 | edition = "2021" 6 | 7 | [dependencies] 8 | coord_2d = "0.3" 9 | rand_range = { path = "../rand-range" } 10 | rand = "0.8" 11 | serde = { version = "1.0", features = ["serde_derive"] } 12 | -------------------------------------------------------------------------------- /ggez/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "boat_journey_ggez" 3 | version = "0.1.0" 4 | authors = ["Stephen Sherratt "] 5 | edition = "2021" 6 | 7 | [dependencies] 8 | chargrid_ggez = "0.3" 9 | env_logger = "0.10" 10 | boat_journey_app = { path = "../app" } 11 | boat_journey_native = { path = "../native" } 12 | -------------------------------------------------------------------------------- /sdl2/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "boat_journey_sdl2" 3 | version = "0.1.0" 4 | authors = ["Stephen Sherratt "] 5 | edition = "2021" 6 | 7 | [dependencies] 8 | chargrid_sdl2 = "0.2" 9 | env_logger = "0.10" 10 | boat_journey_app = { path = "../app" } 11 | boat_journey_native = { path = "../native" } 12 | -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | (import ( 2 | let 3 | lock = builtins.fromJSON (builtins.readFile ./flake.lock); 4 | in fetchTarball { 5 | url = "https://github.com/edolstra/flake-compat/archive/${lock.nodes.flake-compat.locked.rev}.tar.gz"; 6 | sha256 = lock.nodes.flake-compat.locked.narHash; } 7 | ) { 8 | src = ./.; 9 | }).shellNix 10 | -------------------------------------------------------------------------------- /wgpu/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "boat_journey_wgpu" 3 | version = "0.1.0" 4 | authors = ["Stephen Sherratt "] 5 | edition = "2021" 6 | 7 | [dependencies] 8 | chargrid_wgpu = "0.3" 9 | env_logger = "0.10" 10 | boat_journey_app = { path = "../app", features = ["print_stdout"] } 11 | boat_journey_native = { path = "../native" } 12 | -------------------------------------------------------------------------------- /ansi-terminal/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "boat_journey_ansi_terminal" 3 | version = "0.1.0" 4 | authors = ["Stephen Sherratt "] 5 | edition = "2021" 6 | 7 | [dependencies] 8 | chargrid_ansi_terminal = "0.4" 9 | env_logger = "0.10" 10 | meap = "0.5" 11 | boat_journey_app = { path = "../app" } 12 | boat_journey_native = { path = "../native" } 13 | rand = "0.8" 14 | -------------------------------------------------------------------------------- /native/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "boat_journey_native" 3 | version = "0.1.0" 4 | authors = ["Stephen Sherratt "] 5 | edition = "2021" 6 | 7 | [dependencies] 8 | general_storage_file = "0.3" 9 | general_storage_static = { version = "0.3", features = ["file"] } 10 | log = "0.4" 11 | boat_journey_app = { path = "../app", features = ["native"] } 12 | meap = "0.5" 13 | -------------------------------------------------------------------------------- /util/rational/src/lib.rs: -------------------------------------------------------------------------------- 1 | use rand::Rng; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | #[derive(Debug, Clone, Copy, Serialize, Deserialize)] 5 | pub struct Rational { 6 | pub numerator: u32, 7 | pub denominator: u32, 8 | } 9 | 10 | impl Rational { 11 | pub fn roll(self, rng: &mut R) -> bool { 12 | rng.gen_range(0..self.denominator) < self.numerator 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | 3 | resolver = "2" # this is required for the wgpu frontend 4 | 5 | members = [ 6 | "image-to-text", 7 | "procgen", 8 | "game", 9 | "app", 10 | "native", 11 | "ansi-terminal", 12 | "web", 13 | "ggez", 14 | "wgpu", 15 | "sdl2", 16 | "util/vector", 17 | "util/rand-range", 18 | "util/rational", 19 | ] 20 | 21 | [profile.release] 22 | lto = true 23 | -------------------------------------------------------------------------------- /scripts/make_zip_web.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euxo pipefail 3 | 4 | BRANCH=$1 5 | NAME=boat-journey-web 6 | 7 | pushd web 8 | 9 | npm install 10 | 11 | NODE_OPTIONS=--openssl-legacy-provider npm run build-production 12 | 13 | TMP=$(mktemp -d) 14 | trap "rm -rf $TMP" EXIT 15 | 16 | rm -rf $NAME 17 | mkdir $NAME 18 | 19 | mv dist $NAME/$BRANCH 20 | 21 | zip -r $TMP/$NAME.zip $NAME 22 | rm -rf $NAME 23 | 24 | popd 25 | 26 | mv $TMP/$NAME.zip . 27 | -------------------------------------------------------------------------------- /game/src/world/spatial.rs: -------------------------------------------------------------------------------- 1 | spatial_table::declare_layers_module! { 2 | layers { 3 | floor: Floor, 4 | feature: Feature, 5 | character: Character, 6 | item: Item, 7 | boat: Boat, 8 | water: Water, 9 | } 10 | } 11 | pub use layers::{Layer, LayerTable, Layers}; 12 | pub type SpatialTable = spatial_table::SpatialTable; 13 | pub type Location = spatial_table::Location; 14 | pub use spatial_table::UpdateError; 15 | -------------------------------------------------------------------------------- /procgen/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "procgen" 3 | version = "0.1.0" 4 | authors = ["Stephen Sherratt "] 5 | edition = "2021" 6 | 7 | [dependencies] 8 | coord_2d = "0.3" 9 | grid_2d = "0.15" 10 | direction = "0.18" 11 | line_2d = "0.5" 12 | perlin2 = "0.1" 13 | rand = "0.8" 14 | vector = { path = "../util/vector" } 15 | 16 | [dev-dependencies] 17 | chargrid_ansi_terminal = "0.4" 18 | chargrid = "0.10" 19 | rgb_int = "0.1" 20 | meap = "0.5" 21 | rand_isaac = "0.3" 22 | -------------------------------------------------------------------------------- /scripts/build_and_upload_web_version.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euxo pipefail 3 | 4 | BRANCH=$1 5 | NAME=boat_journey 6 | 7 | pushd web 8 | 9 | npm install 10 | 11 | NODE_OPTIONS=--openssl-legacy-provider npm run build-production 12 | 13 | TMP=$(mktemp -d) 14 | trap "rm -rf $TMP" EXIT 15 | 16 | rm -rf $NAME 17 | mkdir $NAME 18 | 19 | mv dist $NAME/$BRANCH 20 | 21 | zip -r $TMP/$NAME.zip $NAME 22 | rm -rf $NAME 23 | 24 | aws s3 cp $TMP/$NAME.zip s3://games.gridbugs.org/$NAME.zip 25 | -------------------------------------------------------------------------------- /web/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "boat_journey_web" 3 | version = "0.1.0" 4 | authors = ["Stephen Sherratt "] 5 | edition = "2021" 6 | 7 | [lib] 8 | crate-type = ["cdylib"] 9 | 10 | [dependencies] 11 | chargrid_web = "0.4" 12 | general_storage_web = "0.3" 13 | general_storage_static = "0.3" 14 | boat_journey_app = { path = "../app", features = ["web", "print_log"]} 15 | wasm-bindgen = "0.2" 16 | console_error_panic_hook = "0.1" 17 | wasm-logger = "0.2" 18 | log = "0.4" 19 | -------------------------------------------------------------------------------- /scripts/make_dmg_macos.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euxo pipefail 3 | 4 | echo $MODE 5 | echo $APP_NAME 6 | echo $DMG_NAME 7 | 8 | TMP=$(mktemp -d) 9 | DMG_DIR=$TMP/$APP_NAME 10 | APP_BIN_DIR=$DMG_DIR/$APP_NAME.app/Contents/MacOS 11 | mkdir -p $APP_BIN_DIR 12 | cp -v extras/macos-dmg/* $DMG_DIR 13 | cp -v target/$MODE/boat_journey_wgpu $APP_BIN_DIR/BoatJourneyApp 14 | cp -v scripts/macos_run_app.sh $APP_BIN_DIR/$APP_NAME 15 | ln -s /Applications $DMG_DIR/Applications 16 | hdiutil create $DMG_NAME -srcfolder $DMG_DIR 17 | -------------------------------------------------------------------------------- /web/README.md: -------------------------------------------------------------------------------- 1 | # Boat Journey Web 2 | 3 | ## Install 4 | ``` 5 | $ cargo install wasm-pack 6 | $ npm install 7 | ``` 8 | 9 | ## Run Development Server 10 | ``` 11 | $ NODE_OPTIONS=--openssl-legacy-provider npm run serve 12 | ``` 13 | 14 | ## Build 15 | Artifacts will be placed in "./dist": 16 | 17 | ### Development (faster to build) 18 | ``` 19 | $ NODE_OPTIONS=--openssl-legacy-provider npm run build 20 | ``` 21 | 22 | ### Production (faster to execute) 23 | ``` 24 | $ NODE_OPTIONS=--openssl-legacy-provider npm run build-production 25 | ``` 26 | -------------------------------------------------------------------------------- /scripts/macos_run_app.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # 3 | # Tiny bash script to invoke the game's binary from within 4 | # the app directory structure. 5 | # 6 | # I have no idea why this is necessary, but without it the 7 | # game doesn't start when you run the app, despite the 8 | # binary starting fine when run directly. 9 | # 10 | # Replacing this script with the binary and running the 11 | # app with `open -a ` gives the error: 12 | # 13 | # LSOpenURLsWithRole() failed for the application APP with error -10810. 14 | # 15 | # Nobody seems to agree on what this error means. 16 | set -euxo pipefail 17 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 18 | "$DIR/BoatJourneyApp" 19 | -------------------------------------------------------------------------------- /scripts/make_archives_unix.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euxo pipefail 3 | 4 | echo $MODE 5 | echo $ARCHIVE_NAME 6 | 7 | TMP=$(mktemp -d) 8 | 9 | trap "rm -rf $TMP" EXIT 10 | 11 | mkdir $TMP/$ARCHIVE_NAME 12 | cp -v target/$MODE/boat_journey_wgpu $TMP/$ARCHIVE_NAME/boat-journey-graphical 13 | cp -v target/$MODE/boat_journey_ggez $TMP/$ARCHIVE_NAME/boat-journey-graphical-compatibility 14 | cp -v target/$MODE/boat_journey_ansi_terminal $TMP/$ARCHIVE_NAME/boat-journey-terminal 15 | cp -v extras/unix/* $TMP/$ARCHIVE_NAME 16 | 17 | pushd $TMP 18 | zip $ARCHIVE_NAME.zip $ARCHIVE_NAME/* 19 | tar -cvf $ARCHIVE_NAME.tar.gz $ARCHIVE_NAME 20 | popd 21 | mv $TMP/$ARCHIVE_NAME.zip . 22 | mv $TMP/$ARCHIVE_NAME.tar.gz . 23 | -------------------------------------------------------------------------------- /extras/unix/README.md: -------------------------------------------------------------------------------- 1 | # Boat Journey 2 | 3 | ## Package Contents 4 | 5 | - boat-journey-graphical: Graphical version of the game, rendering with metal on macos and vulkan on linux 6 | - boat-journey-terminal: Terminal version of the game, rendering as text in an ansi terminal 7 | 8 | ## HIDPI 9 | 10 | HIDPI scaling can make the game run larger than the screen size on some monitors. 11 | The `WINIT_X11_SCALE_FACTOR` environment variable overrides the HIDPI scaling factor. 12 | 13 | For example: 14 | ``` 15 | WINIT_X11_SCALE_FACTOR=1 ./boat-journey-graphical 16 | ``` 17 | 18 | ## MacOS 19 | 20 | In order to run binaries on MacOS, you may need to navigate to this directory 21 | in Finder and right click -> Open on the binaries, then choose Open at the 22 | prompt. 23 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "boat_journey_web", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "serve": "webpack serve --mode development", 9 | "serve-production": "webpack serve --mode production", 10 | "build": "webpack --mode development", 11 | "build-production": "webpack --mode production" 12 | }, 13 | "author": "", 14 | "license": "MIT", 15 | "dependencies": { 16 | "@wasm-tool/wasm-pack-plugin": "^1.5.0", 17 | "copy-webpack-plugin": "^9.0.1", 18 | "html-webpack-plugin": "^5.3.2", 19 | "text-encoding": "^0.7.0", 20 | "webpack": "^5.45.1", 21 | "webpack-cli": "^4.7.2", 22 | "webpack-dev-server": "^4.3.1" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Boat Journey 2 | 3 | [![dependency status](https://deps.rs/repo/github/gridbugs/boat-journey/status.svg)](https://deps.rs/repo/github/gridbugs/boat-journey) 4 | [![test status](https://github.com/gridbugs/boat-journey/actions/workflows/test.yml/badge.svg)](https://github.com/gridbugs/boat-journey/actions/workflows/test.yml) 5 | 6 | ## HIDPI 7 | 8 | HIDPI scaling can make the game run larger than the screen size on some monitors. 9 | The `WINIT_X11_SCALE_FACTOR` environment variable overrides the HIDPI scaling factor. 10 | 11 | For example: 12 | ``` 13 | WINIT_X11_SCALE_FACTOR=3 cargo run --manifest-path wgpu/Cargo.toml 14 | ``` 15 | 16 | ## Nix 17 | 18 | To set up a shell with an installation of rust and external dependencies: 19 | ``` 20 | nix-shell 21 | ``` 22 | ...or for nix flakes users: 23 | ``` 24 | nix develop 25 | ``` 26 | -------------------------------------------------------------------------------- /image-to-text/src/main.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | use meap::prelude::*; 3 | let in_path = opt_req::("PATH", 'i') 4 | .name("in") 5 | .desc("path to input image file") 6 | .with_help_default() 7 | .parse_env_or_exit(); 8 | let in_image = image::open(in_path).unwrap().to_rgb8(); 9 | for y in 0..in_image.height() { 10 | print!("\""); 11 | for x in 0..in_image.width() { 12 | let [r, g, b] = in_image.get_pixel(x, y).0; 13 | let ch = match (r, g, b) { 14 | (0, 0, 0) => '#', 15 | (255, 255, 255) => '.', 16 | (0, 0, 255) => '$', 17 | (255, 0, 0) => '?', 18 | other => panic!("unrecognised colour: {:?}", other), 19 | }; 20 | print!("{}", ch); 21 | } 22 | print!("\","); 23 | println!(""); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | Boat Journey 3 | 37 |
38 | -------------------------------------------------------------------------------- /util/rand-range/src/lib.rs: -------------------------------------------------------------------------------- 1 | use rand::{ 2 | distributions::{ 3 | uniform::{SampleUniform, Uniform}, 4 | Distribution, 5 | }, 6 | Rng, 7 | }; 8 | use serde::{Deserialize, Serialize}; 9 | 10 | #[derive(Clone, Copy, Debug, Serialize, Deserialize)] 11 | pub struct UniformInclusiveRange { 12 | pub low: T, 13 | pub high: T, 14 | } 15 | 16 | impl UniformInclusiveRange { 17 | pub fn choose(&self, rng: &mut R) -> T { 18 | Uniform::::new_inclusive(&self.low, &self.high).sample(rng) 19 | } 20 | } 21 | 22 | #[derive(Clone, Copy, Debug, Serialize, Deserialize)] 23 | pub struct UniformLeftInclusiveRange { 24 | pub low: T, 25 | pub high: T, 26 | } 27 | 28 | impl UniformLeftInclusiveRange { 29 | pub fn choose(&self, rng: &mut R) -> T { 30 | Uniform::::new(&self.low, &self.high).sample(rng) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /app/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "boat_journey_app" 3 | version = "0.1.0" 4 | authors = ["Stephen Sherratt "] 5 | edition = "2021" 6 | 7 | [features] 8 | print_stdout = [] 9 | print_log = [] 10 | native = ["general_storage_static/file"] 11 | web = ["getrandom/js", "general_storage_static/web"] 12 | 13 | [dependencies] 14 | general_storage_static = { version = "0.3", features = ["bincode", "json"] } 15 | direction = "0.18" 16 | chargrid = { version = "0.10", features = ["serialize"] } 17 | rgb_int = "0.1" 18 | perlin2 = { version = "0.1", features = ["serialize"] } 19 | coord_2d = "0.3" 20 | grid_2d = "0.15" 21 | boat_journey_game = { path = "../game" } 22 | log = "0.4" 23 | serde = { version = "1.0", features = ["serde_derive"] } 24 | rand = "0.8" 25 | rand_isaac = { version = "0.3", features = ["serde1"] } 26 | rand_xorshift = { version = "0.3", features = ["serde1"] } 27 | maplit = "1.0" 28 | getrandom = "0.2" 29 | bincode = "1.3" 30 | -------------------------------------------------------------------------------- /app/src/mist.rs: -------------------------------------------------------------------------------- 1 | use coord_2d::Coord; 2 | use perlin2::Perlin2; 3 | use rand::Rng; 4 | use rgb_int::Rgba32; 5 | use serde::{Deserialize, Serialize}; 6 | 7 | #[derive(Serialize, Deserialize)] 8 | pub struct Mist { 9 | perlin: Perlin2, 10 | intensity: f64, 11 | offset_x: f64, 12 | speed_x: f64, 13 | } 14 | 15 | impl Mist { 16 | pub fn new(rng: &mut R) -> Self { 17 | Self { 18 | perlin: Perlin2::new(rng), 19 | intensity: 0.03, 20 | offset_x: 0., 21 | speed_x: 0.005, 22 | } 23 | } 24 | 25 | pub fn get(&self, coord: Coord) -> Rgba32 { 26 | let noise = self 27 | .perlin 28 | .noise01((coord.x as f64 * 0.05 + self.offset_x, coord.y as f64 * 0.2)); 29 | let alpha = (self.intensity * 255. * noise) as u8; 30 | Rgba32::new(255, 255, 255, alpha) 31 | } 32 | 33 | pub fn tick(&mut self) { 34 | self.offset_x += self.speed_x; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /game/src/world/mod.rs: -------------------------------------------------------------------------------- 1 | use coord_2d::Size; 2 | use entity_table::EntityAllocator; 3 | use grid_search_cardinal_distance_map::DistanceMap; 4 | use serde::{Deserialize, Serialize}; 5 | 6 | pub mod spatial; 7 | use spatial::SpatialTable; 8 | 9 | pub mod data; 10 | use data::Components; 11 | 12 | pub mod spawn; 13 | 14 | #[derive(Debug, Serialize, Deserialize)] 15 | pub struct World { 16 | pub entity_allocator: EntityAllocator, 17 | pub components: Components, 18 | pub spatial_table: SpatialTable, 19 | pub distance_map: DistanceMap, 20 | } 21 | 22 | impl World { 23 | pub fn new(size: Size) -> Self { 24 | let entity_allocator = EntityAllocator::default(); 25 | let components = Components::default(); 26 | let spatial_table = SpatialTable::new(size); 27 | Self { 28 | entity_allocator, 29 | components, 30 | spatial_table, 31 | distance_map: DistanceMap::new(size), 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /game/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "boat_journey_game" 3 | version = "0.1.0" 4 | authors = ["Stephen Sherratt "] 5 | edition = "2021" 6 | 7 | [dependencies] 8 | coord_2d = "0.3" 9 | direction = "0.18" 10 | entity_table = { version = "0.2", features = ["serialize"] } 11 | spatial_table = { version = "0.4", features = ["serialize"] } 12 | grid_2d = "0.15" 13 | line_2d = { version = "0.5", features = ["serialize"] } 14 | rgb_int = "0.1" 15 | grid_search_cardinal_distance_map = { version = "0.3", features = ["serialize"] } 16 | shadowcast = { version = "0.8", features = ["serialize"] } 17 | visible_area_detection = { version = "0.2", features = ["serialize"] } 18 | log = "0.4" 19 | serde = { version = "1.0", features = ["serde_derive"] } 20 | rand = "0.8" 21 | rand_isaac = { version = "0.3", features = ["serde1"] } 22 | vector = { path = "../util/vector" } 23 | rational = { path = "../util/rational" } 24 | rand_range = { path = "../util/rand-range" } 25 | procgen = { path = "../procgen" } 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Stephen Sherratt 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /extras/unix/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Stephen Sherratt 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /extras/macos-dmg/LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Stephen Sherratt 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /extras/windows/LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Stephen Sherratt 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /web/src/lib.rs: -------------------------------------------------------------------------------- 1 | use boat_journey_app::{app, AppArgs, AppStorage, InitialRngSeed}; 2 | use chargrid_web::{Context, Size}; 3 | use general_storage_static::StaticStorage; 4 | use general_storage_web::LocalStorage; 5 | use wasm_bindgen::prelude::*; 6 | 7 | const SAVE_KEY: &str = "save"; 8 | const CONFIG_KEY: &str = "config"; 9 | const CONTROLS_KEY: &str = "controls"; 10 | 11 | #[wasm_bindgen(start)] 12 | pub fn run() -> Result<(), JsValue> { 13 | wasm_logger::init(wasm_logger::Config::new(log::Level::Info)); 14 | console_error_panic_hook::set_once(); 15 | let mut storage = StaticStorage::new(LocalStorage::new()); 16 | let _ = storage.remove(CONFIG_KEY); 17 | let _ = storage.remove(CONTROLS_KEY); 18 | let context = Context::new(Size::new(80, 60), "content"); 19 | let args = AppArgs { 20 | storage: AppStorage { 21 | handle: storage, 22 | save_game_key: SAVE_KEY.to_string(), 23 | config_key: CONFIG_KEY.to_string(), 24 | controls_key: CONTROLS_KEY.to_string(), 25 | }, 26 | initial_rng_seed: InitialRngSeed::Random, 27 | omniscient: false, 28 | new_game: false, 29 | }; 30 | context.run(app(args)); 31 | Ok(()) 32 | } 33 | -------------------------------------------------------------------------------- /app/src/lib.rs: -------------------------------------------------------------------------------- 1 | use boat_journey_game::Config; 2 | use chargrid::{control_flow::*, core::*}; 3 | 4 | mod colour; 5 | mod controls; 6 | mod game_instance; 7 | mod game_loop; 8 | mod image; 9 | mod mist; 10 | mod text; 11 | 12 | pub use game_loop::{AppStorage, InitialRngSeed}; 13 | 14 | struct AppState { 15 | game_loop_data: game_loop::GameLoopData, 16 | } 17 | 18 | pub struct AppArgs { 19 | pub storage: AppStorage, 20 | pub initial_rng_seed: InitialRngSeed, 21 | pub omniscient: bool, 22 | pub new_game: bool, 23 | } 24 | 25 | pub fn app( 26 | AppArgs { 27 | storage, 28 | initial_rng_seed, 29 | omniscient, 30 | new_game, 31 | }: AppArgs, 32 | ) -> impl Component { 33 | let config = Config { 34 | omniscient: if omniscient { Config::OMNISCIENT } else { None }, 35 | demo: false, 36 | debug: false, 37 | }; 38 | let (game_loop_data, initial_state) = 39 | game_loop::GameLoopData::new(config, storage, initial_rng_seed, new_game); 40 | let state = AppState { game_loop_data }; 41 | game_loop::game_loop_component(initial_state) 42 | .lens_state(lens!(AppState[game_loop_data]: game_loop::GameLoopData)) 43 | .map(|_| app::Exit) 44 | .with_state(state) 45 | .clear_each_frame() 46 | .exit_on_close() 47 | } 48 | -------------------------------------------------------------------------------- /sdl2/src/main.rs: -------------------------------------------------------------------------------- 1 | #![windows_subsystem = "windows"] 2 | use boat_journey_app::{app, AppArgs}; 3 | use boat_journey_native::{meap, NativeCommon}; 4 | use chargrid_sdl2::*; 5 | 6 | fn main() { 7 | use meap::Parser; 8 | env_logger::init(); 9 | let NativeCommon { 10 | storage, 11 | initial_rng_seed, 12 | omniscient, 13 | new_game, 14 | } = NativeCommon::parser() 15 | .with_help_default() 16 | .parse_env_or_exit(); 17 | let context = Context::new(Config { 18 | font_bytes: FontBytes { 19 | normal: include_bytes!("./fonts/PxPlus_IBM_CGAthin-with-quadrant-blocks.ttf").to_vec(), 20 | bold: include_bytes!("./fonts/PxPlus_IBM_CGA-with-quadrant-blocks.ttf").to_vec(), 21 | }, 22 | title: "Boat Journey".to_string(), 23 | window_dimensions_px: Dimensions { 24 | width: 960., 25 | height: 720., 26 | }, 27 | cell_dimensions_px: Dimensions { 28 | width: 12., 29 | height: 12., 30 | }, 31 | font_point_size: 12, 32 | character_cell_offset: Dimensions { 33 | width: 0., 34 | height: -1., 35 | }, 36 | underline_width_cell_ratio: 0.1, 37 | underline_top_offset_cell_ratio: 0.8, 38 | resizable: false, 39 | }); 40 | context.run(app(AppArgs { 41 | storage, 42 | initial_rng_seed, 43 | omniscient, 44 | new_game, 45 | })); 46 | } 47 | -------------------------------------------------------------------------------- /ggez/src/main.rs: -------------------------------------------------------------------------------- 1 | #![windows_subsystem = "windows"] 2 | use boat_journey_app::{app, AppArgs}; 3 | use boat_journey_native::{meap, NativeCommon}; 4 | use chargrid_ggez::*; 5 | 6 | const CELL_SIZE: f64 = 12.; 7 | 8 | fn main() { 9 | use meap::Parser; 10 | env_logger::init(); 11 | let NativeCommon { 12 | storage, 13 | initial_rng_seed, 14 | omniscient, 15 | new_game, 16 | } = NativeCommon::parser() 17 | .with_help_default() 18 | .parse_env_or_exit(); 19 | let context = Context::new(Config { 20 | font_bytes: FontBytes { 21 | normal: include_bytes!("./fonts/PxPlus_IBM_CGAthin-with-quadrant-blocks.ttf").to_vec(), 22 | bold: include_bytes!("./fonts/PxPlus_IBM_CGA-with-quadrant-blocks.ttf").to_vec(), 23 | }, 24 | title: "Boat Journey".to_string(), 25 | window_dimensions_px: Dimensions { 26 | width: 960., 27 | height: 720., 28 | }, 29 | cell_dimensions_px: Dimensions { 30 | width: CELL_SIZE, 31 | height: CELL_SIZE, 32 | }, 33 | font_scale: Dimensions { 34 | width: CELL_SIZE, 35 | height: CELL_SIZE, 36 | }, 37 | underline_width_cell_ratio: 0.1, 38 | underline_top_offset_cell_ratio: 0.8, 39 | resizable: false, 40 | }); 41 | context.run(app(AppArgs { 42 | storage, 43 | initial_rng_seed, 44 | omniscient, 45 | new_game, 46 | })); 47 | } 48 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: 3 | push: 4 | branches: 5 | - '*' 6 | env: 7 | CARGO_TERM_COLOR: always 8 | jobs: 9 | 10 | test-macos-aarch64: 11 | runs-on: macOS-latest 12 | steps: 13 | - uses: hecrj/setup-rust-action@v1 14 | with: 15 | rust-version: stable 16 | targets: aarch64-apple-darwin 17 | - uses: actions/checkout@v3 18 | - run: cargo build --target=aarch64-apple-darwin --workspace --exclude boat_journey_sdl2 19 | 20 | test-web: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: hecrj/setup-rust-action@v1 24 | with: 25 | rust-version: stable 26 | targets: wasm32-unknown-unknown 27 | - uses: actions/checkout@v3 28 | - run: cargo build --target=wasm32-unknown-unknown --manifest-path=web/Cargo.toml 29 | 30 | test-x86_64: 31 | runs-on: ${{ matrix.os }} 32 | strategy: 33 | matrix: 34 | os: [ubuntu-latest, windows-latest, macOS-latest] 35 | rust: [stable] 36 | steps: 37 | - if: matrix.os == 'ubuntu-latest' 38 | name: 'Install dependencies (ubuntu)' 39 | run: | 40 | sudo apt update 41 | sudo apt install libudev-dev libasound2-dev libsdl2-dev libsdl2-ttf-dev 42 | - uses: hecrj/setup-rust-action@v1 43 | with: 44 | rust-version: ${{ matrix.rust }} 45 | - uses: actions/checkout@v3 46 | - if: matrix.os == 'ubuntu-latest' 47 | run: cargo test --workspace 48 | - if: matrix.os != 'ubuntu-latest' 49 | run: cargo test --workspace --exclude boat_journey_sdl2 50 | -------------------------------------------------------------------------------- /web/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 4 | const WasmPackPlugin = require('@wasm-tool/wasm-pack-plugin'); 5 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 6 | const util = require('util'); 7 | const exec = util.promisify(require('child_process').exec); 8 | 9 | module.exports = async (env, argv) => { 10 | const revision = (await exec('git rev-parse HEAD')).stdout.trim(); 11 | return { 12 | entry: { 13 | main: './index.js', 14 | }, 15 | output: { 16 | path: path.resolve(__dirname, 'dist'), 17 | // Various levels of caching on the web will mean that updates to wasm 18 | // and js files can take a long time to become visible. Prevent this by 19 | // including the revision hash of the repository in the names of these 20 | // files. 21 | filename: `index.${revision}.js`, 22 | webassemblyModuleFilename: `app.${revision}.wasm`, 23 | }, 24 | plugins: [ 25 | new HtmlWebpackPlugin({ 26 | template: './index.html', 27 | }), 28 | new WasmPackPlugin({ 29 | crateDirectory: path.resolve(__dirname, '.'), 30 | extraArgs: '--no-typescript', 31 | }), 32 | // Required to work in Edge 33 | new webpack.ProvidePlugin({ 34 | TextDecoder: ['text-encoding', 'TextDecoder'], 35 | TextEncoder: ['text-encoding', 'TextEncoder'] 36 | }), 37 | new CopyWebpackPlugin({ 38 | patterns: [ 39 | { from: 'static_web' }, 40 | ], 41 | }), 42 | ], 43 | experiments: { 44 | asyncWebAssembly: true, 45 | }, 46 | devServer: { 47 | client: { 48 | overlay: false, 49 | }, 50 | }, 51 | }; 52 | }; 53 | -------------------------------------------------------------------------------- /app/src/controls.rs: -------------------------------------------------------------------------------- 1 | use chargrid::input::{Input, KeyboardInput}; 2 | use direction::CardinalDirection; 3 | use maplit::btreemap; 4 | use serde::{Deserialize, Serialize}; 5 | use std::collections::BTreeMap; 6 | 7 | #[derive(Debug, Clone, Serialize, Deserialize)] 8 | pub enum AppInput { 9 | Direction(CardinalDirection), 10 | Wait, 11 | DriveToggle, 12 | Ability(u8), 13 | } 14 | 15 | #[derive(Serialize, Deserialize)] 16 | pub struct Controls { 17 | keys: BTreeMap, 18 | } 19 | 20 | impl Default for Controls { 21 | fn default() -> Self { 22 | let keys = btreemap![ 23 | KeyboardInput::Left => AppInput::Direction(CardinalDirection::West), 24 | KeyboardInput::Right => AppInput::Direction(CardinalDirection::East), 25 | KeyboardInput::Up => AppInput::Direction(CardinalDirection::North), 26 | KeyboardInput::Down => AppInput::Direction(CardinalDirection::South), 27 | KeyboardInput::Char(' ') => AppInput::Wait, 28 | KeyboardInput::Char('e') => AppInput::DriveToggle, 29 | KeyboardInput::Char('1') => AppInput::Ability(1), 30 | KeyboardInput::Char('2') => AppInput::Ability(2), 31 | KeyboardInput::Char('3') => AppInput::Ability(3), 32 | KeyboardInput::Char('4') => AppInput::Ability(4), 33 | KeyboardInput::Char('5') => AppInput::Ability(5), 34 | KeyboardInput::Char('6') => AppInput::Ability(6), 35 | KeyboardInput::Char('7') => AppInput::Ability(7), 36 | KeyboardInput::Char('8') => AppInput::Ability(8), 37 | KeyboardInput::Char('9') => AppInput::Ability(9), 38 | ]; 39 | Self { keys } 40 | } 41 | } 42 | impl Controls { 43 | pub fn get(&self, input: Input) -> Option { 44 | match input { 45 | Input::Keyboard(keyboard_input) => self.keys.get(&keyboard_input).cloned(), 46 | Input::Mouse(_) => None, 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /wgpu/src/main.rs: -------------------------------------------------------------------------------- 1 | #![windows_subsystem = "windows"] 2 | use boat_journey_app::{app, AppArgs}; 3 | use boat_journey_native::{meap, NativeCommon}; 4 | use chargrid_wgpu::*; 5 | 6 | const CELL_SIZE: f64 = 12.; 7 | 8 | struct Args { 9 | native_common: NativeCommon, 10 | force_opengl: bool, 11 | } 12 | 13 | impl Args { 14 | fn parser() -> impl meap::Parser { 15 | meap::let_map! { 16 | let { 17 | native_common = NativeCommon::parser(); 18 | force_opengl = flag("force-opengl").desc("force opengl"); 19 | } in { 20 | Self { native_common, force_opengl } 21 | } 22 | } 23 | } 24 | } 25 | 26 | fn main() { 27 | use meap::Parser; 28 | env_logger::init(); 29 | let Args { 30 | native_common: 31 | NativeCommon { 32 | storage, 33 | initial_rng_seed, 34 | omniscient, 35 | new_game, 36 | }, 37 | force_opengl, 38 | } = Args::parser().with_help_default().parse_env_or_exit(); 39 | let context = Context::new(Config { 40 | font_bytes: FontBytes { 41 | normal: include_bytes!("./fonts/PxPlus_IBM_CGAthin-with-quadrant-blocks.ttf").to_vec(), 42 | bold: include_bytes!("./fonts/PxPlus_IBM_CGA-with-quadrant-blocks.ttf").to_vec(), 43 | }, 44 | title: "Boat Journey".to_string(), 45 | window_dimensions_px: Dimensions { 46 | width: 960., 47 | height: 720., 48 | }, 49 | cell_dimensions_px: Dimensions { 50 | width: CELL_SIZE, 51 | height: CELL_SIZE, 52 | }, 53 | font_scale: Dimensions { 54 | width: CELL_SIZE, 55 | height: CELL_SIZE, 56 | }, 57 | underline_width_cell_ratio: 0.1, 58 | underline_top_offset_cell_ratio: 0.8, 59 | resizable: false, 60 | force_secondary_adapter: force_opengl, 61 | }); 62 | context.run(app(AppArgs { 63 | storage, 64 | initial_rng_seed, 65 | omniscient, 66 | new_game, 67 | })); 68 | } 69 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Boat Journey"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 6 | rust-overlay.url = "github:oxalica/rust-overlay"; 7 | flake-utils.url = "github:numtide/flake-utils"; 8 | flake-compat = { 9 | url = "github:edolstra/flake-compat"; 10 | flake = false; 11 | }; 12 | }; 13 | 14 | outputs = { self, nixpkgs, rust-overlay, flake-utils, flake-compat, ... }: 15 | flake-utils.lib.eachDefaultSystem (system: 16 | let 17 | overlays = [ (import rust-overlay) ]; 18 | pkgs = import nixpkgs { 19 | inherit system overlays; 20 | }; 21 | in 22 | with pkgs; 23 | { 24 | devShell = mkShell rec { 25 | buildInputs = [ 26 | # General C Compiler/Linker/Tools 27 | lld 28 | clang 29 | pkg-config 30 | openssl 31 | cmake 32 | (rust-bin.stable.latest.default.override { 33 | extensions = [ "rust-src" "rust-analysis" ]; 34 | targets = [ "wasm32-unknown-unknown" ]; 35 | }) 36 | rust-analyzer 37 | cargo-watch 38 | zip 39 | 40 | # Graphics and Audio Dependencies 41 | alsa-lib 42 | libao 43 | openal 44 | libpulseaudio 45 | udev 46 | fontconfig 47 | xorg.libX11 48 | xorg.libXcursor 49 | xorg.libXrandr 50 | xorg.libXi 51 | vulkan-loader 52 | vulkan-tools 53 | libGL 54 | bzip2 55 | zlib 56 | libpng 57 | expat 58 | brotli 59 | SDL2 60 | SDL2_ttf 61 | 62 | # JS/Wasm Deps 63 | nodejs 64 | wasm-pack 65 | ]; 66 | 67 | # Allows rust-analyzer to find the rust source 68 | RUST_SRC_PATH = "${pkgs.rustPlatform.rustLibSrc}"; 69 | 70 | # Without this graphical frontends can't find the GPU adapters 71 | LD_LIBRARY_PATH = "${pkgs.lib.makeLibraryPath buildInputs}"; 72 | 73 | }; 74 | } 75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /app/src/audio.rs: -------------------------------------------------------------------------------- 1 | use gridbugs::audio::{Audio as Sound, AudioHandle, AudioPlayer}; 2 | 3 | use maplit::hashmap; 4 | use std::collections::HashMap; 5 | 6 | pub type AppAudioPlayer = Option; 7 | pub type AppHandle = Option; 8 | 9 | #[derive(Clone, Copy, Hash, PartialEq, Eq, Debug)] 10 | pub enum Audio {} 11 | 12 | pub struct AudioState { 13 | audio_player: AppAudioPlayer, 14 | music_handle: AppHandle, 15 | music_volume: f32, 16 | music_volume_multiplier: f32, 17 | } 18 | 19 | impl AudioState { 20 | pub fn new(audio_player: AppAudioPlayer) -> Self { 21 | Self { 22 | audio_player, 23 | music_handle: None, 24 | music_volume: 1., 25 | music_volume_multiplier: 1., 26 | } 27 | } 28 | 29 | pub fn play_once(&self, audio: Audio, volume: f32) { 30 | log::info!("Playing audio {:?} at volume {:?}", audio, volume); 31 | if let Some(sound) = self.audio_table.get(audio) { 32 | if let Some(audio_player) = self.audio_player.as_ref() { 33 | let handle = audio_player.play(&sound); 34 | handle.set_volume(volume); 35 | handle.background(); 36 | } 37 | } 38 | } 39 | 40 | pub fn loop_music(&mut self, audio: Audio, volume: f32) { 41 | log::info!("Looping audio {:?} at volume {:?}", audio, volume); 42 | if let Some(sound) = self.audio_table.get(audio) { 43 | if let Some(audio_player) = self.audio_player.as_ref() { 44 | let handle = audio_player.play_loop(&sound); 45 | handle.set_volume(volume * self.music_volume_multiplier); 46 | self.music_handle = Some(handle); 47 | self.music_volume = volume; 48 | } 49 | } 50 | } 51 | 52 | pub fn set_music_volume(&mut self, volume: f32) { 53 | self.music_volume = volume; 54 | if let Some(music_handle) = self.music_handle.as_mut() { 55 | music_handle.set_volume(volume * self.music_volume_multiplier); 56 | } 57 | } 58 | 59 | pub fn set_music_volume_multiplier(&mut self, music_volume_multiplier: f32) { 60 | self.music_volume_multiplier = music_volume_multiplier; 61 | if let Some(music_handle) = self.music_handle.as_mut() { 62 | music_handle.set_volume(self.music_volume * self.music_volume_multiplier); 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /ansi-terminal/src/main.rs: -------------------------------------------------------------------------------- 1 | use boat_journey_app::{app, AppArgs, InitialRngSeed}; 2 | use boat_journey_native::NativeCommon; 3 | use chargrid_ansi_terminal::{col_encode, Context}; 4 | use rand::Rng; 5 | 6 | enum ColEncodeChoice { 7 | TrueColour, 8 | Rgb, 9 | Greyscale, 10 | Ansi, 11 | } 12 | 13 | impl ColEncodeChoice { 14 | fn parser() -> impl meap::Parser { 15 | use ColEncodeChoice::*; 16 | meap::choose_at_most_one!( 17 | flag("true-colour").some_if(TrueColour), 18 | flag("rgb").some_if(Rgb), 19 | flag("greyscale").some_if(Greyscale), 20 | flag("ansi").some_if(Ansi), 21 | ) 22 | .with_default_general(TrueColour) 23 | } 24 | } 25 | 26 | struct Args { 27 | native_common: NativeCommon, 28 | col_encode_choice: ColEncodeChoice, 29 | } 30 | 31 | impl Args { 32 | fn parser() -> impl meap::Parser { 33 | meap::let_map! { 34 | let { 35 | native_common = NativeCommon::parser(); 36 | col_encode_choice = ColEncodeChoice::parser(); 37 | } in { 38 | Self { native_common, col_encode_choice } 39 | } 40 | } 41 | } 42 | } 43 | 44 | fn main() { 45 | use meap::Parser; 46 | let Args { 47 | native_common: 48 | NativeCommon { 49 | storage, 50 | initial_rng_seed, 51 | omniscient, 52 | new_game, 53 | }, 54 | col_encode_choice, 55 | } = Args::parser().with_help_default().parse_env_or_exit(); 56 | if let ColEncodeChoice::TrueColour = col_encode_choice { 57 | println!("Running in true-colour mode.\nIf colours look wrong, run with `--rgb` or try a different terminal emulator."); 58 | } 59 | let initial_rng_seed = match initial_rng_seed { 60 | InitialRngSeed::U64(seed) => seed, 61 | InitialRngSeed::Random => rand::thread_rng().gen(), 62 | }; 63 | println!("Initial RNG Seed: {}", initial_rng_seed); 64 | let context = Context::new().unwrap(); 65 | let app = app(AppArgs { 66 | storage, 67 | initial_rng_seed: InitialRngSeed::U64(initial_rng_seed), 68 | omniscient, 69 | new_game, 70 | }); 71 | use ColEncodeChoice as C; 72 | match col_encode_choice { 73 | C::TrueColour => context.run(app, col_encode::XtermTrueColour), 74 | C::Rgb => context.run(app, col_encode::FromTermInfoRgb), 75 | C::Greyscale => context.run(app, col_encode::FromTermInfoGreyscale), 76 | C::Ansi => context.run(app, col_encode::FromTermInfoAnsi16Colour), 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-compat": { 4 | "flake": false, 5 | "locked": { 6 | "lastModified": 1673956053, 7 | "narHash": "sha256-4gtG9iQuiKITOjNQQeQIpoIB6b16fm+504Ch3sNKLd8=", 8 | "owner": "edolstra", 9 | "repo": "flake-compat", 10 | "rev": "35bb57c0c8d8b62bbfd284272c928ceb64ddbde9", 11 | "type": "github" 12 | }, 13 | "original": { 14 | "owner": "edolstra", 15 | "repo": "flake-compat", 16 | "type": "github" 17 | } 18 | }, 19 | "flake-utils": { 20 | "locked": { 21 | "lastModified": 1676283394, 22 | "narHash": "sha256-XX2f9c3iySLCw54rJ/CZs+ZK6IQy7GXNY4nSOyu2QG4=", 23 | "owner": "numtide", 24 | "repo": "flake-utils", 25 | "rev": "3db36a8b464d0c4532ba1c7dda728f4576d6d073", 26 | "type": "github" 27 | }, 28 | "original": { 29 | "owner": "numtide", 30 | "repo": "flake-utils", 31 | "type": "github" 32 | } 33 | }, 34 | "flake-utils_2": { 35 | "locked": { 36 | "lastModified": 1659877975, 37 | "narHash": "sha256-zllb8aq3YO3h8B/U0/J1WBgAL8EX5yWf5pMj3G0NAmc=", 38 | "owner": "numtide", 39 | "repo": "flake-utils", 40 | "rev": "c0e246b9b83f637f4681389ecabcb2681b4f3af0", 41 | "type": "github" 42 | }, 43 | "original": { 44 | "owner": "numtide", 45 | "repo": "flake-utils", 46 | "type": "github" 47 | } 48 | }, 49 | "nixpkgs": { 50 | "locked": { 51 | "lastModified": 1678654296, 52 | "narHash": "sha256-aVfw3ThpY7vkUeF1rFy10NAkpKDS2imj3IakrzT0Occ=", 53 | "owner": "NixOS", 54 | "repo": "nixpkgs", 55 | "rev": "5a1dc8acd977ff3dccd1328b7c4a6995429a656b", 56 | "type": "github" 57 | }, 58 | "original": { 59 | "owner": "NixOS", 60 | "ref": "nixos-unstable", 61 | "repo": "nixpkgs", 62 | "type": "github" 63 | } 64 | }, 65 | "nixpkgs_2": { 66 | "locked": { 67 | "lastModified": 1665296151, 68 | "narHash": "sha256-uOB0oxqxN9K7XGF1hcnY+PQnlQJ+3bP2vCn/+Ru/bbc=", 69 | "owner": "NixOS", 70 | "repo": "nixpkgs", 71 | "rev": "14ccaaedd95a488dd7ae142757884d8e125b3363", 72 | "type": "github" 73 | }, 74 | "original": { 75 | "owner": "NixOS", 76 | "ref": "nixpkgs-unstable", 77 | "repo": "nixpkgs", 78 | "type": "github" 79 | } 80 | }, 81 | "root": { 82 | "inputs": { 83 | "flake-compat": "flake-compat", 84 | "flake-utils": "flake-utils", 85 | "nixpkgs": "nixpkgs", 86 | "rust-overlay": "rust-overlay" 87 | } 88 | }, 89 | "rust-overlay": { 90 | "inputs": { 91 | "flake-utils": "flake-utils_2", 92 | "nixpkgs": "nixpkgs_2" 93 | }, 94 | "locked": { 95 | "lastModified": 1678760344, 96 | "narHash": "sha256-N8u9/O0NWt3PUQc9xmCeod1SFilOFicALjtYtslib2g=", 97 | "owner": "oxalica", 98 | "repo": "rust-overlay", 99 | "rev": "d907affef544f64bd6886fe6bcc5fa2495a82373", 100 | "type": "github" 101 | }, 102 | "original": { 103 | "owner": "oxalica", 104 | "repo": "rust-overlay", 105 | "type": "github" 106 | } 107 | } 108 | }, 109 | "root": "root", 110 | "version": 7 111 | } 112 | -------------------------------------------------------------------------------- /util/vector/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub use coord_2d::Coord; 2 | use rand::{ 3 | distributions::uniform::{SampleBorrow, SampleUniform, UniformFloat, UniformSampler}, 4 | Rng, 5 | }; 6 | use rand_range::UniformLeftInclusiveRange; 7 | use serde::{Deserialize, Serialize}; 8 | 9 | #[derive(Debug, Clone, Copy)] 10 | pub struct Cartesian { 11 | pub x: f64, 12 | pub y: f64, 13 | } 14 | 15 | #[derive(Debug, Clone, Copy)] 16 | pub struct Radial { 17 | pub angle: Radians, 18 | pub length: f64, 19 | } 20 | 21 | impl Cartesian { 22 | pub fn from_coord(coord: Coord) -> Self { 23 | Self { 24 | x: coord.x as f64, 25 | y: coord.y as f64, 26 | } 27 | } 28 | pub fn to_coord_round_nearest(self) -> Coord { 29 | Coord::new(self.x.round() as i32, self.y.round() as i32) 30 | } 31 | pub fn to_radial(self) -> Radial { 32 | Radial { 33 | angle: Radians(self.y.atan2(self.x)), 34 | length: ((self.x * self.x) + (self.y * self.y)).sqrt(), 35 | } 36 | } 37 | } 38 | 39 | impl Radial { 40 | pub fn to_cartesian(self) -> Cartesian { 41 | Cartesian { 42 | x: self.length * self.angle.0.cos(), 43 | y: self.length * self.angle.0.sin(), 44 | } 45 | } 46 | pub fn rotate_clockwise(self, angle: Radians) -> Self { 47 | Self { 48 | angle: Radians(self.angle.0 + angle.0), 49 | length: self.length, 50 | } 51 | } 52 | } 53 | 54 | #[derive(Debug, Clone, Copy, Serialize, Deserialize)] 55 | pub struct Radians(pub f64); 56 | 57 | pub const PI: Radians = Radians(::std::f64::consts::PI); 58 | pub const NEG_PI: Radians = Radians(-::std::f64::consts::PI); 59 | 60 | impl Radians { 61 | pub const fn uniform_range_all() -> UniformLeftInclusiveRange { 62 | UniformLeftInclusiveRange { 63 | low: NEG_PI, 64 | high: PI, 65 | } 66 | } 67 | pub fn random(rng: &mut R) -> Self { 68 | Self(rng.gen_range(-::std::f64::consts::PI..::std::f64::consts::PI)) 69 | } 70 | } 71 | 72 | pub struct UniformRadians { 73 | inner: UniformFloat, 74 | } 75 | 76 | impl UniformSampler for UniformRadians { 77 | type X = Radians; 78 | fn new(low: B1, high: B2) -> Self 79 | where 80 | B1: SampleBorrow + Sized, 81 | B2: SampleBorrow + Sized, 82 | { 83 | Self { 84 | inner: UniformFloat::::new(low.borrow().0, high.borrow().0), 85 | } 86 | } 87 | fn new_inclusive(low: B1, high: B2) -> Self 88 | where 89 | B1: SampleBorrow + Sized, 90 | B2: SampleBorrow + Sized, 91 | { 92 | UniformSampler::new(low, high) 93 | } 94 | fn sample(&self, rng: &mut R) -> Self::X { 95 | Radians(self.inner.sample(rng)) 96 | } 97 | } 98 | 99 | impl SampleUniform for Radians { 100 | type Sampler = UniformRadians; 101 | } 102 | 103 | #[cfg(test)] 104 | mod test { 105 | use super::*; 106 | 107 | #[test] 108 | fn conversion() { 109 | let cartesian = Cartesian { x: 42., y: 3. }; 110 | let radial = cartesian.to_radial(); 111 | let rotated_radial = radial.rotate_clockwise(Radians(::std::f64::consts::FRAC_PI_2)); 112 | let rotated_cartesian = rotated_radial.to_cartesian(); 113 | assert!((rotated_cartesian.x + cartesian.y) < 0.1); 114 | assert!((rotated_cartesian.y - cartesian.x) < 0.1); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /native/src/lib.rs: -------------------------------------------------------------------------------- 1 | use boat_journey_app::{AppStorage, InitialRngSeed}; 2 | use general_storage_file::{FileStorage, IfDirectoryMissing}; 3 | use general_storage_static::StaticStorage; 4 | pub use meap; 5 | 6 | const DEFAULT_SAVE_FILE: &str = "save"; 7 | const DEFAULT_NEXT_TO_EXE_STORAGE_DIR: &str = "save"; 8 | const DEFAULT_CONFIG_FILE: &str = "config.json"; 9 | const DEFAULT_CONTROLS_FILE: &str = "controls.json"; 10 | 11 | pub struct NativeCommon { 12 | pub storage: AppStorage, 13 | pub initial_rng_seed: InitialRngSeed, 14 | pub omniscient: bool, 15 | pub new_game: bool, 16 | } 17 | impl NativeCommon { 18 | pub fn parser() -> impl meap::Parser { 19 | meap::let_map! { 20 | let { 21 | rng_seed = opt_opt::("INT", 'r').name("rng-seed").desc("rng seed to use for first new game"); 22 | save_file = opt_opt("PATH", 's').name("save-file").desc("save file") 23 | .with_default(DEFAULT_SAVE_FILE.to_string()); 24 | config_file = opt_opt("PATH", 'c').name("config-file").desc("config file") 25 | .with_default(DEFAULT_CONFIG_FILE.to_string()); 26 | controls_file = opt_opt("PATH", "controls-file").desc("controls file") 27 | .with_default(DEFAULT_CONTROLS_FILE.to_string()); 28 | storage_dir = opt_opt("PATH", 'd').name("storage-dir") 29 | .desc("directory that will contain state") 30 | .with_default(DEFAULT_NEXT_TO_EXE_STORAGE_DIR.to_string()); 31 | delete_save = flag("delete-save").desc("delete save game file"); 32 | delete_config = flag("delete-config").desc("delete config file"); 33 | delete_controls = flag("delete-controls").desc("delete controls file"); 34 | new_game = flag("new-game").desc("start a new game, skipping the menu"); 35 | omniscient = flag("omniscient").desc("enable omniscience"); 36 | } in {{ 37 | let initial_rng_seed = rng_seed.map(InitialRngSeed::U64).unwrap_or(InitialRngSeed::Random); 38 | let mut file_storage = StaticStorage::new( 39 | FileStorage::next_to_exe(storage_dir, IfDirectoryMissing::Create) 40 | .expect("failed to open directory"), 41 | ); 42 | if delete_save { 43 | let result = file_storage.remove(&save_file); 44 | if result.is_err() { 45 | log::warn!("couldn't find save file to delete"); 46 | } 47 | } 48 | if delete_config { 49 | let result = file_storage.remove(&config_file); 50 | if result.is_err() { 51 | log::warn!("couldn't find config file to delete"); 52 | } 53 | } 54 | if delete_controls { 55 | let result = file_storage.remove(&controls_file); 56 | if result.is_err() { 57 | log::warn!("couldn't find controls file to delete"); 58 | } 59 | } 60 | let storage = AppStorage { 61 | handle: file_storage, 62 | save_game_key: save_file, 63 | config_key: config_file, 64 | controls_key: controls_file, 65 | }; 66 | Self { 67 | initial_rng_seed, 68 | storage, 69 | omniscient, 70 | new_game, 71 | } 72 | }} 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /app/src/image.rs: -------------------------------------------------------------------------------- 1 | use boat_journey_game::{MenuImage, Npc}; 2 | use chargrid::prelude::*; 3 | use grid_2d::Grid; 4 | 5 | pub struct Image { 6 | pub grid: Grid, 7 | } 8 | 9 | impl Image { 10 | pub fn render(&self, ctx: Ctx, fb: &mut FrameBuffer) { 11 | for (coord, &cell) in self.grid.enumerate() { 12 | fb.set_cell_relative_to_ctx(ctx, coord, 0, cell); 13 | } 14 | } 15 | } 16 | 17 | #[derive(Clone, Copy)] 18 | enum ImageName { 19 | Townsfolk1, 20 | Grave, 21 | Ocean, 22 | Boat, 23 | Soldier, 24 | Physicist, 25 | Beast, 26 | Ghost, 27 | Surgeon, 28 | Thief, 29 | Surveyor, 30 | Shop, 31 | } 32 | 33 | impl ImageName { 34 | const fn data(self) -> &'static [u8] { 35 | match self { 36 | Self::Townsfolk1 => include_bytes!("images/townsfolk1.bin"), 37 | Self::Grave => include_bytes!("images/grave.bin"), 38 | Self::Ocean => include_bytes!("images/ocean.bin"), 39 | Self::Boat => include_bytes!("images/boat.bin"), 40 | Self::Soldier => include_bytes!("images/soldier.bin"), 41 | Self::Physicist => include_bytes!("images/physicist.bin"), 42 | Self::Beast => include_bytes!("images/beast.bin"), 43 | Self::Ghost => include_bytes!("images/ghost.bin"), 44 | Self::Surgeon => include_bytes!("images/surgeon.bin"), 45 | Self::Thief => include_bytes!("images/thief.bin"), 46 | Self::Surveyor => include_bytes!("images/surveyor.bin"), 47 | Self::Shop => include_bytes!("images/innkeeper.bin"), 48 | } 49 | } 50 | 51 | fn load_grid(self) -> Image { 52 | let grid = bincode::deserialize::>(self.data()).unwrap(); 53 | Image { grid } 54 | } 55 | } 56 | 57 | pub struct Images { 58 | pub townsfolk1: Image, 59 | pub grave: Image, 60 | pub ocean: Image, 61 | pub boat: Image, 62 | pub soldier: Image, 63 | pub physicist: Image, 64 | pub beast: Image, 65 | pub ghost: Image, 66 | pub surgeon: Image, 67 | pub thief: Image, 68 | pub surveyor: Image, 69 | pub shop: Image, 70 | } 71 | 72 | impl Images { 73 | pub fn new() -> Self { 74 | Self { 75 | townsfolk1: ImageName::Townsfolk1.load_grid(), 76 | grave: ImageName::Grave.load_grid(), 77 | ocean: ImageName::Ocean.load_grid(), 78 | boat: ImageName::Boat.load_grid(), 79 | soldier: ImageName::Soldier.load_grid(), 80 | physicist: ImageName::Physicist.load_grid(), 81 | beast: ImageName::Beast.load_grid(), 82 | ghost: ImageName::Ghost.load_grid(), 83 | surgeon: ImageName::Surgeon.load_grid(), 84 | thief: ImageName::Thief.load_grid(), 85 | surveyor: ImageName::Surveyor.load_grid(), 86 | shop: ImageName::Shop.load_grid(), 87 | } 88 | } 89 | 90 | pub fn image_from_menu_image(&self, menu_image: MenuImage) -> &Image { 91 | match menu_image { 92 | MenuImage::Townsperson => &self.townsfolk1, 93 | MenuImage::Grave => &self.grave, 94 | MenuImage::Shop => &self.shop, 95 | MenuImage::Npc(Npc::Soldier) => &self.soldier, 96 | MenuImage::Npc(Npc::Physicist) => &self.physicist, 97 | MenuImage::Npc(Npc::Beast) => &self.beast, 98 | MenuImage::Npc(Npc::Ghost) => &self.ghost, 99 | MenuImage::Npc(Npc::Surgeon) => &self.surgeon, 100 | MenuImage::Npc(Npc::Surveyor) => &self.surveyor, 101 | MenuImage::Npc(Npc::Thief) => &self.thief, 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /app/src/text.rs: -------------------------------------------------------------------------------- 1 | use crate::game_loop::{AppCF, State}; 2 | use boat_journey_game::GameOverReason; 3 | use chargrid::{ 4 | control_flow::*, 5 | prelude::*, 6 | text::{StyledString, Text}, 7 | }; 8 | 9 | fn text_component(width: u32, text: Vec) -> CF<(), State> { 10 | Text::new(text).wrap_word().cf().set_width(width) 11 | } 12 | 13 | pub fn help(width: u32) -> AppCF<()> { 14 | let t = |s: &str| StyledString { 15 | string: s.to_string(), 16 | style: Style::plain_text(), 17 | }; 18 | let b = |s: &str| StyledString { 19 | string: s.to_string(), 20 | style: Style::plain_text().with_bold(true), 21 | }; 22 | text_component( 23 | width, 24 | vec![ 25 | b("Controls:\n\n"), 26 | b("General\n"), 27 | t("Wait: Space\n"), 28 | t("Ability: 1-9\n"), 29 | t("\n"), 30 | b("On Foot\n"), 31 | t("Walk: Arrow Keys\n"), 32 | t("Drive Boat: e\n"), 33 | t("\n"), 34 | b("Driving Boat\n"), 35 | t("Move: Forward/Backward\n"), 36 | t("Turn: Left/Right\n"), 37 | t("Leave Boat: e\n"), 38 | b("\n\nTips:\n\n"), 39 | t("- Walk into a door (+) to open it\n"), 40 | t("- Walk into the wall next to a door to close the door\n"), 41 | t("- Head to the inn when it gets dark\n"), 42 | ], 43 | ) 44 | .press_any_key() 45 | } 46 | 47 | pub fn loading(width: u32) -> AppCF<()> { 48 | let t = |s: &str| StyledString { 49 | string: s.to_string(), 50 | style: Style::plain_text(), 51 | }; 52 | text_component(width, vec![t("Generating...")]).delay(Duration::from_millis(32)) 53 | } 54 | 55 | pub fn saving(width: u32) -> AppCF<()> { 56 | let t = |s: &str| StyledString { 57 | string: s.to_string(), 58 | style: Style::plain_text(), 59 | }; 60 | text_component(width, vec![t("Saving...")]).delay(Duration::from_millis(32)) 61 | } 62 | 63 | fn game_over_text(width: u32, reason: GameOverReason) -> CF<(), State> { 64 | let t = |s: &str| StyledString { 65 | string: s.to_string(), 66 | style: Style::plain_text(), 67 | }; 68 | let text = match reason { 69 | GameOverReason::OutOfFuel => vec! { 70 | t("You fail to reach the ocean.\n\n"), 71 | t("The boat sputters to a halt as the last dregs of fuel are consumed.\n\n"), 72 | t("Over time you make a home aboard the stationary boat and hope that one day someone will pick you up and take you to the ocean."),}, 73 | GameOverReason::KilledByGhost => vec!{ 74 | t("You fail to reach the ocean.\n\n"), 75 | t("At the icy touch of the ghost you lose your corporeal form.\n\n"), 76 | t("You lose sight of the boat as an unfamiliar figure drives it into the darkness."), 77 | }, 78 | GameOverReason::KilledByBeast => vec!{ 79 | t("You fail to reach the ocean.\n\n"), 80 | t("You are wounded by the beast and unable to return to your boat.\n\n"), 81 | t("Over time your wounds heal but you no longer wish to travel to the ocean and don't understand why anyone would."), 82 | }, 83 | GameOverReason::Abandoned => vec!{ 84 | t("You fail to reach the ocean and decide to remain in the inn.\n\n"), 85 | }, 86 | GameOverReason::KilledBySoldier => vec!{ 87 | t("You fail to reach the ocean.\n\n"), 88 | t("You were caught in the soldier's blast.\n\n"), 89 | }, 90 | }; 91 | text_component(width, text) 92 | } 93 | 94 | pub fn game_over(width: u32, reason: GameOverReason) -> AppCF<()> { 95 | game_over_text(width, reason) 96 | .delay(Duration::from_secs(2)) 97 | .then(move || game_over_text(width, reason).press_any_key()) 98 | } 99 | 100 | fn sleep_text(width: u32, i: u32) -> CF<(), State> { 101 | let t = |s: &str| StyledString { 102 | string: s.to_string(), 103 | style: Style::plain_text(), 104 | }; 105 | let _ = i; 106 | let text = vec![t( 107 | "Your passengers come in from the cold and you rest in the inn until morning.\n\n\ 108 | The ghosts disappear and your health is restored.\n\n\ 109 | Passenger actions are refreshed.\n\n\ 110 | Press any key...", 111 | )]; 112 | text_component(width, text) 113 | } 114 | 115 | pub fn sleep(width: u32, i: u32) -> AppCF<()> { 116 | sleep_text(width, i) 117 | .delay(Duration::from_millis(100)) 118 | .then(move || sleep_text(width, i).press_any_key()) 119 | } 120 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | push: 4 | tags: 5 | - '*' 6 | env: 7 | CARGO_TERM_COLOR: always 8 | 9 | jobs: 10 | 11 | release-windows: 12 | runs-on: windows-latest 13 | steps: 14 | - uses: hecrj/setup-rust-action@v1 15 | with: 16 | rust-version: stable 17 | targets: x86_64-pc-windows-gnu 18 | - uses: actions/checkout@v3 19 | - run: cargo build --manifest-path=wgpu/Cargo.toml --target=x86_64-pc-windows-gnu --release 20 | - run: cargo build --manifest-path=ggez/Cargo.toml --target=x86_64-pc-windows-gnu --release 21 | - run: echo OUTPUT_DIR=boat-journey-windows-x86_64-${{ github.ref_name }} >> $env:GITHUB_ENV 22 | - run: mkdir x 23 | - run: mkdir x\$env:OUTPUT_DIR 24 | - run: copy-item extras\windows\* x\$env:OUTPUT_DIR 25 | - run: copy-item target\x86_64-pc-windows-gnu\release\boat_journey_wgpu.exe x\$env:OUTPUT_DIR\boat_journey.exe 26 | - run: copy-item target\x86_64-pc-windows-gnu\release\boat_journey_ggez.exe x\$env:OUTPUT_DIR\boat-journey-compatibility.exe 27 | - run: Add-Type -A 'System.IO.Compression.FileSystem'; [IO.Compression.ZipFile]::CreateFromDirectory('x', $env:OUTPUT_DIR + '.zip'); 28 | - uses: ncipollo/release-action@v1 29 | with: 30 | allowUpdates: true 31 | artifacts: "*.zip" 32 | 33 | release-web: 34 | runs-on: ubuntu-latest 35 | steps: 36 | - uses: hecrj/setup-rust-action@v1 37 | with: 38 | rust-version: stable 39 | - uses: actions/checkout@v3 40 | - name: 'Build wasm version' 41 | run: | 42 | pushd web 43 | npm install 44 | NODE_OPTIONS=--openssl-legacy-provider npm run build-production 45 | ARCHIVE_NAME=boat-journey-web-${{ github.ref_name }} 46 | mv dist $ARCHIVE_NAME 47 | zip -r $ARCHIVE_NAME.zip $ARCHIVE_NAME 48 | popd 49 | mv web/$ARCHIVE_NAME.zip . 50 | - uses: ncipollo/release-action@v1 51 | with: 52 | allowUpdates: true 53 | artifacts: "*.zip" 54 | 55 | release-macos-aarch64: 56 | runs-on: macOS-latest 57 | steps: 58 | - uses: hecrj/setup-rust-action@v1 59 | with: 60 | rust-version: stable 61 | targets: aarch64-apple-darwin 62 | - uses: actions/checkout@v3 63 | - name: 'Build graphical version' 64 | run: cargo build --target=aarch64-apple-darwin --manifest-path=wgpu/Cargo.toml --release 65 | - name: 'Build graphical version (compatibility)' 66 | run: cargo build --target=aarch64-apple-darwin --manifest-path=ggez/Cargo.toml --release 67 | - name: 'Build ansi-terminal version' 68 | run: cargo build --target=aarch64-apple-darwin --manifest-path=ansi-terminal/Cargo.toml --release 69 | - name: 'Strip binaries' 70 | run: | 71 | strip -v target/aarch64-apple-darwin/release/boat_journey_wgpu 72 | strip -v target/aarch64-apple-darwin/release/boat_journey_ggez 73 | strip -v target/aarch64-apple-darwin/release/boat_journey_ansi_terminal 74 | - name: 'Make archives' 75 | run: MODE=aarch64-apple-darwin/release ARCHIVE_NAME=boat-journey-macos-aarch64-${{ github.ref_name }} scripts/make_archives_unix.sh 76 | - name: 'Make app and disk image' 77 | run: MODE=aarch64-apple-darwin/release APP_NAME=BoatJourney DMG_NAME=BoatJourney-macos-aarch64-${{ github.ref_name }}.dmg scripts/make_dmg_macos.sh 78 | - uses: ncipollo/release-action@v1 79 | with: 80 | allowUpdates: true 81 | artifacts: "*.tar.gz,*.zip,*.dmg" 82 | 83 | release-unix-x86_64: 84 | runs-on: ${{ matrix.os }} 85 | strategy: 86 | matrix: 87 | os: [ubuntu-latest, macOS-latest] 88 | steps: 89 | - if: matrix.os == 'ubuntu-latest' 90 | name: 'Install dependencies (ubuntu)' 91 | run: | 92 | sudo apt update 93 | sudo apt install libudev-dev libasound2-dev 94 | - if: matrix.os == 'ubuntu-latest' 95 | name: 'Set system name (ubuntu)' 96 | run: echo SYSTEM_NAME=linux >> $GITHUB_ENV 97 | - if: matrix.os == 'macOS-latest' 98 | name: 'Set system name (macOS)' 99 | run: echo SYSTEM_NAME=macos >> $GITHUB_ENV 100 | - uses: hecrj/setup-rust-action@v1 101 | with: 102 | rust-version: stable 103 | - uses: actions/checkout@v3 104 | - name: 'Build graphical version' 105 | run: cargo build --manifest-path=wgpu/Cargo.toml --release 106 | - name: 'Build graphical version (compatibility)' 107 | run: cargo build --manifest-path=ggez/Cargo.toml --release 108 | - name: 'Build ansi-terminal version' 109 | run: cargo build --manifest-path=ansi-terminal/Cargo.toml --release 110 | - name: 'Strip binaries' 111 | run: | 112 | strip -v target/release/boat_journey_wgpu 113 | strip -v target/release/boat_journey_ggez 114 | strip -v target/release/boat_journey_ansi_terminal 115 | - name: 'Make archives' 116 | run: MODE=release ARCHIVE_NAME=boat-journey-${{ env.SYSTEM_NAME }}-x86_64-${{ github.ref_name }} scripts/make_archives_unix.sh 117 | - if: matrix.os == 'macOS-latest' 118 | name: 'Make macos app and disk image' 119 | run: MODE=release APP_NAME=BoatJourney DMG_NAME=BoatJourney-macos-x86_64-${{ github.ref_name }}.dmg scripts/make_dmg_macos.sh 120 | - uses: ncipollo/release-action@v1 121 | with: 122 | allowUpdates: true 123 | artifacts: "*.tar.gz,*.zip,*.dmg" 124 | -------------------------------------------------------------------------------- /procgen/examples/run.rs: -------------------------------------------------------------------------------- 1 | use chargrid::{control_flow::*, core::*}; 2 | use chargrid_ansi_terminal::{col_encode, Context}; 3 | use grid_2d::Size; 4 | use procgen::{generate, Spec, Terrain}; 5 | use rand::{Rng, SeedableRng}; 6 | use rand_isaac::Isaac64Rng; 7 | use rgb_int::Rgba32; 8 | 9 | struct Args { 10 | size: Size, 11 | rng: Isaac64Rng, 12 | } 13 | 14 | impl Args { 15 | fn parser() -> impl meap::Parser { 16 | meap::let_map! { 17 | let { 18 | rng_seed = opt_opt::("INT", 'r').name("rng-seed").desc("rng seed") 19 | .with_default_lazy_general(|| rand::thread_rng().gen()); 20 | width = opt_opt("INT", 'x').name("width").with_default(20); 21 | height = opt_opt("INT", 'y').name("height").with_default(14); 22 | } in {{ 23 | eprintln!("RNG Seed: {}", rng_seed); 24 | let rng = Isaac64Rng::seed_from_u64(rng_seed); 25 | let size = Size::new(width, height); 26 | Self { 27 | rng, 28 | size, 29 | } 30 | }} 31 | } 32 | } 33 | } 34 | 35 | fn app(terrain: Terrain) -> App { 36 | render(move |ctx, fb| { 37 | /* 38 | let mut max_height = 0f64; 39 | for coord in terrain.land.cells.coord_iter() { 40 | max_height = max_height.max(terrain.land.get_height(coord).unwrap()); 41 | } 42 | for (y, row) in terrain.land.cells.rows().enumerate() { 43 | for (x, _cell) in row.into_iter().enumerate() { 44 | let coord = Coord::new(x as i32, y as i32); 45 | let height = terrain.land.get_height(coord).unwrap(); 46 | let bg = Rgba32::new_grey(((height * 255.) / max_height) as u8); 47 | let render_cell = RenderCell::default().with_background(bg); 48 | fb.set_cell_relative_to_ctx(ctx, coord, 0, render_cell); 49 | } 50 | } 51 | for &coord in &terrain.river { 52 | let bg = Rgba32::new(0, 0, 255, 255); 53 | let render_cell = RenderCell::default().with_background(bg); 54 | fb.set_cell_relative_to_ctx(ctx, coord, 0, render_cell); 55 | } 56 | for (coord, &cell) in terrain.world1.enumerate() { 57 | use procgen::WorldCell1; 58 | let render_cell = match cell { 59 | WorldCell1::Land => RenderCell::default().with_character('#'), 60 | WorldCell1::Water => RenderCell::default() 61 | .with_character('~') 62 | .with_background(Rgba32::new_rgb(0, 255, 255)), 63 | }; 64 | fb.set_cell_relative_to_ctx(ctx, coord, 0, render_cell); 65 | }*/ 66 | /* 67 | for &coord in &terrain.blob.inside { 68 | let render_cell = RenderCell::default().with_character('#'); 69 | fb.set_cell_relative_to_ctx(ctx, coord, 0, render_cell); 70 | }*/ 71 | for offset in terrain.viz_size.coord_iter_row_major() { 72 | use procgen::WorldCell3; 73 | let coord = terrain.viz_coord + offset; 74 | if let Some(&cell) = terrain.world3.grid.get(coord) { 75 | let render_cell = match cell { 76 | WorldCell3::Ground => RenderCell::default() 77 | .with_character('.') 78 | .with_background(Rgba32::new(0, 127, 0, 255)), 79 | WorldCell3::Grave => RenderCell::default() 80 | .with_character('!') 81 | .with_background(Rgba32::new(127, 127, 127, 255)), 82 | WorldCell3::TownGround => RenderCell::default() 83 | .with_character('.') 84 | .with_background(Rgba32::new(87, 127, 0, 255)), 85 | WorldCell3::Floor => RenderCell::default() 86 | .with_character('.') 87 | .with_background(Rgba32::new(127, 127, 127, 255)), 88 | WorldCell3::Water(_) => RenderCell::default() 89 | .with_character('~') 90 | .with_background(Rgba32::new_rgb(0, 0, 255)), 91 | WorldCell3::Wall | WorldCell3::Gate => { 92 | RenderCell::default().with_character('#') 93 | } 94 | WorldCell3::Door => RenderCell::default().with_character('+'), 95 | WorldCell3::StairsDown => RenderCell::default().with_character('>'), 96 | WorldCell3::StairsUp => RenderCell::default().with_character('<'), 97 | }; 98 | fb.set_cell_relative_to_ctx(ctx, offset, 0, render_cell); 99 | } 100 | if coord == terrain.world3.spawn { 101 | let render_cell = RenderCell::default().with_character('@'); 102 | fb.set_cell_relative_to_ctx(ctx, offset, 0, render_cell); 103 | } 104 | } 105 | }) 106 | .press_any_key() 107 | .map(|()| app::Exit) 108 | } 109 | 110 | fn run(terrain: Terrain) { 111 | let context = Context::new().unwrap(); 112 | let app = app(terrain); 113 | context.run(app, col_encode::XtermTrueColour); 114 | } 115 | 116 | fn main() { 117 | use meap::Parser; 118 | let Args { size, mut rng } = Args::parser().with_help_default().parse_env_or_exit(); 119 | let spec = Spec { 120 | size, 121 | num_graves: 2, 122 | }; 123 | let terrain = generate(&spec, &mut rng); 124 | run(terrain); 125 | } 126 | -------------------------------------------------------------------------------- /game/src/witness.rs: -------------------------------------------------------------------------------- 1 | use crate::{ActionError, Config, GameControlFlow, GameOverReason, Input, Menu as GameMenu, Npc}; 2 | use coord_2d::Coord; 3 | use direction::CardinalDirection; 4 | use rand::Rng; 5 | use serde::{Deserialize, Serialize}; 6 | use std::time::Duration; 7 | 8 | pub struct Game { 9 | inner_game: crate::Game, 10 | } 11 | 12 | #[derive(Serialize, Deserialize)] 13 | pub struct RunningGame { 14 | game: crate::Game, 15 | } 16 | 17 | impl RunningGame { 18 | pub fn new(game: Game, running: Running) -> Self { 19 | let _ = running; 20 | Self { 21 | game: game.inner_game, 22 | } 23 | } 24 | 25 | pub fn into_game(self) -> (Game, Running) { 26 | ( 27 | Game { 28 | inner_game: self.game, 29 | }, 30 | Running(Private), 31 | ) 32 | } 33 | } 34 | 35 | #[derive(Debug)] 36 | struct Private; 37 | 38 | #[derive(Debug)] 39 | pub struct Running(Private); 40 | 41 | #[derive(Debug)] 42 | pub struct Win(Private); 43 | 44 | #[derive(Debug)] 45 | pub struct Menu { 46 | private: Private, 47 | pub menu: GameMenu, 48 | } 49 | 50 | #[derive(Debug)] 51 | pub struct Aim { 52 | private: Private, 53 | pub npc: Npc, 54 | } 55 | 56 | #[derive(Debug)] 57 | pub enum Witness { 58 | Running(Running), 59 | GameOver(GameOverReason), 60 | Win(Win), 61 | Menu(Menu), 62 | Aim(Aim), 63 | } 64 | 65 | impl Witness { 66 | fn running(private: Private) -> Self { 67 | Self::Running(Running(private)) 68 | } 69 | } 70 | 71 | impl Menu { 72 | pub fn cancel(self) -> Witness { 73 | let Self { private, .. } = self; 74 | Witness::running(private) 75 | } 76 | pub fn commit(self, game: &mut Game, choice: crate::MenuChoice) -> Witness { 77 | let Self { private, .. } = self; 78 | game.witness_handle_choice(choice, private) 79 | } 80 | } 81 | 82 | impl Aim { 83 | pub fn cancel(self) -> Witness { 84 | let Self { private, .. } = self; 85 | Witness::running(private) 86 | } 87 | pub fn commit(self, game: &mut Game, coord: Coord) -> Witness { 88 | let Self { private, npc } = self; 89 | game.witness_handle_aim(npc, coord, private) 90 | } 91 | } 92 | 93 | pub enum ControlInput { 94 | Walk(CardinalDirection), 95 | Wait, 96 | } 97 | 98 | pub fn new_game( 99 | config: &Config, 100 | victories: Vec, 101 | base_rng: &mut R, 102 | ) -> (Game, Running) { 103 | let g = Game { 104 | inner_game: crate::Game::new(config, victories, base_rng), 105 | }; 106 | (g, Running(Private)) 107 | } 108 | 109 | impl Win { 110 | pub fn into_running(self) -> Running { 111 | Running(self.0) 112 | } 113 | } 114 | 115 | impl Running { 116 | pub fn new_panics() -> Self { 117 | panic!("this constructor is meant for temporary use during debugging to get the code to compile") 118 | } 119 | 120 | /// Call this method with the knowledge that you have sinned 121 | pub fn cheat() -> Self { 122 | Self(Private) 123 | } 124 | 125 | pub fn into_witness(self) -> Witness { 126 | Witness::Running(self) 127 | } 128 | 129 | pub fn tick(self, game: &mut Game, since_last_tick: Duration, config: &Config) -> Witness { 130 | let Self(private) = self; 131 | game.witness_handle_tick(since_last_tick, config, private) 132 | } 133 | 134 | pub fn walk( 135 | self, 136 | game: &mut Game, 137 | direction: CardinalDirection, 138 | config: &Config, 139 | ) -> (Witness, Result<(), ActionError>) { 140 | let Self(private) = self; 141 | game.witness_handle_input(Input::Walk(direction), config, private) 142 | } 143 | 144 | pub fn wait(self, game: &mut Game, config: &Config) -> (Witness, Result<(), ActionError>) { 145 | let Self(private) = self; 146 | game.witness_handle_input(Input::Wait, config, private) 147 | } 148 | 149 | pub fn drive_toggle( 150 | self, 151 | game: &mut Game, 152 | config: &Config, 153 | ) -> (Witness, Result<(), ActionError>) { 154 | let Self(private) = self; 155 | game.witness_handle_input(Input::DriveToggle, config, private) 156 | } 157 | 158 | pub fn ability( 159 | self, 160 | game: &mut Game, 161 | config: &Config, 162 | index: u8, 163 | ) -> (Witness, Result<(), ActionError>) { 164 | let Self(private) = self; 165 | game.witness_handle_input(Input::Ability(index), config, private) 166 | } 167 | } 168 | 169 | impl Game { 170 | fn witness_handle_input( 171 | &mut self, 172 | input: Input, 173 | config: &Config, 174 | private: Private, 175 | ) -> (Witness, Result<(), ActionError>) { 176 | match self.inner_game.handle_input(input, config) { 177 | Err(e) => (Witness::running(private), Err(e)), 178 | Ok(None) => (Witness::running(private), Ok(())), 179 | Ok(Some(GameControlFlow::GameOver(reason))) => (Witness::GameOver(reason), Ok(())), 180 | Ok(Some(GameControlFlow::Menu(menu))) => { 181 | (Witness::Menu(Menu { private, menu }), Ok(())) 182 | } 183 | Ok(Some(GameControlFlow::Aim(npc))) => (Witness::Aim(Aim { private, npc }), Ok(())), 184 | Ok(Some(other)) => panic!("unhandled control flow {:?}", other), 185 | } 186 | } 187 | 188 | fn handle_control_flow( 189 | &mut self, 190 | control_flow: Option, 191 | private: Private, 192 | ) -> Witness { 193 | match control_flow { 194 | None => Witness::running(private), 195 | Some(GameControlFlow::GameOver(reason)) => Witness::GameOver(reason), 196 | Some(GameControlFlow::Win) => Witness::Win(Win(private)), 197 | Some(GameControlFlow::Menu(menu)) => Witness::Menu(Menu { private, menu }), 198 | Some(GameControlFlow::Aim(npc)) => Witness::Aim(Aim { private, npc }), 199 | } 200 | } 201 | 202 | fn witness_handle_tick( 203 | &mut self, 204 | since_last_tick: Duration, 205 | config: &Config, 206 | private: Private, 207 | ) -> Witness { 208 | let control_flow = self.inner_game.handle_tick(since_last_tick, config); 209 | self.handle_control_flow(control_flow, private) 210 | } 211 | 212 | fn witness_handle_choice(&mut self, choice: crate::MenuChoice, private: Private) -> Witness { 213 | let control_flow = self.inner_game.handle_choice(choice); 214 | self.handle_control_flow(control_flow, private) 215 | } 216 | 217 | fn witness_handle_aim(&mut self, npc: Npc, target: Coord, private: Private) -> Witness { 218 | let control_flow = self.inner_game.handle_aim(npc, target); 219 | self.handle_control_flow(control_flow, private) 220 | } 221 | 222 | pub fn inner_ref(&self) -> &crate::Game { 223 | &self.inner_game 224 | } 225 | 226 | pub fn into_running_game(self, running: Running) -> RunningGame { 227 | RunningGame::new(self, running) 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /game/src/world/spawn.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | world::{ 3 | data::{DoorState, EntityData, Junk, Layer, Location, Npc, Tile}, 4 | World, 5 | }, 6 | Entity, 7 | }; 8 | use coord_2d::Coord; 9 | use entity_table::entity_data; 10 | 11 | pub fn make_player() -> EntityData { 12 | EntityData { 13 | tile: Some(Tile::Player), 14 | ..Default::default() 15 | } 16 | } 17 | 18 | impl World { 19 | pub fn insert_entity_data(&mut self, location: Location, entity_data: EntityData) -> Entity { 20 | let entity = self.entity_allocator.alloc(); 21 | self.spatial_table.update(entity, location).unwrap(); 22 | self.components.insert_entity_data(entity, entity_data); 23 | entity 24 | } 25 | 26 | fn spawn_entity>(&mut self, location: L, entity_data: EntityData) -> Entity { 27 | let entity = self.entity_allocator.alloc(); 28 | let location @ Location { layer, coord } = location.into(); 29 | if let Err(e) = self.spatial_table.update(entity, location) { 30 | panic!("{:?}: There is already a {:?} at {:?}", e, layer, coord); 31 | } 32 | self.components.insert_entity_data(entity, entity_data); 33 | entity 34 | } 35 | 36 | fn spawn_water(&mut self, coord: Coord, tile: Tile) -> Entity { 37 | self.spawn_entity( 38 | (coord, Layer::Water), 39 | entity_data! { 40 | tile, 41 | }, 42 | ) 43 | } 44 | 45 | pub fn spawn_water1(&mut self, coord: Coord) -> Entity { 46 | self.spawn_water(coord, Tile::Water1) 47 | } 48 | 49 | pub fn spawn_water2(&mut self, coord: Coord) -> Entity { 50 | self.spawn_water(coord, Tile::Water2) 51 | } 52 | 53 | fn spawn_ocean_water(&mut self, coord: Coord, tile: Tile) -> Entity { 54 | self.spawn_entity( 55 | (coord, Layer::Water), 56 | entity_data! { 57 | tile, 58 | ocean: (), 59 | }, 60 | ) 61 | } 62 | 63 | pub fn spawn_ocean_water1(&mut self, coord: Coord) -> Entity { 64 | self.spawn_ocean_water(coord, Tile::Water1) 65 | } 66 | 67 | pub fn spawn_ocean_water2(&mut self, coord: Coord) -> Entity { 68 | self.spawn_ocean_water(coord, Tile::Water2) 69 | } 70 | 71 | pub fn spawn_wall(&mut self, coord: Coord) -> Entity { 72 | self.spawn_entity( 73 | (coord, Layer::Feature), 74 | entity_data! { 75 | tile: Tile::Wall, 76 | solid: (), 77 | opacity: 255, 78 | }, 79 | ) 80 | } 81 | 82 | pub fn spawn_gate(&mut self, coord: Coord) -> Entity { 83 | self.spawn_entity( 84 | (coord, Layer::Feature), 85 | entity_data! { 86 | tile: Tile::Wall, 87 | solid: (), 88 | opacity: 255, 89 | gate: (), 90 | }, 91 | ) 92 | } 93 | 94 | pub fn spawn_floor(&mut self, coord: Coord) -> Entity { 95 | self.spawn_entity( 96 | (coord, Layer::Floor), 97 | entity_data! { 98 | tile: Tile::Floor, 99 | }, 100 | ) 101 | } 102 | 103 | pub fn spawn_door(&mut self, coord: Coord) -> Entity { 104 | self.spawn_entity( 105 | (coord, Layer::Feature), 106 | entity_data! { 107 | tile: Tile::DoorClosed, 108 | solid: (), 109 | door_state: DoorState::Closed, 110 | opacity: 255, 111 | }, 112 | ) 113 | } 114 | 115 | pub fn spawn_player_door(&mut self, coord: Coord) -> Entity { 116 | self.spawn_entity( 117 | (coord, Layer::Feature), 118 | entity_data! { 119 | tile: Tile::DoorClosed, 120 | solid: (), 121 | door_state: DoorState::Closed, 122 | opacity: 255, 123 | threshold: (), 124 | }, 125 | ) 126 | } 127 | 128 | pub fn spawn_boat_floor(&mut self, coord: Coord) -> Entity { 129 | self.spawn_entity( 130 | (coord, Layer::Floor), 131 | entity_data! { 132 | tile: Tile::BoatFloor, 133 | part_of_boat: (), 134 | }, 135 | ) 136 | } 137 | 138 | pub fn spawn_boat_edge(&mut self, coord: Coord) -> Entity { 139 | self.spawn_entity( 140 | (coord, Layer::Feature), 141 | entity_data! { 142 | tile: Tile::BoatEdge, 143 | solid: (), 144 | part_of_boat: (), 145 | }, 146 | ) 147 | } 148 | 149 | pub fn spawn_boat_wall(&mut self, coord: Coord) -> Entity { 150 | self.spawn_entity( 151 | (coord, Layer::Feature), 152 | entity_data! { 153 | tile: Tile::Wall, 154 | solid: (), 155 | part_of_boat: (), 156 | opacity: 255, 157 | }, 158 | ) 159 | } 160 | 161 | pub fn spawn_board(&mut self, coord: Coord) -> Entity { 162 | self.spawn_entity( 163 | (coord, Layer::Boat), 164 | entity_data! { 165 | tile: Tile::Board, 166 | part_of_boat: (), 167 | }, 168 | ) 169 | } 170 | 171 | pub fn spawn_boat_controls(&mut self, coord: Coord) -> Entity { 172 | self.spawn_entity( 173 | (coord, Layer::Floor), 174 | entity_data! { 175 | tile: Tile::BoatControls, 176 | part_of_boat: (), 177 | boat_controls: (), 178 | }, 179 | ) 180 | } 181 | 182 | pub fn spawn_tree(&mut self, coord: Coord) -> Entity { 183 | self.spawn_entity( 184 | (coord, Layer::Feature), 185 | entity_data! { 186 | tile: Tile::Tree, 187 | solid: (), 188 | opacity: 100, 189 | destructible: (), 190 | }, 191 | ) 192 | } 193 | 194 | pub fn spawn_stairs_down(&mut self, coord: Coord, index: usize) -> Entity { 195 | self.spawn_entity( 196 | (coord, Layer::Feature), 197 | entity_data! { 198 | tile: Tile::StairsDown, 199 | stairs_down: index, 200 | }, 201 | ) 202 | } 203 | 204 | pub fn spawn_stairs_up(&mut self, coord: Coord) -> Entity { 205 | self.spawn_entity( 206 | (coord, Layer::Feature), 207 | entity_data! { 208 | tile: Tile::StairsUp, 209 | stairs_up: (), 210 | }, 211 | ) 212 | } 213 | 214 | pub fn spawn_ghost(&mut self, coord: Coord) -> Entity { 215 | self.spawn_entity( 216 | (coord, Layer::Character), 217 | entity_data! { 218 | tile: Tile::Ghost, 219 | ghost: (), 220 | }, 221 | ) 222 | } 223 | 224 | pub fn spawn_beast(&mut self, coord: Coord) -> Entity { 225 | self.spawn_entity( 226 | (coord, Layer::Character), 227 | entity_data! { 228 | tile: Tile::Beast, 229 | beast: (), 230 | destructible: (), 231 | }, 232 | ) 233 | } 234 | 235 | pub fn spawn_unimportant_npc(&mut self, coord: Coord) -> Entity { 236 | self.spawn_entity( 237 | (coord, Layer::Character), 238 | entity_data! { 239 | tile: Tile::UnimportantNpc, 240 | unimportant_npc: (), 241 | }, 242 | ) 243 | } 244 | 245 | pub fn spawn_grave(&mut self, coord: Coord, victory: crate::Victory) -> Entity { 246 | self.spawn_entity( 247 | (coord, Layer::Feature), 248 | entity_data! { 249 | tile: Tile::Grave, 250 | grave: victory, 251 | }, 252 | ) 253 | } 254 | 255 | pub fn spawn_npc(&mut self, coord: Coord, npc: Npc) -> Entity { 256 | self.spawn_entity( 257 | (coord, Layer::Character), 258 | entity_data! { 259 | tile: Tile::Npc(npc), 260 | npc, 261 | }, 262 | ) 263 | } 264 | 265 | pub fn spawn_junk(&mut self, coord: Coord, junk: Junk) -> Entity { 266 | self.spawn_entity( 267 | (coord, Layer::Item), 268 | entity_data! { 269 | tile: Tile::Junk, 270 | junk, 271 | }, 272 | ) 273 | } 274 | 275 | pub fn spawn_shop(&mut self, coord: Coord, i: usize) -> Entity { 276 | self.spawn_entity( 277 | (coord, Layer::Character), 278 | entity_data! { 279 | tile: Tile::Shop, 280 | shop: i, 281 | }, 282 | ) 283 | } 284 | 285 | pub fn spawn_button(&mut self, coord: Coord) -> Entity { 286 | self.spawn_entity( 287 | (coord, Layer::Feature), 288 | entity_data! { 289 | tile: Tile::Button, 290 | button: false, 291 | }, 292 | ) 293 | } 294 | } 295 | -------------------------------------------------------------------------------- /game/src/world/data.rs: -------------------------------------------------------------------------------- 1 | pub use crate::world::spatial::{Layer, Location}; 2 | use coord_2d::Coord; 3 | use entity_table::declare_entity_module; 4 | use line_2d::InfiniteStepIter; 5 | use serde::{Deserialize, Serialize}; 6 | use vector::{Radial, Radians}; 7 | 8 | declare_entity_module! { 9 | components { 10 | tile: Tile, 11 | boat: Boat, 12 | solid: (), 13 | part_of_boat: (), 14 | door_state: DoorState, 15 | opacity: u8, 16 | boat_controls: (), 17 | ocean: (), 18 | stairs_down: usize, 19 | stairs_up: (), 20 | ghost: (), 21 | unimportant_npc: (), 22 | threshold: (), 23 | grave: crate::Victory, 24 | npc: Npc, 25 | junk: Junk, 26 | inside: (), 27 | shop: usize, 28 | button: bool, 29 | gate: (), 30 | beast: (), 31 | destructible: (), 32 | } 33 | } 34 | pub use components::{Components, EntityData, EntityUpdate}; 35 | 36 | #[derive(Debug, Clone, Copy, Serialize, Deserialize)] 37 | pub enum Junk { 38 | RotaryPhone, 39 | BrokenTypewriter, 40 | PolaroidCamera, 41 | VhsTape, 42 | VinylRecord, 43 | CassettePlayer, 44 | } 45 | 46 | impl Junk { 47 | pub fn name(self) -> String { 48 | let s = match self { 49 | Self::RotaryPhone => "rotary telephone", 50 | Self::BrokenTypewriter => "broken typewriter", 51 | Self::PolaroidCamera => "polaroid camera", 52 | Self::VhsTape => "VHS tape", 53 | Self::VinylRecord => "vinyl record", 54 | Self::CassettePlayer => "cassette player", 55 | }; 56 | s.to_string() 57 | } 58 | pub fn all() -> Vec { 59 | vec![ 60 | Junk::RotaryPhone, 61 | Junk::BrokenTypewriter, 62 | Junk::PolaroidCamera, 63 | Junk::VhsTape, 64 | Junk::VinylRecord, 65 | Junk::CassettePlayer, 66 | ] 67 | } 68 | } 69 | 70 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)] 71 | pub enum Tile { 72 | Player, 73 | BoatEdge, 74 | BoatFloor, 75 | Water1, 76 | Water2, 77 | Floor, 78 | BurntFloor, 79 | Wall, 80 | DoorClosed, 81 | DoorOpen, 82 | Rock, 83 | Board, 84 | BoatControls, 85 | Tree, 86 | StairsDown, 87 | StairsUp, 88 | Ghost, 89 | UnimportantNpc, 90 | Grave, 91 | Npc(Npc), 92 | Junk, 93 | Shop, 94 | Button, 95 | ButtonPressed, 96 | Beast, 97 | } 98 | 99 | #[derive(Debug, Clone, Serialize, Deserialize)] 100 | pub struct Boat { 101 | heading: Radians, 102 | movement_iter: InfiniteStepIter, 103 | } 104 | 105 | impl Boat { 106 | fn sync_movement_iter(&self) -> Self { 107 | let mut ret = self.clone(); 108 | let movement_delta = Radial { 109 | length: 1000f64, 110 | angle: Radians(self.heading.0 - std::f64::consts::FRAC_PI_2), 111 | } 112 | .to_cartesian() 113 | .to_coord_round_nearest(); 114 | ret.movement_iter = InfiniteStepIter::new(movement_delta); 115 | ret 116 | } 117 | 118 | pub fn new(heading: Radians) -> Self { 119 | Self { 120 | heading, 121 | movement_iter: InfiniteStepIter::new(Coord::new(1, 0)), 122 | } 123 | .sync_movement_iter() 124 | } 125 | 126 | #[must_use] 127 | pub fn add_heading(&self, delta: Radians) -> Self { 128 | let mut ret = self.clone(); 129 | ret.heading.0 += delta.0; 130 | ret.sync_movement_iter() 131 | } 132 | 133 | pub fn heading(&self) -> Radians { 134 | self.heading 135 | } 136 | 137 | #[must_use] 138 | pub fn step(&self) -> (Self, Coord) { 139 | let mut ret = self.clone(); 140 | let coord = ret.movement_iter.step().coord(); 141 | (ret, coord) 142 | } 143 | 144 | #[must_use] 145 | pub fn step_backwards(&self) -> (Self, Coord) { 146 | let mut ret = self.clone(); 147 | let coord = ret.movement_iter.step_back().coord(); 148 | (ret, coord) 149 | } 150 | } 151 | 152 | #[derive(Debug, Clone, Serialize, Deserialize)] 153 | pub enum DoorState { 154 | Open, 155 | Closed, 156 | } 157 | 158 | #[derive(Debug, Clone, Serialize, Deserialize)] 159 | pub struct Meter { 160 | current: u32, 161 | max: u32, 162 | } 163 | 164 | impl Meter { 165 | pub fn new(current: u32, max: u32) -> Self { 166 | Self { current, max } 167 | } 168 | pub fn current_and_max(&self) -> (u32, u32) { 169 | (self.current, self.max) 170 | } 171 | pub fn current(&self) -> u32 { 172 | self.current 173 | } 174 | pub fn max(&self) -> u32 { 175 | self.max 176 | } 177 | pub fn set_current(&mut self, to: u32) { 178 | self.current = to.min(self.max); 179 | } 180 | pub fn decrease(&mut self, by: u32) { 181 | self.current = self.current.saturating_sub(by); 182 | } 183 | pub fn increase(&mut self, by: u32) { 184 | self.set_current(self.current + by); 185 | } 186 | pub fn set_max(&mut self, to: u32) { 187 | self.max = to; 188 | self.set_current(self.current); 189 | } 190 | pub fn is_empty(&self) -> bool { 191 | self.current == 0 192 | } 193 | pub fn is_full(&self) -> bool { 194 | self.current == self.max 195 | } 196 | pub fn fill(&mut self) { 197 | self.current = self.max; 198 | } 199 | } 200 | 201 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)] 202 | pub enum Npc { 203 | Soldier, 204 | Physicist, 205 | Beast, 206 | Ghost, 207 | Surgeon, 208 | Thief, 209 | Surveyor, 210 | } 211 | 212 | impl Npc { 213 | pub fn all() -> Vec { 214 | vec![ 215 | Self::Soldier, 216 | Self::Physicist, 217 | Self::Beast, 218 | Self::Ghost, 219 | Self::Surgeon, 220 | Self::Thief, 221 | Self::Surveyor, 222 | ] 223 | } 224 | pub fn name(self) -> String { 225 | match self { 226 | Self::Soldier => format!("Soldier"), 227 | Self::Physicist => format!("Physicist"), 228 | Self::Beast => format!("Beast"), 229 | Self::Ghost => format!("Ghost"), 230 | Self::Surgeon => format!("Surgeon"), 231 | Self::Thief => format!("Thief"), 232 | Self::Surveyor => format!("Surveyor"), 233 | } 234 | } 235 | pub fn ability_name(self) -> String { 236 | match self { 237 | Self::Soldier => format!("Destroy"), 238 | Self::Physicist => format!("Blink"), 239 | Self::Beast => format!("Fear"), 240 | Self::Ghost => format!("Phase"), 241 | Self::Surgeon => format!("Heal"), 242 | Self::Thief => format!("Sneak"), 243 | Self::Surveyor => format!("Telescope"), 244 | } 245 | } 246 | pub fn ability_uses(self) -> u32 { 247 | match self { 248 | Self::Soldier => 2, 249 | Self::Physicist => 2, 250 | Self::Beast => 2, 251 | Self::Ghost => 2, 252 | Self::Surgeon => 2, 253 | Self::Thief => 2, 254 | Self::Surveyor => 2, 255 | } 256 | } 257 | pub fn text(self) -> String { 258 | let name = self.name(); 259 | match self { 260 | Self::Soldier => format!( 261 | "{name}:\n\nDuty has called me to the ocean. \ 262 | Will you take me there? \ 263 | I can help you defeat your enemies or clear a path through the trees."), 264 | Self::Physicist => format!( 265 | "{name}:\n\nMy studies necessitate that I visit the ocean. \ 266 | Will you take me? \ 267 | If you take me on your boat I will let you borrow my experimental teleportation device."), 268 | Self::Beast => format!( 269 | "{name}:\n\n\ 270 | One of those...things...bit me, but jokes on them because it didn't seem to work. \ 271 | Take me to the ocean? \ 272 | I can scare away other beasts that get in your way."), 273 | Self::Ghost => format!( 274 | "{name}:\n\n\ 275 | Not all ghosts are scary. \ 276 | I just want to go to the ocean. Will you take me? \ 277 | I can briefly make you incorporeal."), 278 | Self::Surgeon => format!( 279 | "{name}:\n\n\ 280 | My skills are needed ad the ocean. \ 281 | Take me there? \ 282 | I can heal you if you get injured."), 283 | Self::Thief => format!( 284 | "{name}:\n\n\ 285 | I'm trying to escape to the ocean. \ 286 | Will you help me get there? \ 287 | With me you can sneak past your enemies."), 288 | Self::Surveyor => format!( 289 | "{name}:\n\n\ 290 | I wish to go to the ocean to map the coastline. \ 291 | Can I travel on your boat? \ 292 | I'll let you borrow my telescope."), 293 | } 294 | } 295 | } 296 | -------------------------------------------------------------------------------- /game/src/terrain.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | world::{ 3 | data::{Boat, EntityData, Junk, Npc}, 4 | spatial::{Layer, Layers, Location}, 5 | World, 6 | }, 7 | Entity, 8 | }; 9 | use coord_2d::{Coord, Size}; 10 | use entity_table::entity_data; 11 | use procgen::{ 12 | generate, generate_dungeon, Dungeon as DungeonGen, DungeonCell, Spec, WaterType, WorldCell3, 13 | }; 14 | use rand::{seq::SliceRandom, Rng}; 15 | use serde::{Deserialize, Serialize}; 16 | 17 | pub struct Terrain { 18 | pub world: World, 19 | pub player_entity: Entity, 20 | pub num_dungeons: usize, 21 | } 22 | 23 | impl Terrain { 24 | pub fn generate( 25 | player_data: EntityData, 26 | mut victories: Vec, 27 | rng: &mut R, 28 | ) -> Self { 29 | let g = generate( 30 | &Spec { 31 | size: Size::new(150, 80), 32 | num_graves: victories.len() as u32, 33 | }, 34 | rng, 35 | ); 36 | let mut world = World::new(g.world3.grid.size()); 37 | let player_entity = world.insert_entity_data( 38 | Location { 39 | coord: g.world3.spawn, 40 | layer: Some(Layer::Character), 41 | }, 42 | player_data, 43 | ); 44 | let boat_data = entity_data! { 45 | boat: Boat::new(g.world3.boat_heading), 46 | }; 47 | world.insert_entity_data( 48 | Location { 49 | coord: g.world3.boat_spawn, 50 | layer: None, 51 | }, 52 | boat_data, 53 | ); 54 | let water_visible_chance = 0.01f64; 55 | let ocean_water_visible_chance = 0.2f64; 56 | let tree_chance1 = 0.2f64; 57 | let tree_chance2 = 0.4f64; 58 | let tree_chance3 = 0.05f64; 59 | let rock_chance1 = 0.05f64; 60 | let rock_chance2 = 0.1f64; 61 | let mut num_stairs = 0; 62 | for (coord, &cell) in g.world3.grid.enumerate() { 63 | let water_distance = *g.water_distance_map.distances.get_checked(coord); 64 | if water_distance < 20 { 65 | match cell { 66 | WorldCell3::Ground => { 67 | if coord.x > g.world2.ocean_x_ofset as i32 - 5 { 68 | if rng.gen::() < rock_chance1 { 69 | world.spawn_floor(coord); 70 | } else { 71 | world.spawn_floor(coord); 72 | } 73 | } else { 74 | if water_distance > 15 { 75 | world.spawn_tree(coord); 76 | } else if water_distance > 7 { 77 | if rng.gen::() < tree_chance2 { 78 | world.spawn_tree(coord); 79 | } else if rng.gen::() < rock_chance1 { 80 | world.spawn_floor(coord); 81 | } else { 82 | world.spawn_floor(coord); 83 | } 84 | } else { 85 | if rng.gen::() < tree_chance1 { 86 | world.spawn_tree(coord); 87 | } else if rng.gen::() < rock_chance2 { 88 | world.spawn_floor(coord); 89 | } else { 90 | world.spawn_floor(coord); 91 | } 92 | } 93 | } 94 | } 95 | WorldCell3::Water(WaterType::River) => { 96 | if rng.gen::() < water_visible_chance { 97 | world.spawn_water1(coord); 98 | } else { 99 | world.spawn_water2(coord); 100 | } 101 | } 102 | WorldCell3::Water(WaterType::Ocean) => { 103 | if rng.gen::() < ocean_water_visible_chance { 104 | world.spawn_ocean_water1(coord); 105 | } else { 106 | world.spawn_ocean_water2(coord); 107 | } 108 | } 109 | WorldCell3::Door => { 110 | if coord == g.world3.your_door { 111 | world.spawn_player_door(coord); 112 | } else { 113 | world.spawn_door(coord); 114 | } 115 | } 116 | WorldCell3::Floor => { 117 | world.spawn_floor(coord); 118 | } 119 | WorldCell3::TownGround => { 120 | if rng.gen::() < tree_chance3 { 121 | world.spawn_tree(coord); 122 | } else { 123 | world.spawn_floor(coord); 124 | } 125 | } 126 | WorldCell3::Wall => { 127 | world.spawn_wall(coord); 128 | } 129 | WorldCell3::StairsDown => { 130 | num_stairs += 1; 131 | world.spawn_stairs_down(coord, num_stairs); 132 | } 133 | WorldCell3::StairsUp => { 134 | world.spawn_stairs_up(coord); 135 | } 136 | WorldCell3::Grave => { 137 | if let Some(victory) = victories.pop() { 138 | world.spawn_grave(coord, victory); 139 | } else { 140 | world.spawn_floor(coord); 141 | } 142 | } 143 | WorldCell3::Gate => { 144 | if rng.gen::() < water_visible_chance { 145 | world.spawn_water1(coord); 146 | } else { 147 | world.spawn_water2(coord); 148 | } 149 | world.spawn_gate(coord); 150 | } 151 | } 152 | } 153 | } 154 | for &coord in g.world3.unimportant_npc_spawns.iter() { 155 | world.spawn_unimportant_npc(coord); 156 | } 157 | let mut all_npcs = Npc::all(); 158 | all_npcs.shuffle(rng); 159 | for &coord in g.world3.npc_spawns.iter() { 160 | if let Some(npc) = all_npcs.pop() { 161 | world.spawn_npc(coord, npc); 162 | } 163 | } 164 | let all_junk = Junk::all(); 165 | for &coord in &g.world3.junk_spawns { 166 | world.spawn_junk(coord, *all_junk.choose(rng).unwrap()); 167 | } 168 | for &coord in &g.world3.inside_coords { 169 | if let Layers { 170 | floor: Some(floor), .. 171 | } = world.spatial_table.layers_at_checked(coord) 172 | { 173 | world.components.inside.insert(*floor, ()); 174 | } 175 | } 176 | for (i, &coord) in g.world3.shop_coords.iter().enumerate() { 177 | world.spawn_shop(coord, i); 178 | } 179 | let mut island_coords = g 180 | .world3 181 | .island_coords 182 | .iter() 183 | .cloned() 184 | .filter(|&c| world.spatial_table.layers_at_checked(c).feature.is_none()) 185 | .collect::>(); 186 | island_coords.shuffle(rng); 187 | for _ in 0..15 { 188 | if let Some(c) = island_coords.pop() { 189 | world.spawn_junk(c, *all_junk.choose(rng).unwrap()); 190 | } 191 | } 192 | for _ in 0..20 { 193 | if let Some(c) = island_coords.pop() { 194 | world.spawn_beast(c); 195 | } 196 | } 197 | let mut building_coords = g 198 | .world3 199 | .building_coords 200 | .iter() 201 | .cloned() 202 | .filter(|&c| world.spatial_table.layers_at_checked(c).feature.is_none()) 203 | .collect::>(); 204 | for _ in 0..15 { 205 | if let Some(c) = building_coords.pop() { 206 | world.spawn_junk(c, *all_junk.choose(rng).unwrap()); 207 | } 208 | } 209 | for _ in 0..20 { 210 | if let Some(c) = building_coords.pop() { 211 | world.spawn_beast(c); 212 | } 213 | } 214 | building_coords.shuffle(rng); 215 | Self { 216 | world, 217 | player_entity, 218 | num_dungeons: num_stairs, 219 | } 220 | } 221 | } 222 | 223 | #[derive(Serialize, Deserialize)] 224 | pub struct Dungeon { 225 | pub world: World, 226 | pub spawn: Coord, 227 | } 228 | 229 | impl Dungeon { 230 | pub fn generate(rng: &mut R) -> Self { 231 | let size = Size::new(30, 30); 232 | let mut world = World::new(size); 233 | let DungeonGen { 234 | grid, 235 | spawn, 236 | destination, 237 | mut other_room_centres, 238 | } = generate_dungeon(size, rng); 239 | for (coord, &cell) in grid.enumerate() { 240 | match cell { 241 | DungeonCell::Door => { 242 | world.spawn_door(coord); 243 | } 244 | DungeonCell::Wall => { 245 | world.spawn_wall(coord); 246 | } 247 | DungeonCell::Floor => { 248 | world.spawn_floor(coord); 249 | } 250 | } 251 | } 252 | world.spawn_stairs_up(spawn); 253 | world.spawn_button(destination); 254 | let num_beasts = 3; 255 | other_room_centres.shuffle(rng); 256 | for _ in 0..num_beasts { 257 | if let Some(coord) = other_room_centres.pop() { 258 | world.spawn_beast(coord); 259 | } 260 | } 261 | let num_junk = 3; 262 | let all_junk = Junk::all(); 263 | for _ in 0..num_junk { 264 | if let Some(coord) = other_room_centres.pop() { 265 | world.spawn_junk(coord, *all_junk.choose(rng).unwrap()); 266 | } 267 | } 268 | Self { world, spawn } 269 | } 270 | } 271 | -------------------------------------------------------------------------------- /procgen/src/rooms_and_corridors.rs: -------------------------------------------------------------------------------- 1 | use coord_2d::{Axis, Coord, Size}; 2 | use direction::CardinalDirection; 3 | use grid_2d::Grid; 4 | use rand::{seq::SliceRandom, Rng}; 5 | use std::collections::HashSet; 6 | 7 | // Will be used as cells in grids representing simple maps of levels during terrain generation 8 | #[derive(Clone, Copy, PartialEq, Eq)] 9 | enum FloorOrWall { 10 | Floor, 11 | Wall, 12 | } 13 | 14 | // An axis-aligned rectangle 15 | #[derive(Clone, Copy)] 16 | struct Rect { 17 | top_left: Coord, 18 | size: Size, 19 | } 20 | 21 | impl Rect { 22 | // Randomly generate a rectangle 23 | fn choose(bounds: Size, min_size: Size, max_size: Size, rng: &mut R) -> Self { 24 | let width = rng.gen_range(min_size.width()..max_size.width()); 25 | let height = rng.gen_range(min_size.height()..max_size.height()); 26 | let size = Size::new(width, height); 27 | let top_left_bounds = bounds - size; 28 | let left = rng.gen_range(0..top_left_bounds.width()); 29 | let top = rng.gen_range(0..top_left_bounds.height()); 30 | let top_left = Coord::new(left as i32, top as i32); 31 | Self { top_left, size } 32 | } 33 | 34 | // Returns an iterator over all the coordinates in the rectangle 35 | fn coords(&self) -> impl '_ + Iterator { 36 | self.size.coord_iter_row_major().map(|c| c + self.top_left) 37 | } 38 | 39 | // Returns true iff the given coordinate is on the edge of the rectangle 40 | fn is_edge(&self, coord: Coord) -> bool { 41 | self.size.is_on_edge(coord - self.top_left) 42 | } 43 | 44 | // Returns an iterator over the edge coordinates of the rectangle 45 | fn edge_coords(&self) -> impl '_ + Iterator { 46 | self.size.edge_iter().map(|c| self.top_left + c) 47 | } 48 | 49 | // Returns an iterator over the internal (non-edge) coordinates of the rectangle 50 | fn internal_coords(&self) -> impl '_ + Iterator { 51 | self.coords().filter(|&c| !self.is_edge(c)) 52 | } 53 | 54 | // Returns the coordinate of the centre of the rectangle 55 | fn centre(&self) -> Coord { 56 | self.top_left + (self.size / 2) 57 | } 58 | } 59 | 60 | // Represents a room during terrain generation 61 | #[derive(Clone, Copy)] 62 | struct Room { 63 | // The edge of the rectangle will be the walls surrounding the room, and the inside of the 64 | // rectangle will be the floor of the room. 65 | rect: Rect, 66 | } 67 | 68 | impl Room { 69 | // Returns true iff any cell of the room corresponds to a floor cell in the given map 70 | fn overlaps_with_floor(&self, map: &Grid) -> bool { 71 | self.rect 72 | .coords() 73 | .any(|coord| *map.get_checked(coord) == FloorOrWall::Floor) 74 | } 75 | 76 | // Updates the given map, setting each cell corresponding to the floor of this room to be a 77 | // floor cell 78 | fn add_floor_to_map(&self, map: &mut Grid) { 79 | for coord in self.rect.internal_coords() { 80 | *map.get_checked_mut(coord) = FloorOrWall::Floor; 81 | } 82 | } 83 | } 84 | 85 | // Checks whether a given cell of a map has a floor either side of it in the given axis, and a 86 | // wall either side of it in the other axis. (An Axis is defined as `enum Axis { X, Y }`.) 87 | // This is used to check whether a cell is suitable to contain a door. 88 | fn is_cell_in_corridor_axis(map: &Grid, coord: Coord, axis: Axis) -> bool { 89 | use FloorOrWall::*; 90 | let axis_delta = Coord::new_axis(1, 0, axis); 91 | let other_axis_delta = Coord::new_axis(0, 1, axis); 92 | let floor_in_axis = *map.get_checked(coord + axis_delta) == Floor 93 | && *map.get_checked(coord - axis_delta) == Floor; 94 | let wall_in_other_axis = *map.get_checked(coord + other_axis_delta) == Wall 95 | && *map.get_checked(coord - other_axis_delta) == Wall; 96 | floor_in_axis && wall_in_other_axis 97 | } 98 | 99 | // Checks whether a given cell of a map has a floor either side of it in some axis, and a wall 100 | // either side of it in the other axis. 101 | // This is used to check whether a cell is suitable to contain a door. 102 | fn is_cell_in_corridor(map: &Grid, coord: Coord) -> bool { 103 | is_cell_in_corridor_axis(map, coord, Axis::X) || is_cell_in_corridor_axis(map, coord, Axis::Y) 104 | } 105 | 106 | // Checks whether a cell has any neighbours which are floors 107 | fn has_floor_neighbour(map: &Grid, coord: Coord) -> bool { 108 | CardinalDirection::all().any(|d| *map.get_checked(coord + d.coord()) == FloorOrWall::Floor) 109 | } 110 | 111 | // Returns a vec of coordinates that define an L-shaped corridor from start to end (in order). The 112 | // corridor stops if it encounters a cell adjacent to a floor cell according to the given map. The 113 | // first axis that is traversed in the L-shaped corridor will be the given axis. 114 | fn l_shaped_corridor_with_first_axis( 115 | start: Coord, 116 | end: Coord, 117 | map: &Grid, 118 | first_axis: Axis, 119 | ) -> Vec { 120 | let mut ret = Vec::new(); 121 | let delta = end - start; 122 | let step = Coord::new_axis(delta.get(first_axis).signum(), 0, first_axis); 123 | // Skip the start coordinate so multiple corridors can start from the same coord 124 | let mut current = start + step; 125 | while current.get(first_axis) != end.get(first_axis) { 126 | ret.push(current); 127 | if has_floor_neighbour(map, current) { 128 | // stop when we get adjacent to a floor cell 129 | return ret; 130 | } 131 | current += step; 132 | } 133 | let step = Coord::new_axis(0, delta.get(first_axis.other()).signum(), first_axis); 134 | while current != end { 135 | ret.push(current); 136 | if has_floor_neighbour(map, current) { 137 | // stop when we get adjacent to a floor cell 138 | return ret; 139 | } 140 | current += step; 141 | } 142 | ret 143 | } 144 | 145 | // Returns a vec of coordinates that define an L-shaped corridor from start to end (in order). The 146 | // corridor stops if it encounters a cell adjacent to a floor cell according to the given map. The 147 | // first axis that is traversed in the L-shaped corridor is chosen at random. 148 | fn l_shaped_corridor( 149 | start: Coord, 150 | end: Coord, 151 | map: &Grid, 152 | rng: &mut R, 153 | ) -> Vec { 154 | let axis = if rng.gen() { Axis::X } else { Axis::Y }; 155 | l_shaped_corridor_with_first_axis(start, end, map, axis) 156 | } 157 | 158 | // Data structure representing the state of the room-placement algorithm 159 | struct RoomPlacement { 160 | // A list of rooms that have been placed 161 | rooms: Vec, 162 | // A set of all coordinates that are the edge of a room 163 | edge_coords: HashSet, 164 | // List of cells that would be suitable to contain doors 165 | door_candidates: Vec, 166 | // Tracks whether there is a wall or floor at each location 167 | map: Grid, 168 | } 169 | 170 | impl RoomPlacement { 171 | fn new(size: Size) -> Self { 172 | Self { 173 | rooms: Vec::new(), 174 | edge_coords: HashSet::new(), 175 | door_candidates: Vec::new(), 176 | map: Grid::new_copy(size, FloorOrWall::Wall), 177 | } 178 | } 179 | 180 | // Adds a new room unless it overlaps with the floor 181 | fn try_add_room(&mut self, new_room: Room, rng: &mut R) { 182 | // Don't add the room if it overlaps with the floor 183 | if new_room.overlaps_with_floor(&self.map) { 184 | return; 185 | } 186 | // Add the room's wall to the collection of edge coords 187 | self.edge_coords.extend(new_room.rect.edge_coords()); 188 | // Randomly choose two rooms to connect the new room to 189 | for &existing_room in self.rooms.choose_multiple(rng, 2) { 190 | // List the coordinates of an L-shaped corridor between the centres of the new room and 191 | // the chosen exsiting room 192 | let corridor = l_shaped_corridor( 193 | new_room.rect.centre(), 194 | existing_room.rect.centre(), 195 | &self.map, 196 | rng, 197 | ); 198 | // Carve out the corridor from the map 199 | for &coord in &corridor { 200 | *self.map.get_checked_mut(coord) = FloorOrWall::Floor; 201 | } 202 | // Update the list of door candidates along this corridor 203 | let mut door_candidate = None; 204 | for &coord in &corridor { 205 | if self.edge_coords.contains(&coord) && is_cell_in_corridor(&self.map, coord) { 206 | door_candidate = Some(coord); 207 | } else if let Some(coord) = door_candidate.take() { 208 | // The candidate is stored in door_candidate (an Option) until a 209 | // non-candidate cell is found, at which point the currently-stored candidate 210 | // is added to the list of door candidates. This prevents multiple consecutive 211 | // door candidates being added, which could result in several doors in a row 212 | // which is undesired. 213 | self.door_candidates.push(coord); 214 | } 215 | } 216 | if let Some(coord) = door_candidate { 217 | self.door_candidates.push(coord); 218 | } 219 | } 220 | new_room.add_floor_to_map(&mut self.map); 221 | self.rooms.push(new_room); 222 | } 223 | } 224 | 225 | // A cell of the RoomsAndCorridorsLevel map 226 | #[derive(Clone, Copy, PartialEq, Eq)] 227 | pub enum RoomsAndCorridorsCell { 228 | Floor, 229 | Wall, 230 | Door, 231 | } 232 | 233 | // Represents a level made up of rooms and corridors 234 | pub struct RoomsAndCorridorsLevel { 235 | // Whether each cell is a floor or wall 236 | pub map: Grid, 237 | // Location where the player will start 238 | pub player_spawn: Coord, 239 | // Player's destination (e.g. stairs to next level) 240 | pub destination: Coord, 241 | pub other_room_centres: Vec, 242 | } 243 | 244 | impl RoomsAndCorridorsLevel { 245 | // Randomly generates a level made up of rooms and corridors 246 | pub fn generate(size: Size, rng: &mut R) -> Self { 247 | const NUM_ROOM_ATTEMPTS: usize = 50; 248 | const MIN_ROOM_SIZE: Size = Size::new_u16(5, 5); 249 | const MAX_ROOM_SIZE: Size = Size::new_u16(11, 9); 250 | let mut room_placement = RoomPlacement::new(size); 251 | // Add all the rooms and corridors 252 | for _ in 0..NUM_ROOM_ATTEMPTS { 253 | let new_room = Room { 254 | rect: Rect::choose(size, MIN_ROOM_SIZE, MAX_ROOM_SIZE, rng), 255 | }; 256 | room_placement.try_add_room(new_room, rng); 257 | } 258 | // Create the map made of `RoomsAndCorridorsCell`s 259 | let mut map = Grid::new_grid_map(room_placement.map, |floor_or_wall| match floor_or_wall { 260 | FloorOrWall::Floor => RoomsAndCorridorsCell::Floor, 261 | FloorOrWall::Wall => RoomsAndCorridorsCell::Wall, 262 | }); 263 | // Add doors 264 | for door_candidate_coord in room_placement.door_candidates { 265 | // Each door candidate has a 50% chance to become a door 266 | if rng.gen::() { 267 | *map.get_checked_mut(door_candidate_coord) = RoomsAndCorridorsCell::Door; 268 | } 269 | } 270 | // The player will start in the centre of a randomly-chosen room 271 | let player_spawn = room_placement.rooms.choose(rng).unwrap().rect.centre(); 272 | 273 | // The destination will be in centre of the room furthest from the player spawn 274 | let destination = room_placement 275 | .rooms 276 | .iter() 277 | .max_by_key(|room| (room.rect.centre() - player_spawn).magnitude2()) 278 | .unwrap() 279 | .rect 280 | .centre(); 281 | 282 | let other_room_centres = room_placement 283 | .rooms 284 | .iter() 285 | .cloned() 286 | .filter(|c| c.rect.centre() != player_spawn && c.rect.centre() != destination) 287 | .map(|c| c.rect.centre()) 288 | .collect::>(); 289 | Self { 290 | map, 291 | player_spawn, 292 | destination, 293 | other_room_centres, 294 | } 295 | } 296 | } 297 | -------------------------------------------------------------------------------- /app/src/game_instance.rs: -------------------------------------------------------------------------------- 1 | use crate::{colour, mist::Mist}; 2 | use boat_journey_game::{ 3 | witness::{self, Game, RunningGame}, 4 | CellVisibility, Config, Layer, Meter, Tile, Victory, 5 | }; 6 | use chargrid::{prelude::*, text}; 7 | use rand::Rng; 8 | use rgb_int::{rgb24, Rgb24}; 9 | use serde::{Deserialize, Serialize}; 10 | use std::collections::HashSet; 11 | 12 | #[derive(Serialize, Deserialize)] 13 | pub struct FadeState { 14 | pub boat_opacity: u8, 15 | pub player_opacity: u8, 16 | pub boat_fading: bool, 17 | pub player_fading: bool, 18 | } 19 | 20 | impl FadeState { 21 | pub fn new() -> Self { 22 | Self { 23 | boat_opacity: 255, 24 | player_opacity: 255, 25 | boat_fading: false, 26 | player_fading: false, 27 | } 28 | } 29 | } 30 | 31 | struct NightTint; 32 | impl Tint for NightTint { 33 | fn tint(&self, rgba32: Rgba32) -> Rgba32 { 34 | let mean = rgba32 35 | .to_rgb24() 36 | .weighted_mean_u16(rgb24::WeightsU16::new(1, 1, 1)); 37 | Rgb24::new_grey(mean) 38 | .saturating_scalar_mul_div(3, 4) 39 | .to_rgba32(255) 40 | } 41 | } 42 | 43 | pub struct GameInstance { 44 | pub game: Game, 45 | pub mist: Mist, 46 | pub fade_state: FadeState, 47 | } 48 | 49 | impl GameInstance { 50 | pub fn new( 51 | config: &Config, 52 | victories: Vec, 53 | rng: &mut R, 54 | ) -> (Self, witness::Running) { 55 | let (game, running) = witness::new_game(config, victories, rng); 56 | let mist = Mist::new(rng); 57 | ( 58 | GameInstance { 59 | game, 60 | mist, 61 | fade_state: FadeState::new(), 62 | }, 63 | running, 64 | ) 65 | } 66 | 67 | pub fn into_storable(self, running: witness::Running) -> GameInstanceStorable { 68 | let Self { 69 | game, 70 | mist, 71 | fade_state, 72 | } = self; 73 | let running_game = game.into_running_game(running); 74 | GameInstanceStorable { 75 | running_game, 76 | mist, 77 | fade_state, 78 | } 79 | } 80 | 81 | fn layer_to_depth(layer: Layer) -> i8 { 82 | match layer { 83 | Layer::Character => 5, 84 | Layer::Item => 4, 85 | Layer::Feature => 3, 86 | Layer::Boat => 2, 87 | Layer::Floor => 1, 88 | Layer::Water => 0, 89 | } 90 | } 91 | 92 | fn tile_to_render_cell( 93 | tile: Tile, 94 | current: bool, 95 | boat_opacity: u8, 96 | player_opacity: u8, 97 | ) -> RenderCell { 98 | let character = match tile { 99 | Tile::Player => { 100 | return RenderCell { 101 | character: Some('@'), 102 | style: Style::new() 103 | .with_bold(true) 104 | .with_foreground( 105 | Rgba32::new_grey(255) 106 | .with_a(player_opacity) 107 | .alpha_composite(colour::MURKY_GREEN.to_rgba32(255)), 108 | ) 109 | .with_background(colour::MURKY_GREEN.to_rgba32(255)), 110 | }; 111 | } 112 | Tile::BoatControls => { 113 | return RenderCell { 114 | character: Some('░'), 115 | style: Style::new() 116 | .with_bold(true) 117 | .with_foreground( 118 | Rgba32::new_grey(255) 119 | .with_a(boat_opacity) 120 | .alpha_composite(colour::MURKY_GREEN.to_rgba32(255)), 121 | ) 122 | .with_background(colour::MURKY_GREEN.to_rgba32(255)), 123 | }; 124 | } 125 | Tile::BoatEdge => { 126 | return RenderCell { 127 | character: Some('#'), 128 | style: Style::new() 129 | .with_bold(true) 130 | .with_foreground( 131 | Rgba32::new_grey(255) 132 | .with_a(boat_opacity) 133 | .alpha_composite(colour::MURKY_GREEN.to_rgba32(255)), 134 | ) 135 | .with_background(colour::MURKY_GREEN.to_rgba32(255)), 136 | }; 137 | } 138 | Tile::BoatFloor => { 139 | return RenderCell { 140 | character: Some('.'), 141 | style: Style::new() 142 | .with_bold(true) 143 | .with_foreground( 144 | Rgba32::new_grey(255) 145 | .with_a(boat_opacity) 146 | .alpha_composite(colour::MURKY_GREEN.to_rgba32(255)), 147 | ) 148 | .with_background(colour::MURKY_GREEN.to_rgba32(255)), 149 | }; 150 | } 151 | Tile::Beast => { 152 | return RenderCell { 153 | character: Some('b'), 154 | style: Style::new() 155 | .with_bold(true) 156 | .with_foreground( 157 | Rgba32::new_grey(255) 158 | .with_a(255) 159 | .alpha_composite(colour::MURKY_GREEN.to_rgba32(255)), 160 | ) 161 | .with_background(colour::MURKY_GREEN.to_rgba32(255)), 162 | }; 163 | } 164 | Tile::Ghost => { 165 | return RenderCell { 166 | character: Some('g'), 167 | style: Style::new() 168 | .with_bold(true) 169 | .with_foreground( 170 | Rgba32::new_grey(255) 171 | .with_a(255) 172 | .alpha_composite(colour::MURKY_GREEN.to_rgba32(255)), 173 | ) 174 | .with_background(colour::MURKY_GREEN.to_rgba32(255)), 175 | }; 176 | } 177 | Tile::Grave => { 178 | return RenderCell { 179 | character: Some('▄'), 180 | style: Style::new() 181 | .with_bold(true) 182 | .with_foreground( 183 | Rgba32::new_grey(255) 184 | .with_a(255) 185 | .alpha_composite(colour::MURKY_GREEN.to_rgba32(255)), 186 | ) 187 | .with_background(colour::MURKY_GREEN.to_rgba32(255)), 188 | }; 189 | } 190 | Tile::Water1 => { 191 | if current { 192 | '~' 193 | } else { 194 | ' ' 195 | } 196 | } 197 | Tile::Water2 => ' ', 198 | Tile::Floor => '.', 199 | Tile::BurntFloor => { 200 | return RenderCell { 201 | character: Some('.'), 202 | style: Style::new() 203 | .with_foreground(Rgba32::new_grey(63)) 204 | .with_background(Rgb24::new(0, 0, 0).to_rgba32(255)), 205 | } 206 | } 207 | Tile::Wall => '█', 208 | Tile::DoorClosed => '+', 209 | Tile::DoorOpen => '-', 210 | Tile::Rock => '%', 211 | Tile::Board => '=', 212 | Tile::Tree => '♣', 213 | Tile::UnimportantNpc => { 214 | return RenderCell { 215 | character: Some('&'), 216 | style: Style::new() 217 | .with_foreground(Rgba32::new_grey(255)) 218 | .with_background(colour::MURKY_GREEN.to_rgba32(255)), 219 | }; 220 | } 221 | Tile::StairsDown => { 222 | return RenderCell { 223 | character: Some('>'), 224 | style: Style::new() 225 | .with_bold(true) 226 | .with_foreground(Rgba32::new_grey(255)) 227 | .with_background(colour::MURKY_GREEN.to_rgba32(255)), 228 | }; 229 | } 230 | Tile::StairsUp => { 231 | return RenderCell { 232 | character: Some('<'), 233 | style: Style::new() 234 | .with_bold(true) 235 | .with_foreground(Rgba32::new_grey(255)) 236 | .with_background(colour::MURKY_GREEN.to_rgba32(255)), 237 | }; 238 | } 239 | Tile::Junk => { 240 | return RenderCell { 241 | character: Some('*'), 242 | style: Style::new() 243 | .with_bold(true) 244 | .with_foreground(Rgba32::new_grey(255)) 245 | .with_background(colour::MURKY_GREEN.to_rgba32(255)), 246 | }; 247 | } 248 | Tile::Button => { 249 | return RenderCell { 250 | character: Some('/'), 251 | style: Style::new() 252 | .with_bold(true) 253 | .with_foreground(Rgba32::new_grey(255)) 254 | .with_background(colour::MURKY_GREEN.to_rgba32(255)), 255 | }; 256 | } 257 | Tile::ButtonPressed => { 258 | return RenderCell { 259 | character: Some('\\'), 260 | style: Style::new() 261 | .with_bold(true) 262 | .with_foreground(Rgba32::new_grey(255)) 263 | .with_background(colour::MURKY_GREEN.to_rgba32(255)), 264 | }; 265 | } 266 | Tile::Shop => { 267 | return RenderCell { 268 | character: Some('$'), 269 | style: Style::new() 270 | .with_bold(true) 271 | .with_foreground(Rgba32::new_grey(255)) 272 | .with_background(colour::MURKY_GREEN.to_rgba32(255)), 273 | }; 274 | } 275 | Tile::Npc(_npc) => { 276 | return RenderCell { 277 | character: Some('@'), 278 | style: Style::plain_text() 279 | .with_foreground(Rgb24::new_grey(255).to_rgba32(player_opacity)) 280 | .with_background(colour::MURKY_GREEN.to_rgba32(255)), 281 | }; 282 | } 283 | }; 284 | RenderCell { 285 | character: Some(character), 286 | style: Style::new() 287 | .with_bold(false) 288 | .with_foreground(Rgba32::new_grey(187)) 289 | .with_background(colour::MURKY_GREEN.to_rgba32(255)), 290 | } 291 | } 292 | 293 | pub fn render_game(&self, ctx: Ctx, fb: &mut FrameBuffer) -> HashSet { 294 | let mut tiles = HashSet::new(); 295 | let ctx = if self.game.inner_ref().is_player_outside_at_night() { 296 | ctx.with_tint(&NightTint) 297 | } else { 298 | ctx 299 | }; 300 | let centre_coord_delta = 301 | self.game.inner_ref().player_coord() - (ctx.bounding_box.size() / 2); 302 | let boat_opacity = self.fade_state.boat_opacity; 303 | let player_opacity = self.fade_state.player_opacity; 304 | for coord in ctx.bounding_box.size().coord_iter_row_major() { 305 | let cell = self 306 | .game 307 | .inner_ref() 308 | .cell_visibility_at_coord(coord + centre_coord_delta); 309 | let mut mist = if self.game.inner_ref().is_in_dungeon() { 310 | Rgba32::new(0, 0, 0, 0) 311 | } else { 312 | self.mist.get(coord) 313 | }; 314 | if self.game.inner_ref().is_player_outside_at_night() { 315 | mist.a *= 4; 316 | } 317 | 318 | let unseen_background = if self.game.inner_ref().is_in_dungeon() { 319 | Rgba32::new(0, 0, 0, 255) 320 | } else { 321 | colour::MISTY_GREY.to_rgba32(255) 322 | }; 323 | 324 | match cell { 325 | CellVisibility::Never => { 326 | let background = mist.alpha_composite(unseen_background); 327 | let render_cell = RenderCell { 328 | character: None, 329 | style: Style::new().with_background(background), 330 | }; 331 | fb.set_cell_relative_to_ctx(ctx, coord, 0, render_cell); 332 | } 333 | CellVisibility::Previous(data) => { 334 | let background = mist.alpha_composite(unseen_background); 335 | data.tiles.for_each_enumerate(|tile, layer| { 336 | if let Some(&tile) = tile.as_ref() { 337 | let depth = Self::layer_to_depth(layer); 338 | let mut render_cell = Self::tile_to_render_cell( 339 | tile, 340 | false, 341 | boat_opacity, 342 | player_opacity, 343 | ); 344 | render_cell.style.background = Some(background); 345 | render_cell.style.foreground = Some(Rgba32::new_grey(63)); 346 | fb.set_cell_relative_to_ctx(ctx, coord, depth, render_cell); 347 | } 348 | }); 349 | } 350 | CellVisibility::Current { data, .. } => { 351 | data.tiles.for_each_enumerate(|tile, layer| { 352 | if let Some(&tile) = tile.as_ref() { 353 | tiles.insert(tile); 354 | let depth = Self::layer_to_depth(layer); 355 | let mut render_cell = 356 | Self::tile_to_render_cell(tile, true, boat_opacity, player_opacity); 357 | if let Some(background) = render_cell.style.background.as_mut() { 358 | *background = mist.alpha_composite(*background); 359 | } 360 | fb.set_cell_relative_to_ctx(ctx, coord, depth, render_cell); 361 | } 362 | }); 363 | } 364 | } 365 | } 366 | tiles 367 | } 368 | 369 | fn render_hints(&self, ctx: Ctx, fb: &mut FrameBuffer, tiles: &HashSet) { 370 | use text::*; 371 | let stats = self.game.inner_ref().stats(); 372 | let mut hints = Vec::new(); 373 | if self.game.inner_ref().is_player_on_boat() { 374 | if self.game.inner_ref().is_driving() { 375 | hints.push(StyledString { 376 | string: format!("Press `e' to stop driving the boat.\n\n"), 377 | style: Style::plain_text(), 378 | }); 379 | } else { 380 | hints.push(StyledString { 381 | string: format!("Press `e' standing on ░ to drive the boat.\n\n"), 382 | style: Style::plain_text(), 383 | }); 384 | } 385 | } 386 | if tiles.contains(&Tile::Junk) { 387 | hints.push(StyledString { 388 | string: format!("Walk over junk (*) to pick it up.\n\n"), 389 | style: Style::plain_text(), 390 | }); 391 | } 392 | if tiles.contains(&Tile::UnimportantNpc) { 393 | hints.push(StyledString { 394 | string: format!("Walk into friendly characters (&) to converse.\n\n"), 395 | style: Style::plain_text(), 396 | }); 397 | } 398 | if tiles.contains(&Tile::Shop) { 399 | hints.push(StyledString { 400 | string: format!("Walk into innkeeper ($) to converse.\n\n"), 401 | style: Style::plain_text(), 402 | }); 403 | } 404 | if tiles.contains(&Tile::Button) { 405 | hints.push(StyledString { 406 | string: format!("Walk into the gate lever (/) to open the gate.\n\n"), 407 | style: Style::plain_text(), 408 | }); 409 | } 410 | if stats.day.current() < 100 411 | && stats.day.current() > 0 412 | && !self.game.inner_ref().is_player_inside() 413 | { 414 | hints.push(StyledString { 415 | string: format!("\"We'd better get inside, 'cause it'll be dark soon, \nand they mostly come at night...mostly\"\n\n"), 416 | style: Style::plain_text(), 417 | }); 418 | } 419 | if stats.fuel.current() < 50 { 420 | hints.push(StyledString { 421 | string: format!("You are almost out of fuel!\n\n"), 422 | style: Style::plain_text(), 423 | }); 424 | } 425 | if stats.health.current() == 1 { 426 | hints.push(StyledString { 427 | string: format!("You are barely clinging to consciousness...\n\n"), 428 | style: Style::plain_text(), 429 | }); 430 | } 431 | if tiles.contains(&Tile::Beast) { 432 | hints.push(StyledString { 433 | string: format!("Beasts (b) move towards you on their turn.\n\n"), 434 | style: Style::plain_text(), 435 | }); 436 | } 437 | if tiles.contains(&Tile::Ghost) { 438 | hints.push(StyledString { 439 | string: format!("Ghosts (g) can move diagonally.\n\n"), 440 | style: Style::plain_text(), 441 | }); 442 | } 443 | if self.game.inner_ref().is_player_outside_at_night() { 444 | hints.push(StyledString { 445 | string: format!("GET INSIDE\n\n"), 446 | style: Style::plain_text().with_bold(true), 447 | }); 448 | } 449 | Text::new(hints).render(&(), ctx, fb); 450 | } 451 | 452 | fn render_ui(&self, ctx: Ctx, fb: &mut FrameBuffer) { 453 | use text::*; 454 | if !self.game.inner_ref().has_been_on_boat() { 455 | return; 456 | } 457 | let stats = self.game.inner_ref().stats(); 458 | let activity = if self.game.inner_ref().is_driving() { 459 | "Driving Boat " 460 | } else { 461 | "On Foot " 462 | }; 463 | let activity_text = StyledString { 464 | string: activity.to_string(), 465 | style: Style::plain_text().with_bold(true), 466 | }; 467 | let day_text = StyledString { 468 | string: format!("Day {} ", self.game.inner_ref().current_day()), 469 | style: Style::plain_text().with_bold(true), 470 | }; 471 | fn meter_text(name: &str, meter: &Meter) -> Vec { 472 | vec![ 473 | StyledString { 474 | string: format!("{}: ", name), 475 | style: Style::plain_text().with_bold(true), 476 | }, 477 | StyledString { 478 | string: format!("{}/{} ", meter.current(), meter.max()), 479 | style: Style::plain_text().with_bold(true), 480 | }, 481 | ] 482 | } 483 | let text = vec![ 484 | vec![activity_text, day_text], 485 | meter_text("Health", &stats.health), 486 | meter_text("Fuel", &stats.fuel), 487 | meter_text("Light", &stats.day), 488 | meter_text("Junk", &stats.junk), 489 | ]; 490 | Text::new(text.concat()).render(&(), ctx, fb); 491 | } 492 | 493 | fn render_messages(&self, ctx: Ctx, fb: &mut FrameBuffer) { 494 | use text::*; 495 | let max = 4; 496 | let mut messages: Vec<(usize, String)> = Vec::new(); 497 | for m in self.game.inner_ref().messages().iter().rev() { 498 | if messages.len() >= max { 499 | break; 500 | } 501 | if let Some((ref mut count, last)) = messages.last_mut() { 502 | if last == m { 503 | *count += 1; 504 | continue; 505 | } 506 | } 507 | messages.push((1, m.clone())); 508 | } 509 | for (i, (count, m)) in messages.into_iter().enumerate() { 510 | let string = if count == 1 { 511 | m 512 | } else { 513 | format!("{} (x{})", m, count) 514 | }; 515 | let alpha = 255 - (i as u8 * 50); 516 | let styled_string = StyledString { 517 | string, 518 | style: Style::plain_text().with_foreground(Rgba32::new_grey(255).with_a(alpha)), 519 | }; 520 | let offset = max as i32 - i as i32 - 1; 521 | styled_string.render(&(), ctx.add_y(offset), fb); 522 | } 523 | } 524 | 525 | pub fn render_side_ui(&self, ctx: Ctx, fb: &mut FrameBuffer) { 526 | use text::*; 527 | let game = self.game.inner_ref(); 528 | if !game.has_talked_to_npc() { 529 | return; 530 | } 531 | let mut text_parts = vec![StyledString { 532 | string: format!("Passengers:\n\n"), 533 | style: Style::plain_text(), 534 | }]; 535 | let passengers = game.passengers(); 536 | for (i, &npc) in passengers.iter().enumerate() { 537 | let i = i + 1; 538 | let name = npc.name(); 539 | let ability_name = npc.ability_name(); 540 | let usage = game.npc_action(npc).unwrap(); 541 | text_parts.push(StyledString { 542 | string: format!("{i}. {name}\n"), 543 | style: Style::plain_text(), 544 | }); 545 | text_parts.push(StyledString { 546 | string: format!(" {ability_name} {}/{}\n\n", usage.current(), usage.max()), 547 | style: Style::plain_text().with_bold(true), 548 | }); 549 | } 550 | for i in passengers.len()..(self.game.inner_ref().num_seats() as usize) { 551 | let i = i + 1; 552 | text_parts.push(StyledString { 553 | string: format!("{i}. (empty)\n\n\n"), 554 | style: Style::plain_text(), 555 | }); 556 | } 557 | Text::new(text_parts).render(&(), ctx, fb); 558 | } 559 | 560 | pub fn render_side_ui2(&self, ctx: Ctx, fb: &mut FrameBuffer) { 561 | use text::*; 562 | let game = self.game.inner_ref(); 563 | if !game.has_talked_to_npc() { 564 | return; 565 | } 566 | let mut text_parts = vec![StyledString { 567 | string: format!("Effects:\n\n"), 568 | style: Style::plain_text(), 569 | }]; 570 | let effects = game.effect_timeouts(); 571 | if effects.fear > 0 { 572 | text_parts.push(StyledString { 573 | string: format!("Fear: {}\n\n", effects.fear), 574 | style: Style::plain_text().with_bold(true), 575 | }); 576 | } 577 | if effects.phase > 0 { 578 | text_parts.push(StyledString { 579 | string: format!("Phase: {}\n\n", effects.phase), 580 | style: Style::plain_text().with_bold(true), 581 | }); 582 | } 583 | if effects.sneak > 0 { 584 | text_parts.push(StyledString { 585 | string: format!("Sneak: {}\n\n", effects.sneak), 586 | style: Style::plain_text().with_bold(true), 587 | }); 588 | } 589 | if text_parts.len() == 1 { 590 | text_parts.push(StyledString { 591 | string: format!("(none)"), 592 | style: Style::plain_text(), 593 | }); 594 | } 595 | Text::new(text_parts).render(&(), ctx, fb); 596 | } 597 | 598 | fn render_aim_hint(&self, ctx: Ctx, fb: &mut FrameBuffer) { 599 | use text::*; 600 | let s = "AIMING\n\nUse the mouse or arrow keys to move the cursor.\n\nPress enter or left mouse button to commit.\n\nPress escape to cancel."; 601 | let ss = StyledString { 602 | string: s.to_string(), 603 | style: Style::plain_text().with_bold(true), 604 | }; 605 | ss.render(&(), ctx, fb); 606 | } 607 | 608 | pub fn render(&self, ctx: Ctx, fb: &mut FrameBuffer, aim_hint: bool) { 609 | let tiles = self.render_game(ctx, fb); 610 | if aim_hint { 611 | self.render_aim_hint(ctx.add_xy(1, 1).add_depth(20), fb); 612 | } else { 613 | self.render_hints(ctx.add_xy(1, 1).add_depth(20), fb, &tiles); 614 | } 615 | self.render_messages( 616 | ctx.add_xy(1, ctx.bounding_box.size().height() as i32 - 7) 617 | .add_depth(20), 618 | fb, 619 | ); 620 | self.render_ui( 621 | ctx.add_xy(1, ctx.bounding_box.size().height() as i32 - 2) 622 | .add_depth(20), 623 | fb, 624 | ); 625 | self.render_side_ui( 626 | ctx.add_xy(ctx.bounding_box.size().width() as i32 - 16, 1) 627 | .add_depth(20), 628 | fb, 629 | ); 630 | self.render_side_ui2( 631 | ctx.add_xy(ctx.bounding_box.size().width() as i32 - 16, 35) 632 | .add_depth(20), 633 | fb, 634 | ); 635 | } 636 | } 637 | 638 | #[derive(Serialize, Deserialize)] 639 | pub struct GameInstanceStorable { 640 | running_game: RunningGame, 641 | mist: Mist, 642 | fade_state: FadeState, 643 | } 644 | 645 | impl GameInstanceStorable { 646 | pub fn into_game_instance(self) -> (GameInstance, witness::Running) { 647 | let Self { 648 | running_game, 649 | mist, 650 | fade_state, 651 | } = self; 652 | let (game, running) = running_game.into_game(); 653 | ( 654 | GameInstance { 655 | game, 656 | mist, 657 | fade_state, 658 | }, 659 | running, 660 | ) 661 | } 662 | } 663 | -------------------------------------------------------------------------------- /app/src/game_loop.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | controls::{AppInput, Controls}, 3 | game_instance::{GameInstance, GameInstanceStorable}, 4 | image::Images, 5 | text, 6 | }; 7 | use boat_journey_game::{ 8 | witness::{self, Witness}, 9 | Config as GameConfig, GameOverReason, MenuChoice as GameMenuChoice, Victory, 10 | }; 11 | use chargrid::{self, border::BorderStyle, control_flow::*, menu, prelude::*}; 12 | use general_storage_static::{self as storage, format, StaticStorage as Storage}; 13 | use rand::{Rng, SeedableRng}; 14 | use rand_isaac::Isaac64Rng; 15 | use serde::{Deserialize, Serialize}; 16 | 17 | #[derive(Debug, Clone, Serialize, Deserialize)] 18 | struct Config { 19 | music_volume: f32, 20 | sfx_volume: f32, 21 | won: bool, 22 | first_run: bool, 23 | victories: Vec, 24 | } 25 | 26 | impl Default for Config { 27 | fn default() -> Self { 28 | Self { 29 | music_volume: 0.2, 30 | sfx_volume: 0.5, 31 | won: false, 32 | first_run: true, 33 | victories: Vec::new(), 34 | } 35 | } 36 | } 37 | 38 | /// An interactive, renderable process yielding a value of type `T` 39 | pub type AppCF = CF, GameLoopData>; 40 | pub type State = GameLoopData; 41 | 42 | const MENU_BACKGROUND: Rgba32 = Rgba32::new_rgb(0, 0, 0); 43 | const MENU_FADE_SPEC: menu::identifier::fade_spec::FadeSpec = { 44 | use menu::identifier::fade_spec::*; 45 | FadeSpec { 46 | on_select: Fade { 47 | to: To { 48 | rgba32: Layers { 49 | foreground: Rgba32::new_grey(255), 50 | background: crate::colour::MISTY_GREY.to_rgba32(255), 51 | }, 52 | bold: true, 53 | underline: false, 54 | }, 55 | from: From::current(), 56 | durations: Layers { 57 | foreground: Duration::from_millis(128), 58 | background: Duration::from_millis(128), 59 | }, 60 | }, 61 | on_deselect: Fade { 62 | to: To { 63 | rgba32: Layers { 64 | foreground: Rgba32::new_grey(187), 65 | background: Rgba32::new(0, 0, 0, 0), 66 | }, 67 | bold: false, 68 | underline: false, 69 | }, 70 | from: From::current(), 71 | durations: Layers { 72 | foreground: Duration::from_millis(128), 73 | background: Duration::from_millis(128), 74 | }, 75 | }, 76 | } 77 | }; 78 | 79 | pub enum InitialRngSeed { 80 | U64(u64), 81 | Random, 82 | } 83 | 84 | struct RngSeedSource { 85 | next_seed: u64, 86 | seed_rng: Isaac64Rng, 87 | } 88 | 89 | impl RngSeedSource { 90 | fn new(initial_rng_seed: InitialRngSeed) -> Self { 91 | let mut seed_rng = Isaac64Rng::from_entropy(); 92 | let next_seed = match initial_rng_seed { 93 | InitialRngSeed::U64(seed) => seed, 94 | InitialRngSeed::Random => seed_rng.gen(), 95 | }; 96 | Self { 97 | next_seed, 98 | seed_rng, 99 | } 100 | } 101 | 102 | fn next_seed(&mut self) -> u64 { 103 | let seed = self.next_seed; 104 | self.next_seed = self.seed_rng.gen(); 105 | #[cfg(feature = "print_stdout")] 106 | println!("RNG Seed: {}", seed); 107 | #[cfg(feature = "print_log")] 108 | log::info!("RNG Seed: {}", seed); 109 | seed 110 | } 111 | } 112 | 113 | pub struct AppStorage { 114 | pub handle: Storage, 115 | pub save_game_key: String, 116 | pub config_key: String, 117 | pub controls_key: String, 118 | } 119 | 120 | impl AppStorage { 121 | const SAVE_GAME_STORAGE_FORMAT: format::Bincode = format::Bincode; 122 | const CONFIG_STORAGE_FORMAT: format::JsonPretty = format::JsonPretty; 123 | const CONTROLS_STORAGE_FORMAT: format::JsonPretty = format::JsonPretty; 124 | 125 | fn save_game(&mut self, instance: &GameInstanceStorable) { 126 | let result = self.handle.store( 127 | &self.save_game_key, 128 | &instance, 129 | Self::SAVE_GAME_STORAGE_FORMAT, 130 | ); 131 | if let Err(e) = result { 132 | use storage::{StoreError, StoreRawError}; 133 | match e { 134 | StoreError::FormatError(e) => log::error!("Failed to format save file: {}", e), 135 | StoreError::Raw(e) => match e { 136 | StoreRawError::IoError(e) => { 137 | log::error!("Error while writing save data: {}", e) 138 | } 139 | }, 140 | } 141 | } 142 | } 143 | 144 | fn load_game(&self) -> Option { 145 | let result = self.handle.load::<_, GameInstanceStorable, _>( 146 | &self.save_game_key, 147 | Self::SAVE_GAME_STORAGE_FORMAT, 148 | ); 149 | match result { 150 | Err(e) => { 151 | use storage::{LoadError, LoadRawError}; 152 | match e { 153 | LoadError::FormatError(e) => log::error!("Failed to parse save file: {}", e), 154 | LoadError::Raw(e) => match e { 155 | LoadRawError::IoError(e) => { 156 | log::error!("Error while reading save data: {}", e) 157 | } 158 | LoadRawError::NoSuchKey => (), 159 | }, 160 | } 161 | None 162 | } 163 | Ok(instance) => Some(instance), 164 | } 165 | } 166 | 167 | fn clear_game(&mut self) { 168 | if self.handle.exists(&self.save_game_key) { 169 | if let Err(e) = self.handle.remove(&self.save_game_key) { 170 | use storage::RemoveError; 171 | match e { 172 | RemoveError::IoError(e) => { 173 | log::error!("Error while removing data: {}", e) 174 | } 175 | RemoveError::NoSuchKey => (), 176 | } 177 | } 178 | } 179 | } 180 | 181 | fn save_config(&mut self, config: &Config) { 182 | let result = self 183 | .handle 184 | .store(&self.config_key, &config, Self::CONFIG_STORAGE_FORMAT); 185 | if let Err(e) = result { 186 | use storage::{StoreError, StoreRawError}; 187 | match e { 188 | StoreError::FormatError(e) => log::error!("Failed to format config: {}", e), 189 | StoreError::Raw(e) => match e { 190 | StoreRawError::IoError(e) => { 191 | log::error!("Error while writing config: {}", e) 192 | } 193 | }, 194 | } 195 | } 196 | } 197 | 198 | fn load_config(&self) -> Option { 199 | let result = self 200 | .handle 201 | .load::<_, Config, _>(&self.config_key, Self::CONFIG_STORAGE_FORMAT); 202 | match result { 203 | Err(e) => { 204 | use storage::{LoadError, LoadRawError}; 205 | match e { 206 | LoadError::FormatError(e) => log::error!("Failed to parse config file: {}", e), 207 | LoadError::Raw(e) => match e { 208 | LoadRawError::IoError(e) => { 209 | log::error!("Error while reading config: {}", e) 210 | } 211 | LoadRawError::NoSuchKey => (), 212 | }, 213 | } 214 | None 215 | } 216 | Ok(instance) => Some(instance), 217 | } 218 | } 219 | 220 | fn save_controls(&mut self, controls: &Controls) { 221 | let result = 222 | self.handle 223 | .store(&self.controls_key, &controls, Self::CONTROLS_STORAGE_FORMAT); 224 | if let Err(e) = result { 225 | use storage::{StoreError, StoreRawError}; 226 | match e { 227 | StoreError::FormatError(e) => log::error!("Failed to format controls: {}", e), 228 | StoreError::Raw(e) => match e { 229 | StoreRawError::IoError(e) => { 230 | log::error!("Error while writing controls: {}", e) 231 | } 232 | }, 233 | } 234 | } 235 | } 236 | 237 | fn load_controls(&self) -> Option { 238 | let result = self 239 | .handle 240 | .load::<_, Controls, _>(&self.controls_key, Self::CONTROLS_STORAGE_FORMAT); 241 | match result { 242 | Err(e) => { 243 | use storage::{LoadError, LoadRawError}; 244 | match e { 245 | LoadError::FormatError(e) => { 246 | log::error!("Failed to parse controls file: {}", e) 247 | } 248 | LoadError::Raw(e) => match e { 249 | LoadRawError::IoError(e) => { 250 | log::error!("Error while reading controls: {}", e) 251 | } 252 | LoadRawError::NoSuchKey => (), 253 | }, 254 | } 255 | None 256 | } 257 | Ok(instance) => Some(instance), 258 | } 259 | } 260 | } 261 | 262 | fn new_game( 263 | rng_seed_source: &mut RngSeedSource, 264 | game_config: &GameConfig, 265 | victories: Vec, 266 | ) -> (GameInstance, witness::Running) { 267 | let mut rng = Isaac64Rng::seed_from_u64(rng_seed_source.next_seed()); 268 | GameInstance::new(game_config, victories, &mut rng) 269 | } 270 | 271 | pub struct GameLoopData { 272 | instance: Option, 273 | controls: Controls, 274 | game_config: GameConfig, 275 | storage: AppStorage, 276 | rng_seed_source: RngSeedSource, 277 | config: Config, 278 | images: Images, 279 | cursor: Option, 280 | } 281 | 282 | impl GameLoopData { 283 | pub fn new( 284 | game_config: GameConfig, 285 | mut storage: AppStorage, 286 | initial_rng_seed: InitialRngSeed, 287 | force_new_game: bool, 288 | ) -> (Self, GameLoopState) { 289 | let mut rng_seed_source = RngSeedSource::new(initial_rng_seed); 290 | let config = storage.load_config().unwrap_or_default(); 291 | let (instance, state) = match storage.load_game() { 292 | Some(instance) => { 293 | let (instance, running) = instance.into_game_instance(); 294 | ( 295 | Some(instance), 296 | GameLoopState::Playing(running.into_witness()), 297 | ) 298 | } 299 | None => { 300 | if force_new_game { 301 | let (instance, running) = 302 | new_game(&mut rng_seed_source, &game_config, config.victories.clone()); 303 | ( 304 | Some(instance), 305 | GameLoopState::Playing(running.into_witness()), 306 | ) 307 | } else { 308 | (None, GameLoopState::MainMenu) 309 | } 310 | } 311 | }; 312 | let controls = if let Some(controls) = storage.load_controls() { 313 | controls 314 | } else { 315 | let controls = Controls::default(); 316 | storage.save_controls(&controls); 317 | controls 318 | }; 319 | ( 320 | Self { 321 | instance, 322 | controls, 323 | game_config, 324 | storage, 325 | rng_seed_source, 326 | config, 327 | images: Images::new(), 328 | cursor: None, 329 | }, 330 | state, 331 | ) 332 | } 333 | 334 | fn screen_coord_to_game_coord(&self, screen_coord: Coord, screen_size: Size) -> Coord { 335 | let instance = self.instance.as_ref().unwrap(); 336 | let player_coord = instance.game.inner_ref().player_coord(); 337 | let mid = screen_size.to_coord().unwrap() / 2; 338 | (screen_coord - mid) + player_coord 339 | } 340 | 341 | fn save_instance(&mut self, running: witness::Running) -> witness::Running { 342 | let instance = self.instance.take().unwrap().into_storable(running); 343 | self.storage.save_game(&instance); 344 | let (instance, running) = instance.into_game_instance(); 345 | self.instance = Some(instance); 346 | running 347 | } 348 | 349 | fn clear_saved_game(&mut self) { 350 | self.storage.clear_game(); 351 | } 352 | 353 | fn new_game(&mut self) -> witness::Running { 354 | let victories = self.config.victories.clone(); 355 | let (instance, running) = new_game(&mut self.rng_seed_source, &self.game_config, victories); 356 | self.instance = Some(instance); 357 | running 358 | } 359 | 360 | fn save_config(&mut self) { 361 | self.storage.save_config(&self.config); 362 | } 363 | 364 | fn render(&self, ctx: Ctx, fb: &mut FrameBuffer) { 365 | let instance = self.instance.as_ref().unwrap(); 366 | instance.render(ctx, fb, self.cursor.is_some()); 367 | if let Some(cursor) = self.cursor { 368 | let cursor_colour = Rgba32::new(255, 255, 255, 127); 369 | let render_cell = RenderCell::default().with_background(cursor_colour); 370 | fb.set_cell_relative_to_ctx(ctx, cursor, 50, render_cell); 371 | } 372 | } 373 | 374 | fn update(&mut self, event: Event, running: witness::Running) -> GameLoopState { 375 | let instance = self.instance.as_mut().unwrap(); 376 | let witness = match event { 377 | Event::Input(input) => { 378 | if let Some(app_input) = self.controls.get(input) { 379 | let (witness, _action_result) = match app_input { 380 | AppInput::Direction(direction) => { 381 | running.walk(&mut instance.game, direction, &self.game_config) 382 | } 383 | AppInput::Wait => running.wait(&mut instance.game, &self.game_config), 384 | AppInput::DriveToggle => { 385 | running.drive_toggle(&mut instance.game, &self.game_config) 386 | } 387 | AppInput::Ability(i) => { 388 | running.ability(&mut instance.game, &self.game_config, i) 389 | } 390 | }; 391 | witness 392 | } else { 393 | running.into_witness() 394 | } 395 | } 396 | Event::Tick(since_previous) => { 397 | instance.mist.tick(); 398 | let fade_speed = 8; 399 | if instance.fade_state.player_fading { 400 | instance.fade_state.player_opacity = instance 401 | .fade_state 402 | .player_opacity 403 | .saturating_sub(fade_speed); 404 | } 405 | if instance.fade_state.boat_fading { 406 | instance.fade_state.boat_opacity = 407 | instance.fade_state.boat_opacity.saturating_sub(fade_speed); 408 | } 409 | 410 | running.tick(&mut instance.game, since_previous, &self.game_config) 411 | } 412 | _ => Witness::Running(running), 413 | }; 414 | GameLoopState::Playing(witness) 415 | } 416 | } 417 | 418 | struct GameInstanceComponent(Option); 419 | 420 | impl GameInstanceComponent { 421 | fn new(running: witness::Running) -> Self { 422 | Self(Some(running)) 423 | } 424 | } 425 | 426 | pub enum GameLoopState { 427 | Paused(witness::Running), 428 | Playing(Witness), 429 | MainMenu, 430 | } 431 | 432 | impl Component for GameInstanceComponent { 433 | type Output = GameLoopState; 434 | type State = GameLoopData; 435 | 436 | fn render(&self, state: &Self::State, ctx: Ctx, fb: &mut FrameBuffer) { 437 | state.render(ctx, fb); 438 | } 439 | 440 | fn update(&mut self, state: &mut Self::State, _ctx: Ctx, event: Event) -> Self::Output { 441 | let running = witness::Running::cheat(); // XXX 442 | if event.is_escape() { 443 | GameLoopState::Paused(running) 444 | } else { 445 | state.update(event, running) 446 | } 447 | } 448 | 449 | fn size(&self, _state: &Self::State, ctx: Ctx) -> Size { 450 | ctx.bounding_box.size() 451 | } 452 | } 453 | 454 | struct GameInstanceComponentAim; 455 | 456 | enum AimResult { 457 | Coord(Coord), 458 | Cancel, 459 | } 460 | 461 | impl Component for GameInstanceComponentAim { 462 | type Output = Option; 463 | type State = GameLoopData; 464 | 465 | fn render(&self, state: &Self::State, ctx: Ctx, fb: &mut FrameBuffer) { 466 | state.render(ctx, fb); 467 | } 468 | 469 | fn update(&mut self, state: &mut Self::State, ctx: Ctx, event: Event) -> Self::Output { 470 | let running = witness::Running::cheat(); // XXX 471 | let cursor = if let Some(cursor) = state.cursor.as_mut() { 472 | cursor 473 | } else { 474 | state.cursor = Some(ctx.bounding_box.size().to_coord().unwrap() / 2); 475 | state.cursor.as_mut().unwrap() 476 | }; 477 | match event { 478 | Event::Tick(_) | Event::Peek => { 479 | state.update(event, running); 480 | None 481 | } 482 | Event::Input(input) => { 483 | use chargrid::input::*; 484 | match input { 485 | Input::Keyboard(key) => match key { 486 | keys::RETURN => { 487 | let ret = *cursor; 488 | state.cursor = None; 489 | let ret = 490 | state.screen_coord_to_game_coord(ret, ctx.bounding_box.size()); 491 | return Some(AimResult::Coord(ret)); 492 | } 493 | keys::ESCAPE => { 494 | state.cursor = None; 495 | return Some(AimResult::Cancel); 496 | } 497 | KeyboardInput::Left => *cursor += Coord::new(-1, 0), 498 | KeyboardInput::Right => *cursor += Coord::new(1, 0), 499 | KeyboardInput::Up => *cursor += Coord::new(0, -1), 500 | KeyboardInput::Down => *cursor += Coord::new(0, 1), 501 | _ => (), 502 | }, 503 | Input::Mouse(mouse) => match mouse { 504 | MouseInput::MouseMove { button: _, coord } => *cursor = coord, 505 | MouseInput::MousePress { button: _, coord } => { 506 | state.cursor = None; 507 | let coord = 508 | state.screen_coord_to_game_coord(coord, ctx.bounding_box.size()); 509 | return Some(AimResult::Coord(coord)); 510 | } 511 | _ => (), 512 | }, 513 | } 514 | None 515 | } 516 | } 517 | } 518 | 519 | fn size(&self, _state: &Self::State, ctx: Ctx) -> Size { 520 | ctx.bounding_box.size() 521 | } 522 | } 523 | 524 | fn menu_style(menu: AppCF) -> AppCF { 525 | menu.border(BorderStyle::default()) 526 | .fill(MENU_BACKGROUND) 527 | .centre() 528 | .overlay_tint( 529 | render_state(|state: &State, ctx, fb| state.render(ctx, fb)), 530 | chargrid::core::TintDim(63), 531 | 60, 532 | ) 533 | } 534 | 535 | #[derive(Clone)] 536 | enum MainMenuEntry { 537 | NewGame, 538 | Help, 539 | Quit, 540 | } 541 | 542 | fn title_decorate(cf: AppCF) -> AppCF { 543 | let decoration = { 544 | let style = Style::plain_text(); 545 | chargrid::many![styled_string( 546 | "Boat Journey".to_string(), 547 | style.with_bold(true) 548 | )] 549 | }; 550 | cf.with_title_vertical(decoration, 2) 551 | } 552 | 553 | fn main_menu() -> AppCF { 554 | use menu::builder::*; 555 | use MainMenuEntry::*; 556 | let mut builder = menu_builder().vi_keys(); 557 | let mut add_item = |entry, name, ch: char| { 558 | let identifier = 559 | MENU_FADE_SPEC.identifier(move |b| write!(b, "({}) {}", ch, name).unwrap()); 560 | builder.add_item_mut(item(entry, identifier).add_hotkey_char(ch)); 561 | }; 562 | add_item(NewGame, "New Game", 'n'); 563 | add_item(Help, "Help", 'h'); 564 | #[cfg(not(feature = "web"))] 565 | add_item(Quit, "Quit", 'q'); 566 | builder.build_cf() 567 | } 568 | 569 | enum MainMenuOutput { 570 | NewGame { new_running: witness::Running }, 571 | Quit, 572 | } 573 | 574 | const MAIN_MENU_TEXT_WIDTH: u32 = 40; 575 | 576 | fn background() -> CF<(), State> { 577 | render(|ctx, fb| { 578 | for coord in ctx.bounding_box.size().coord_iter_row_major() { 579 | fb.set_cell_relative_to_ctx( 580 | ctx, 581 | coord, 582 | 1, 583 | RenderCell::default().with_background(crate::colour::MURKY_GREEN.to_rgba32(255)), 584 | ); 585 | } 586 | }) 587 | .ignore_state() 588 | } 589 | 590 | fn main_menu_loop() -> AppCF { 591 | use MainMenuEntry::*; 592 | title_decorate(main_menu()) 593 | .add_x(12) 594 | .add_y(12) 595 | .overlay( 596 | render_state(|state: &State, ctx, fb| state.images.boat.render(ctx, fb)), 597 | 1, 598 | ) 599 | .repeat_unit(move |entry| match entry { 600 | NewGame => text::loading(MAIN_MENU_TEXT_WIDTH) 601 | .centre() 602 | .overlay(background(), 1) 603 | .then(|| { 604 | on_state(|state: &mut State| MainMenuOutput::NewGame { 605 | new_running: state.new_game(), 606 | }) 607 | }) 608 | .break_(), 609 | Help => text::help(MAIN_MENU_TEXT_WIDTH) 610 | .fill(crate::colour::MURKY_GREEN.to_rgba32(255)) 611 | .centre() 612 | .overlay(background(), 1) 613 | .continue_(), 614 | Quit => val_once(MainMenuOutput::Quit).break_(), 615 | }) 616 | } 617 | 618 | #[derive(Clone)] 619 | enum PauseMenuEntry { 620 | Resume, 621 | SaveQuit, 622 | Save, 623 | NewGame, 624 | Help, 625 | Clear, 626 | } 627 | 628 | fn pause_menu() -> AppCF { 629 | use menu::builder::*; 630 | use PauseMenuEntry::*; 631 | let mut builder = menu_builder().vi_keys(); 632 | let mut add_item = |entry, name, ch: char| { 633 | let identifier = 634 | MENU_FADE_SPEC.identifier(move |b| write!(b, "({}) {}", ch, name).unwrap()); 635 | builder.add_item_mut(item(entry, identifier).add_hotkey_char(ch)); 636 | }; 637 | add_item(Resume, "Resume", 'r'); 638 | #[cfg(not(feature = "web"))] 639 | add_item(SaveQuit, "Save and Quit", 'q'); 640 | #[cfg(not(feature = "web"))] 641 | add_item(Save, "Save", 's'); 642 | add_item(NewGame, "New Game", 'n'); 643 | add_item(Help, "Help", 'h'); 644 | add_item(Clear, "Clear", 'c'); 645 | builder.build_cf() 646 | } 647 | 648 | fn pause_menu_loop(running: witness::Running) -> AppCF { 649 | use PauseMenuEntry::*; 650 | let text_width = 64; 651 | pause_menu() 652 | .menu_harness() 653 | .repeat( 654 | running, 655 | move |running, entry_or_escape| match entry_or_escape { 656 | Ok(entry) => match entry { 657 | Resume => break_(PauseOutput::ContinueGame { running }), 658 | SaveQuit => text::saving(MAIN_MENU_TEXT_WIDTH) 659 | .then(|| { 660 | on_state(|state: &mut State| { 661 | state.save_instance(running); 662 | PauseOutput::Quit 663 | }) 664 | }) 665 | .break_(), 666 | Save => text::saving(MAIN_MENU_TEXT_WIDTH) 667 | .then(|| { 668 | on_state(|state: &mut State| PauseOutput::ContinueGame { 669 | running: state.save_instance(running), 670 | }) 671 | }) 672 | .break_(), 673 | NewGame => text::loading(MAIN_MENU_TEXT_WIDTH) 674 | .then(|| { 675 | on_state(|state: &mut State| PauseOutput::ContinueGame { 676 | running: state.new_game(), 677 | }) 678 | }) 679 | .break_(), 680 | Help => text::help(text_width).continue_with(running), 681 | Clear => on_state(|state: &mut State| { 682 | state.clear_saved_game(); 683 | PauseOutput::MainMenu 684 | }) 685 | .break_(), 686 | }, 687 | Err(_escape_or_start) => break_(PauseOutput::ContinueGame { running }), 688 | }, 689 | ) 690 | } 691 | 692 | enum PauseOutput { 693 | ContinueGame { running: witness::Running }, 694 | MainMenu, 695 | Quit, 696 | } 697 | 698 | fn pause(running: witness::Running) -> AppCF { 699 | menu_style(pause_menu_loop(running)) 700 | } 701 | 702 | fn game_instance_component(running: witness::Running) -> AppCF { 703 | cf(GameInstanceComponent::new(running)).some().no_peek() 704 | } 705 | 706 | fn game_instance_component_aim() -> AppCF { 707 | cf(GameInstanceComponentAim) 708 | } 709 | 710 | fn win(win_: witness::Win) -> AppCF<()> { 711 | use chargrid::{ 712 | text::{StyledString, Text}, 713 | text_field::TextField, 714 | }; 715 | // TODO: fading out the player and then the boat shouldn't be hard 716 | on_state_then(|state: &mut State| { 717 | if let Some(instance) = state.instance.as_mut() { 718 | instance.fade_state.player_fading = true; 719 | } 720 | unit() 721 | }) 722 | .delay(Duration::from_secs(1)) 723 | .then_side_effect(|state: &mut State| { 724 | if let Some(instance) = state.instance.as_mut() { 725 | instance.fade_state.boat_fading = true; 726 | } 727 | // TODO: understand why calling `.some()` on the below causes it not to work 728 | unit().delay(Duration::from_secs(1)) 729 | }) 730 | .overlay(game_instance_component(win_.into_running()), 1) 731 | .then(|| { 732 | on_state_then(move |state: &mut State| { 733 | state.clear_saved_game(); 734 | state.config.won = true; 735 | state.save_config(); 736 | cf(TextField::with_initial_string(30, "".to_string())) 737 | .border(BorderStyle::default()) 738 | .ignore_state() 739 | .map_side_effect(|mut name: String, state: &mut State| { 740 | if name.is_empty() { 741 | name = "an unknown person".to_string(); 742 | } 743 | if let Some(instance) = state.instance.as_ref() { 744 | let stats = instance.game.inner_ref().victory_stats().clone(); 745 | let victory = Victory { name, stats }; 746 | state.config.victories.push(victory); 747 | state.save_config(); 748 | } 749 | }) 750 | .with_title_vertical( 751 | Text::new(vec![StyledString { 752 | string: 753 | "The ocean welcomes your return.\n\nWhat was your name? (enter to confirm):" 754 | .to_string(), 755 | style: Style::plain_text(), 756 | }]) 757 | .wrap_word() 758 | .cf() 759 | .set_width(MAIN_MENU_TEXT_WIDTH), 760 | 1, 761 | ) 762 | }) 763 | .centre() 764 | .overlay( 765 | render_state(|state: &State, ctx, fb| state.images.ocean.render(ctx, fb)), 766 | 1, 767 | ) 768 | }) 769 | } 770 | 771 | fn game_over(reason: GameOverReason) -> AppCF<()> { 772 | on_state_then(move |state: &mut State| { 773 | state.clear_saved_game(); 774 | state.save_config(); 775 | text::game_over(MAIN_MENU_TEXT_WIDTH, reason) 776 | }) 777 | .centre() 778 | .overlay(background(), 1) 779 | } 780 | 781 | fn game_menu(menu_witness: witness::Menu) -> AppCF { 782 | use chargrid::align::*; 783 | use menu::builder::*; 784 | let mut builder = menu_builder(); 785 | let mut add_item = |entry, name, ch: char| { 786 | let identifier = MENU_FADE_SPEC.identifier(move |b| write!(b, "{}. {}", ch, name).unwrap()); 787 | builder.add_item_mut(item(entry, identifier).add_hotkey_char(ch)); 788 | }; 789 | for (i, choice) in menu_witness.menu.choices.iter().enumerate() { 790 | let ch = std::char::from_digit(i as u32 + 1, 10).unwrap(); 791 | match choice { 792 | GameMenuChoice::SayNothing => { 793 | add_item(choice.clone(), "Say nothing...".to_string(), ch) 794 | } 795 | GameMenuChoice::Leave => add_item(choice.clone(), "Leave...".to_string(), ch), 796 | GameMenuChoice::AddNpcToPassengers(_) => { 797 | add_item(choice.clone(), "Welcome aboard".to_string(), ch) 798 | } 799 | GameMenuChoice::DontAddNpcToPassengers => { 800 | add_item(choice.clone(), "Perhaps later".to_string(), ch) 801 | } 802 | GameMenuChoice::BuyCrewCapacity(cost) => add_item( 803 | choice.clone(), 804 | format!("Buy passenger space ({cost} junk)"), 805 | ch, 806 | ), 807 | GameMenuChoice::BuyFuel { amount, cost } => add_item( 808 | choice.clone(), 809 | format!("Buy {amount} fuel ({cost} junk)"), 810 | ch, 811 | ), 812 | GameMenuChoice::SleepUntilMorning(_) => add_item( 813 | choice.clone(), 814 | "Rest until morning (no charge)".to_string(), 815 | ch, 816 | ), 817 | GameMenuChoice::StayAtInnForever => add_item( 818 | choice.clone(), 819 | "Stay at inn forever (abandon run)".to_string(), 820 | ch, 821 | ), 822 | GameMenuChoice::AbandonQuest => add_item( 823 | choice.clone(), 824 | "Yes, I've made up my mind (end the game)".to_string(), 825 | ch, 826 | ), 827 | GameMenuChoice::ChangeMind => add_item( 828 | choice.clone(), 829 | "No, I still want to go to the ocean".to_string(), 830 | ch, 831 | ), 832 | GameMenuChoice::Okay => add_item(choice.clone(), "Okay".to_string(), ch), 833 | } 834 | } 835 | let title = { 836 | use chargrid::text::*; 837 | Text::new(vec![StyledString { 838 | string: menu_witness.menu.text.clone(), 839 | style: Style::plain_text(), 840 | }]) 841 | .wrap_word() 842 | .cf::() 843 | .set_width(36) 844 | }; 845 | let menu_cf = builder 846 | .build_cf() 847 | .menu_harness() 848 | .add_x(2) 849 | .with_title_vertical(title, 2) 850 | .align(Alignment { 851 | x: AlignmentX::Left, 852 | y: AlignmentY::Centre, 853 | }) 854 | .add_x(4) 855 | .overlay( 856 | render_state(move |state: &State, ctx, fb| { 857 | state 858 | .images 859 | .image_from_menu_image(menu_witness.menu.image) 860 | .render(ctx, fb) 861 | }), 862 | 1, 863 | ); 864 | menu_cf.and_then_side_effect(|result, state: &mut State| { 865 | let witness = match result { 866 | Err(Close) => menu_witness.cancel(), 867 | Ok(choice) => { 868 | if let Some(instance) = state.instance.as_mut() { 869 | let witness = menu_witness.commit(&mut instance.game, choice.clone()); 870 | if let GameMenuChoice::SleepUntilMorning(i) = choice { 871 | return text::sleep(MAIN_MENU_TEXT_WIDTH, i) 872 | .centre() 873 | .overlay(background(), 1) 874 | .map_val(|| witness); 875 | } 876 | witness 877 | } else { 878 | menu_witness.cancel() 879 | } 880 | } 881 | }; 882 | val_once(witness) 883 | }) 884 | } 885 | 886 | fn aim(aim_: witness::Aim) -> AppCF { 887 | game_instance_component_aim().map_side_effect(|result, state: &mut State| match result { 888 | AimResult::Cancel => aim_.cancel(), 889 | AimResult::Coord(coord) => { 890 | if let Some(instance) = state.instance.as_mut() { 891 | aim_.commit(&mut instance.game, coord) 892 | } else { 893 | aim_.cancel() 894 | } 895 | } 896 | }) 897 | } 898 | 899 | pub fn game_loop_component(initial_state: GameLoopState) -> AppCF<()> { 900 | use GameLoopState::*; 901 | loop_(initial_state, |state| match state { 902 | Playing(witness) => match witness { 903 | Witness::Running(running) => game_instance_component(running).continue_(), 904 | Witness::GameOver(reason) => game_over(reason).map_val(|| MainMenu).continue_(), 905 | Witness::Win(win_) => win(win_).map_val(|| MainMenu).continue_(), 906 | Witness::Menu(menu_) => game_menu(menu_).map(Playing).continue_(), 907 | Witness::Aim(aim_) => aim(aim_).map(Playing).continue_(), 908 | }, 909 | Paused(running) => pause(running).map(|pause_output| match pause_output { 910 | PauseOutput::ContinueGame { running } => { 911 | LoopControl::Continue(Playing(running.into_witness())) 912 | } 913 | PauseOutput::MainMenu => LoopControl::Continue(MainMenu), 914 | PauseOutput::Quit => LoopControl::Break(()), 915 | }), 916 | MainMenu => main_menu_loop().map(|main_menu_output| match main_menu_output { 917 | MainMenuOutput::NewGame { new_running } => { 918 | LoopControl::Continue(Playing(new_running.into_witness())) 919 | } 920 | MainMenuOutput::Quit => LoopControl::Break(()), 921 | }), 922 | }) 923 | .bound_size(Size::new_u16(80, 60)) 924 | } 925 | --------------------------------------------------------------------------------