diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bf2272c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,25 @@ +name: CI + +on: + push: + branches: ["**"] + pull_request: + branches: [main] + +jobs: + build-and-test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - name: Build + run: cargo build + + - name: Test + run: cargo test -- --test-threads=1 diff --git a/.github/workflows/show-case.yml b/.github/workflows/show-case.yml new file mode 100644 index 0000000..fad2dba --- /dev/null +++ b/.github/workflows/show-case.yml @@ -0,0 +1,152 @@ +name: Showcase + +on: + push: + branches: ["**"] + pull_request: + branches: [main] + +env: + TINYDEW_DB_PATH: /tmp/tinydew-showcase.sqlite + +jobs: + showcase: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Build + run: cargo build + + - name: Initial Status + run: cargo run -- status + + # Basic actions at Farm (start at (3,3)) + - name: Move Down + run: cargo run -- do move down + - name: Status after Move Down + run: cargo run -- status + + - name: Move Left + run: cargo run -- do move left + - name: Status after Move Left + run: cargo run -- status + + - name: Move Right + run: cargo run -- do move right + - name: Status after Move Right + run: cargo run -- status + + - name: Move Up + run: cargo run -- do move up + - name: Status after Move Up + run: cargo run -- status + + - name: Clear Up + run: cargo run -- do clear up + - name: Status after Clear + run: cargo run -- status + + - name: Plant Up + run: cargo run -- do plant up + - name: Status after Plant + run: cargo run -- status + + - name: Water Up + run: cargo run -- do water up + - name: Status after Water + run: cargo run -- status + + - name: Buy Seed + run: cargo run -- do buy seed + - name: Status after Buy + run: cargo run -- status + + - name: Harvest Up + run: cargo run -- do harvest up + - name: Status after Harvest + run: cargo run -- status + + - name: Sell Strawberry + run: cargo run -- do sell 🍓 + - name: Status after Sell Strawberry + run: cargo run -- status + + - name: Sell Mushroom + run: cargo run -- do sell 🍄 + - name: Status after Sell Mushroom + run: cargo run -- status + + # Navigate Farm -> EastPath (from (3,3) to PathEast (7,5)) + - name: Navigate to (3,4) + run: cargo run -- do move down + - name: Navigate to (3,5) + run: cargo run -- do move down + - name: Navigate to (4,5) + run: cargo run -- do move right + - name: Navigate to (5,5) + run: cargo run -- do move right + - name: Navigate to (6,5) + run: cargo run -- do move right + - name: Transition Farm to EastPath + run: cargo run -- do move right + - name: Status at EastPath + run: cargo run -- status + + # Navigate EastPath -> Square (from (1,2) to PathSquare (5,0)) + - name: Navigate to (2,2) + run: cargo run -- do move right + - name: Navigate to (3,2) + run: cargo run -- do move right + - name: Navigate to (4,2) + run: cargo run -- do move right + - name: Navigate to (5,2) + run: cargo run -- do move right + - name: Navigate to (5,1) + run: cargo run -- do move up + - name: Transition EastPath to Square + run: cargo run -- do move up + - name: Status at Square + run: cargo run -- status + + # Navigate Square -> EastPath -> SouthRiver + - name: Transition Square to EastPath + run: cargo run -- do move down + - name: Navigate to (4,1) + run: cargo run -- do move left + - name: Navigate to (3,1) + run: cargo run -- do move left + - name: Navigate to (2,1) + run: cargo run -- do move left + - name: Navigate to (2,2) + run: cargo run -- do move down + - name: Transition EastPath to SouthRiver + run: cargo run -- do move down + - name: Status at SouthRiver + run: cargo run -- status + + # Fish at SouthRiver + - name: Fish Down + run: cargo run -- do fish down + - name: Status after Fish + run: cargo run -- status + + # Navigate SouthRiver -> EastPath -> Farm + - name: Transition SouthRiver to EastPath + run: cargo run -- do move up + - name: Navigate to (1,2) + run: cargo run -- do move left + - name: Transition EastPath to Farm + run: cargo run -- do move left + - name: Status back at Farm + run: cargo run -- status + + # Sleep and day transition + - name: Sleep + run: cargo run -- do sleep + - name: Status Day 2 + run: cargo run -- status diff --git a/.github/workflows/ui.yml b/.github/workflows/ui.yml new file mode 100644 index 0000000..bfe2892 --- /dev/null +++ b/.github/workflows/ui.yml @@ -0,0 +1,20 @@ +name: UI + +on: + push: + branches: ["**"] + pull_request: + branches: [main] + +jobs: + ui-smoke-test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Farm UI regression test + run: cargo test initial_farm_ui -- --nocapture diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..8e77bbb --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,322 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "cc" +version = "1.2.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tinydew" +version = "0.1.0" +dependencies = [ + "rand", + "rusqlite", + "serde", + "serde_json", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..df6a019 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "tinydew" +version = "0.1.0" +edition = "2024" + +[dependencies] +rusqlite = { version = "0.32", features = ["bundled"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +rand = "0.8" diff --git a/src/action.rs b/src/action.rs new file mode 100644 index 0000000..a8569f2 --- /dev/null +++ b/src/action.rs @@ -0,0 +1,561 @@ +use rand::Rng; + +use crate::state::GameState; +use crate::types::*; + +pub fn execute(state: &mut GameState, args: &[&str]) -> String { + if args.is_empty() { + return "Usage: tinydew do [args]".to_string(); + } + + let action = args[0]; + let arg = args.get(1).copied(); + + match action { + "move" => do_move(state, arg), + "clear" => do_directional(state, arg, do_clear), + "plant" => do_directional(state, arg, do_plant), + "water" => do_directional(state, arg, do_water), + "harvest" => do_directional(state, arg, do_harvest), + "fish" => do_fish(state, arg), + "buy" => do_buy(state, arg), + "sell" => do_sell(state, arg), + "sleep" => do_sleep(state), + _ => format!("Unknown action: {}", action), + } +} + +fn do_directional( + state: &mut GameState, + dir_arg: Option<&str>, + handler: fn(&mut GameState, Direction) -> String, +) -> String { + let dir = match dir_arg { + Some(s) => match parse_direction(s) { + Some(d) => d, + None => return format!("Invalid direction: {}", s), + }, + None => state.player.direction, + }; + handler(state, dir) +} + +// --- Movement --- + +fn do_move(state: &mut GameState, dir_arg: Option<&str>) -> String { + let dir = match dir_arg { + Some(s) => match parse_direction(s) { + Some(d) => d, + None => return format!("Invalid direction: {}", s), + }, + None => return "Usage: move ".to_string(), + }; + + state.player.direction = dir; + + let map = state.maps.get(&state.player.region); + let height = map.len(); + let width = map[0].len(); + + let (tx, ty) = match target_pos(state.player.x, state.player.y, dir, width, height) { + Some(pos) => pos, + None => return "Can't move there.".to_string(), + }; + + let tile = &map[ty][tx]; + + if matches!(tile, TileType::Wonder) { + return "That is so beautiful. Let's enjoy it together in the game.".to_string(); + } + + if let TileType::Plant { days_grown, .. } = tile { + if *days_grown >= 1 { + return "A mature crop is in the way. Try harvesting it first.".to_string(); + } + } + + // Check for region transitions + let transition = match (&state.player.region, tile) { + (Region::Farm, TileType::PathEast) => Some((Region::EastPath, 1, 2, Direction::Right, "You walk east along the path.")), + (Region::EastPath, TileType::PathFarm) => Some((Region::Farm, 6, 5, Direction::Left, "You return to the farm.")), + (Region::EastPath, TileType::PathSquare) => Some((Region::Square, 4, 3, Direction::Up, "You enter the square.")), + (Region::Square, TileType::PathSquare) => Some((Region::EastPath, 5, 1, Direction::Down, "You head back to the east path.")), + (Region::EastPath, TileType::PathSouthRiver) => Some((Region::SouthRiver, 2, 1, Direction::Down, "You walk south to the river.")), + (Region::SouthRiver, TileType::PathSouthRiverGate) => Some((Region::EastPath, 2, 2, Direction::Up, "You head back to the east path.")), + _ => None, + }; + + if let Some((region, x, y, facing, msg)) = transition { + state.player.region = region; + state.player.x = x; + state.player.y = y; + state.player.direction = facing; + advance_time(state, 5); + return msg.to_string(); + } + + if !tile.is_walkable() { + return "Can't move there.".to_string(); + } + + state.player.x = tx; + state.player.y = ty; + advance_time(state, 5); + + format!("Moved {}.", dir) +} + +// --- Farming actions --- + +fn do_clear(state: &mut GameState, dir: Direction) -> String { + if state.player.region != Region::Farm { + return "You can't clear here.".to_string(); + } + + let map = state.maps.get(&state.player.region); + let (tx, ty) = match target_pos(state.player.x, state.player.y, dir, map[0].len(), map.len()) { + Some(pos) => pos, + None => return "Can't clear there.".to_string(), + }; + + let tile = &map[ty][tx]; + match tile { + TileType::Grass => { + state.maps.get_mut(&state.player.region)[ty][tx] = TileType::Soil; + advance_time(state, 5); + "Cleared the ground.".to_string() + } + TileType::Plant { days_grown, .. } if *days_grown < 1 => { + state.maps.get_mut(&state.player.region)[ty][tx] = TileType::Soil; + advance_time(state, 5); + "Cleared the ground.".to_string() + } + TileType::Soil => "The ground is already cleared.".to_string(), + TileType::Plant { days_grown, .. } if *days_grown >= 1 => { + "Can't clear a mature crop. Try harvesting it first.".to_string() + } + _ => "Can't clear that.".to_string(), + } +} + +fn do_plant(state: &mut GameState, dir: Direction) -> String { + if state.player.region != Region::Farm { + return "You can't plant here.".to_string(); + } + if state.inventory.seeds == 0 { + return "You don't have any seeds.".to_string(); + } + + let map = state.maps.get(&state.player.region); + let (tx, ty) = match target_pos(state.player.x, state.player.y, dir, map[0].len(), map.len()) { + Some(pos) => pos, + None => return "Can't plant there.".to_string(), + }; + + if map[ty][tx] != TileType::Soil { + return "Can only plant on soil.".to_string(); + } + + let mut rng = rand::thread_rng(); + let crop = match rng.gen_range(0..3) { + 0 => CropType::Carrot, + 1 => CropType::Strawberry, + _ => CropType::Cauliflower, + }; + + state.inventory.seeds -= 1; + state.maps.get_mut(&state.player.region)[ty][tx] = TileType::Plant { + crop, + days_grown: 0, + watered: false, + }; + + advance_time(state, 5); + format!("Planted a seed. A {} is growing! \u{1f331}", crop.name()) +} + +fn do_water(state: &mut GameState, dir: Direction) -> String { + let region = state.player.region; + let map = state.maps.get(®ion); + let (tx, ty) = match target_pos(state.player.x, state.player.y, dir, map[0].len(), map.len()) { + Some(pos) => pos, + None => return "Nothing to water here.".to_string(), + }; + + if !matches!(map[ty][tx], TileType::Plant { .. }) { + return "Nothing to water here.".to_string(); + } + + if let TileType::Plant { watered, .. } = &mut state.maps.get_mut(®ion)[ty][tx] { + *watered = true; + } + + advance_time(state, 5); + "Watered the crop. \u{1f4a7}".to_string() +} + +fn do_harvest(state: &mut GameState, dir: Direction) -> String { + let region = state.player.region; + let map = state.maps.get(®ion); + let (tx, ty) = match target_pos(state.player.x, state.player.y, dir, map[0].len(), map.len()) { + Some(pos) => pos, + None => return "Nothing to harvest here.".to_string(), + }; + + let tile = map[ty][tx].clone(); + match &tile { + TileType::Plant { crop, days_grown, .. } if *days_grown >= 1 => { + if region != Region::Farm { + return "You can't harvest crops here.".to_string(); + } + let crop = *crop; + state.maps.get_mut(®ion)[ty][tx] = TileType::Plant { + crop, + days_grown: 0, + watered: false, + }; + match crop { + CropType::Carrot => state.inventory.carrots += 1, + CropType::Strawberry => state.inventory.strawberries += 1, + CropType::Cauliflower => state.inventory.cauliflowers += 1, + } + advance_time(state, 5); + format!("Harvested a {}! +1 {}", crop.name(), crop.emoji()) + } + TileType::Mushroom => { + state.maps.get_mut(®ion)[ty][tx] = TileType::Soil; + state.inventory.mushrooms += 1; + advance_time(state, 5); + "Foraged a mushroom! +1 \u{1f344}".to_string() + } + TileType::Flower => { + state.maps.get_mut(®ion)[ty][tx] = TileType::Soil; + state.inventory.flowers += 1; + advance_time(state, 5); + "Foraged a flower! +1 \u{1f33a}".to_string() + } + _ => "Nothing to harvest here.".to_string(), + } +} + +// --- Fishing --- + +fn do_fish(state: &mut GameState, dir_arg: Option<&str>) -> String { + let dir = match dir_arg { + Some(s) => match parse_direction(s) { + Some(d) => d, + None => return format!("Invalid direction: {}", s), + }, + None => { + // Auto-target: find adjacent fishable tile + let map = state.maps.get(&state.player.region); + let dirs = [Direction::Up, Direction::Down, Direction::Left, Direction::Right]; + let mut found = None; + for d in dirs { + if let Some((tx, ty)) = + target_pos(state.player.x, state.player.y, d, map[0].len(), map.len()) + { + if map[ty][tx].is_fishable() { + found = Some(d); + break; + } + } + } + match found { + Some(d) => d, + None => return "Can't fish here.".to_string(), + } + } + }; + + let region = state.player.region; + let map = state.maps.get(®ion); + let (tx, ty) = match target_pos(state.player.x, state.player.y, dir, map[0].len(), map.len()) { + Some(pos) => pos, + None => return "Can't fish here.".to_string(), + }; + + if !map[ty][tx].is_fishable() { + return "Can't fish here.".to_string(); + } + + let is_bubble = map[ty][tx] == TileType::RiverBubble; + + if is_bubble { + state.maps.get_mut(®ion)[ty][tx] = TileType::River; + } + + advance_time(state, 60); + + let mut rng = rand::thread_rng(); + let roll: u32 = rng.gen_range(0..100); + + let (common_thresh, rare_thresh) = if is_bubble { (70, 90) } else { (40, 50) }; + + if roll < common_thresh { + state.inventory.common_fish += 1; + "You caught a fish! \u{1f41f}".to_string() + } else if roll < rare_thresh { + state.inventory.rare_fish += 1; + "You caught a rare fish! \u{1f420}".to_string() + } else { + "No bite...".to_string() + } +} + +// --- Economy --- + +fn do_buy(state: &mut GameState, item_arg: Option<&str>) -> String { + let item = match item_arg { + Some(s) => s, + None => return "What would you like to buy?".to_string(), + }; + + match item { + "seed" => { + if state.money >= 10 { + state.money -= 10; + state.inventory.seeds += 1; + "Bought a seed. -$10".to_string() + } else { + "Not enough money.".to_string() + } + } + _ => format!("Can't buy '{}'.", item), + } +} + +fn do_sell(state: &mut GameState, item_arg: Option<&str>) -> String { + let item = match item_arg { + Some(s) => s, + None => return "What would you like to sell?".to_string(), + }; + + match item { + "\u{1f353}" => sell_item(&mut state.inventory.strawberries, &mut state.money, 20, "strawberry", "\u{1f353}"), + "\u{1f955}" => sell_item(&mut state.inventory.carrots, &mut state.money, 15, "carrot", "\u{1f955}"), + "\u{1f966}" => sell_item(&mut state.inventory.cauliflowers, &mut state.money, 25, "cauliflower", "\u{1f966}"), + "\u{1f344}" => sell_item(&mut state.inventory.mushrooms, &mut state.money, 25, "mushroom", "\u{1f344}"), + "\u{1f33a}" => sell_item(&mut state.inventory.flowers, &mut state.money, 25, "flower", "\u{1f33a}"), + "\u{1f41f}" => sell_item(&mut state.inventory.common_fish, &mut state.money, 10, "fish", "\u{1f41f}"), + "\u{1f420}" => sell_item(&mut state.inventory.rare_fish, &mut state.money, 30, "rare fish", "\u{1f420}"), + _ => format!("Can't sell '{}'.", item), + } +} + +fn sell_item(count: &mut u32, money: &mut i32, price: i32, name: &str, emoji: &str) -> String { + if *count > 0 { + *count -= 1; + *money += price; + format!("Sold a {}. +${}", name, price) + } else { + format!("You don't have any {} to sell.", emoji) + } +} + +// --- Sleep & Day Transition --- + +fn do_sleep(state: &mut GameState) -> String { + state.day += 1; + state.time_minutes = 360; // 06:00 + + state.player.x = 3; + state.player.y = 3; + state.player.region = Region::Farm; + state.player.direction = Direction::Down; + + // 1. Weather roll (with festival override) + state.weather = roll_weather(state.day); + + // 2. Crop growth check + watered reset + grow_crops(state); + + // 3. River bubble reset + spawn new bubble + reset_river_bubbles(state); + + // 4. Random spawns + do_random_spawns(state); + + // 5. Soil reverts to grass + revert_soil_to_grass(state); + + // 6. Festival checks + check_festival(state); + + "You slept through the night. Good morning!".to_string() +} + +fn advance_time(state: &mut GameState, minutes: u32) { + state.time_minutes += minutes; +} + +pub fn roll_weather(day: u32) -> Weather { + if day == 1 { + return Weather::Sunny; + } + if day == 28 { + return Weather::Sunny; + } + let hash = day.wrapping_mul(2654435761); + let roll = hash % 100; + if roll < 50 { + Weather::Sunny + } else if roll < 80 { + Weather::Cloudy + } else { + Weather::Rainy + } +} + +fn grow_crops(state: &mut GameState) { + let is_rainy = state.weather == Weather::Rainy; + for region in Region::ALL { + let map = state.maps.get_mut(®ion); + for row in map.iter_mut() { + for tile in row.iter_mut() { + if let TileType::Plant { + watered, + days_grown, + .. + } = tile + { + if is_rainy { + *watered = true; + } + if *watered { + *days_grown = days_grown.saturating_add(1); + } + *watered = false; + } + } + } + } +} + +fn reset_river_bubbles(state: &mut GameState) { + let map = state.maps.get_mut(&Region::SouthRiver); + for row in map.iter_mut() { + for tile in row.iter_mut() { + if *tile == TileType::RiverBubble { + *tile = TileType::River; + } + } + } + // Spawn a new bubble deterministically + spawn_river_bubble(state); +} + +fn spawn_river_bubble(state: &mut GameState) { + let map = state.maps.get_mut(&Region::SouthRiver); + let width = map[0].len(); + let hash = state.day.wrapping_mul(7919); + let x = (hash as usize) % width; + let y = 2 + ((hash as usize / width) % 2); + if map[y][x] == TileType::River { + map[y][x] = TileType::RiverBubble; + } +} + +fn do_random_spawns(state: &mut GameState) { + let day = state.day; + + for (i, region) in Region::ALL.iter().enumerate() { + let region_idx = i as u32; + + // Check for existing flower + let has_flower = { + let map = state.maps.get(region); + map.iter() + .flatten() + .any(|t| matches!(t, TileType::Flower)) + }; + + if !has_flower { + let candidates = { + let map = state.maps.get(region); + collect_grass_candidates(map, *region) + }; + if !candidates.is_empty() { + let hash = det_hash(day, region_idx, 0); + let idx = (hash as usize) % candidates.len(); + let (x, y) = candidates[idx]; + state.maps.get_mut(region)[y][x] = TileType::Flower; + } + } + + // Check for existing mushroom (re-check map since flower may have been placed) + let has_mushroom = { + let map = state.maps.get(region); + map.iter() + .flatten() + .any(|t| matches!(t, TileType::Mushroom)) + }; + + if !has_mushroom { + let candidates = { + let map = state.maps.get(region); + collect_grass_candidates(map, *region) + }; + if !candidates.is_empty() { + let hash = det_hash(day, region_idx, 1); + let idx = (hash as usize) % candidates.len(); + let (x, y) = candidates[idx]; + state.maps.get_mut(region)[y][x] = TileType::Mushroom; + } + } + } +} + +fn collect_grass_candidates(map: &[Vec], region: Region) -> Vec<(usize, usize)> { + let mut candidates = Vec::new(); + for (y, row) in map.iter().enumerate() { + for (x, tile) in row.iter().enumerate() { + if *tile == TileType::Grass && !is_protected_tile(region, x, y) { + candidates.push((x, y)); + } + } + } + candidates +} + +fn is_protected_tile(region: Region, x: usize, y: usize) -> bool { + match region { + Region::Farm => { + // Wake-up position (3,3) + x == 3 && y == 3 + } + _ => false, + } +} + +fn det_hash(day: u32, region_idx: u32, type_idx: u32) -> u32 { + day.wrapping_mul(31) + .wrapping_add(region_idx.wrapping_mul(97)) + .wrapping_add(type_idx.wrapping_mul(53)) + .wrapping_mul(2654435761) +} + +fn revert_soil_to_grass(state: &mut GameState) { + for region in Region::ALL { + let map = state.maps.get_mut(®ion); + for row in map.iter_mut() { + for tile in row.iter_mut() { + if *tile == TileType::Soil { + *tile = TileType::Grass; + } + } + } + } +} + +fn check_festival(state: &mut GameState) { + if state.season == Season::Spring && state.day == 28 { + let map = state.maps.get_mut(&Region::Square); + map[2][2] = TileType::Wonder; + } else { + let map = state.maps.get_mut(&Region::Square); + if map[2][2] == TileType::Wonder { + map[2][2] = TileType::Grass; + } + } +} diff --git a/src/db.rs b/src/db.rs new file mode 100644 index 0000000..6a11f00 --- /dev/null +++ b/src/db.rs @@ -0,0 +1,98 @@ +use rusqlite::{Connection, params}; +use std::path::PathBuf; + +use crate::state::GameState; + +const SCHEMA_VERSION: i32 = 1; + +fn db_path() -> PathBuf { + if let Ok(path) = std::env::var("TINYDEW_DB_PATH") { + return PathBuf::from(path); + } + + let base = if let Ok(xdg) = std::env::var("XDG_DATA_HOME") { + PathBuf::from(xdg) + } else if let Ok(home) = std::env::var("HOME") { + PathBuf::from(home).join(".local").join("share") + } else { + PathBuf::from(".") + }; + + base.join("tinydew").join("tinydew.sqlite") +} + +pub fn load_or_create() -> GameState { + let path = db_path(); + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).expect("Failed to create data directory"); + } + + let conn = Connection::open(&path).expect("Failed to open database"); + init_db(&conn); + + let payload: Option = conn + .query_row("SELECT payload FROM game_save WHERE id = 1", [], |row| { + row.get(0) + }) + .ok(); + + if let Some(json) = payload { + if let Ok(state) = serde_json::from_str::(&json) { + return state; + } + } + + let state = GameState::new(); + save_with_conn(&conn, &state); + state +} + +pub fn save(state: &GameState) { + let path = db_path(); + let conn = Connection::open(&path).expect("Failed to open database"); + conn.pragma_update(None, "journal_mode", "wal").ok(); + save_with_conn(&conn, state); +} + +fn init_db(conn: &Connection) { + conn.pragma_update(None, "journal_mode", "wal").ok(); + + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS schema_version ( + id INTEGER PRIMARY KEY CHECK (id = 1), + version INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS game_save ( + id INTEGER PRIMARY KEY CHECK (id = 1), + updated_at TEXT NOT NULL, + payload TEXT NOT NULL + );", + ) + .expect("Failed to create tables"); + + let version: Option = conn + .query_row( + "SELECT version FROM schema_version WHERE id = 1", + [], + |row| row.get(0), + ) + .ok(); + + if version.is_none() { + conn.execute( + "INSERT INTO schema_version (id, version) VALUES (1, ?1)", + params![SCHEMA_VERSION], + ) + .expect("Failed to insert schema version"); + } +} + +fn save_with_conn(conn: &Connection, state: &GameState) { + let json = serde_json::to_string(state).expect("Failed to serialize state"); + conn.execute( + "INSERT OR REPLACE INTO game_save (id, updated_at, payload) VALUES (1, datetime('now'), ?1)", + params![json], + ) + .expect("Failed to save game state"); +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..d216a59 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,37 @@ +pub mod action; +pub mod db; +pub mod map; +pub mod state; +pub mod types; +pub mod ui; + +#[cfg(test)] +mod tests { + use crate::state::GameState; + use crate::ui; + + #[test] + fn initial_farm_ui() { + let state = GameState::new(); + let output = ui::format_status(&state); + println!("{}", output); + + // Verify header + assert!(output.contains("tinydew day 1")); + assert!(output.contains("06:00")); + + // Verify map elements + assert!(output.contains("\u{1f9d1}")); // 🧑 player + assert!(output.contains("\u{1f3e0}")); // 🏠 house + assert!(output.contains("\u{1f333}")); // 🌳 boundary + + // Verify inventory + assert!(output.contains("\u{1fad9} x1")); // 🫙 x1 seed + + // Verify money + assert!(output.contains("Money: \u{1f4b0} $100")); + + // Verify greeting + assert!(output.contains("Good morning!")); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..b52a508 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,48 @@ +use std::process; + +fn main() { + let args: Vec = std::env::args().collect(); + + if args.len() < 2 { + print_help(); + return; + } + + match args[1].as_str() { + "-h" | "--help" => print_help(), + "-V" | "--version" => println!("tinydew 0.1.0"), + "status" => { + let state = tinydew::db::load_or_create(); + tinydew::ui::render_status(&state); + } + "do" => { + if args.len() < 3 { + eprintln!("Usage: tinydew do [args]"); + process::exit(1); + } + let mut state = tinydew::db::load_or_create(); + let action_args: Vec<&str> = args[2..].iter().map(|s| s.as_str()).collect(); + let result = tinydew::action::execute(&mut state, &action_args); + println!("{}", result); + tinydew::db::save(&state); + } + other => { + eprintln!("Unknown command: {}", other); + process::exit(1); + } + } +} + +fn print_help() { + println!("tinydew - A cozy farming game"); + println!(); + println!("Usage: tinydew [ARGS...]"); + println!(); + println!("Commands:"); + println!(" status Show current game status"); + println!(" do Execute an action (move, clear, plant, water, harvest, fish, buy, sell, sleep)"); + println!(); + println!("Options:"); + println!(" -h, --help Display help information"); + println!(" -V, --version Display version information"); +} diff --git a/src/map.rs b/src/map.rs new file mode 100644 index 0000000..91a2b18 --- /dev/null +++ b/src/map.rs @@ -0,0 +1,64 @@ +use crate::types::TileType::{self, *}; + +pub fn create_farm() -> Vec> { + let b = Boundary; + let g = Grass; + let h = House; + let pe = PathEast; + + vec![ + vec![b.clone(), b.clone(), b.clone(), b.clone(), b.clone(), b.clone(), b.clone(), b.clone()], + vec![b.clone(), g.clone(), g.clone(), g.clone(), g.clone(), g.clone(), g.clone(), b.clone()], + vec![b.clone(), g.clone(), h, g.clone(), g.clone(), g.clone(), g.clone(), b.clone()], + vec![b.clone(), g.clone(), g.clone(), g.clone(), g.clone(), g.clone(), g.clone(), b.clone()], + vec![b.clone(), g.clone(), g.clone(), g.clone(), g.clone(), g.clone(), g.clone(), b.clone()], + vec![b.clone(), g.clone(), g.clone(), g.clone(), g.clone(), g.clone(), g.clone(), pe ], + vec![b.clone(), g.clone(), g.clone(), g.clone(), g.clone(), g.clone(), g.clone(), b.clone()], + vec![b.clone(), b.clone(), b.clone(), b.clone(), b.clone(), b.clone(), b.clone(), b.clone()], + ] +} + +pub fn create_east_path() -> Vec> { + let b = Boundary; + let g = Grass; + let pf = PathFarm; + let ps = PathSquare; + let psr = PathSouthRiver; + let m = Mushroom; + + vec![ + vec![b.clone(), b.clone(), b.clone(), b.clone(), b.clone(), ps, b.clone(), b.clone(), b.clone(), b.clone(), b.clone()], + vec![b.clone(), g.clone(), g.clone(), g.clone(), g.clone(), g.clone(), g.clone(), g.clone(), g.clone(), g.clone(), b.clone()], + vec![pf, g.clone(), g.clone(), g.clone(), g.clone(), g.clone(), g.clone(), g.clone(), g.clone(), m, b.clone()], + vec![b.clone(), b.clone(), psr, b.clone(), b.clone(), b.clone(), b.clone(), b.clone(), b.clone(), b.clone(), b.clone()], + ] +} + +pub fn create_square() -> Vec> { + let b = Boundary; + let g = Grass; + let f = Flower; + let fn_ = Fountain; + let ps = PathSquare; + + vec![ + vec![b.clone(), b.clone(), b.clone(), b.clone(), b.clone(), b.clone(), b.clone(), b.clone(), b.clone()], + vec![b.clone(), f, g.clone(), g.clone(), g.clone(), g.clone(), g.clone(), g.clone(), b.clone()], + vec![b.clone(), g.clone(), g.clone(), g.clone(), fn_, g.clone(), g.clone(), g.clone(), b.clone()], + vec![b.clone(), g.clone(), g.clone(), g.clone(), g.clone(), g.clone(), g.clone(), g.clone(), b.clone()], + vec![b.clone(), b.clone(), b.clone(), b.clone(), ps, b.clone(), b.clone(), b.clone(), b.clone()], + ] +} + +pub fn create_south_river() -> Vec> { + let g = Grass; + let pg = PathSouthRiverGate; + let r = River; + + vec![ + vec![g.clone(), g.clone(), pg, g.clone(), g.clone(), g.clone(), g.clone(), g.clone(), g.clone(), g.clone(), g.clone(), g.clone(), g.clone()], + vec![g.clone(), g.clone(), g.clone(), g.clone(), g.clone(), g.clone(), g.clone(), g.clone(), g.clone(), g.clone(), g.clone(), g.clone(), g.clone()], + vec![r.clone(), r.clone(), r.clone(), r.clone(), r.clone(), r.clone(), r.clone(), r.clone(), r.clone(), r.clone(), r.clone(), r.clone(), r.clone()], + vec![r.clone(), r.clone(), r.clone(), r.clone(), r.clone(), r.clone(), r.clone(), r.clone(), r.clone(), r.clone(), r.clone(), r.clone(), r.clone()], + ] +} diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..0460a4f --- /dev/null +++ b/src/state.rs @@ -0,0 +1,44 @@ +use serde::{Deserialize, Serialize}; + +use crate::map; +use crate::types::*; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct GameState { + pub day: u32, + pub time_minutes: u32, + pub weather: Weather, + pub season: Season, + pub player: Player, + pub maps: RegionMaps, + pub inventory: Inventory, + pub money: i32, +} + +impl GameState { + pub fn new() -> Self { + Self { + day: 1, + time_minutes: 360, // 06:00 + weather: Weather::Sunny, + season: Season::Spring, + player: Player { + x: 3, + y: 3, + region: Region::Farm, + direction: Direction::Down, + }, + maps: RegionMaps { + farm: map::create_farm(), + east_path: map::create_east_path(), + square: map::create_square(), + south_river: map::create_south_river(), + }, + inventory: Inventory { + seeds: 1, + ..Inventory::default() + }, + money: 100, + } + } +} diff --git a/src/types.rs b/src/types.rs new file mode 100644 index 0000000..900851e --- /dev/null +++ b/src/types.rs @@ -0,0 +1,275 @@ +use serde::{Deserialize, Serialize}; +use std::fmt; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Region { + Farm, + EastPath, + Square, + SouthRiver, +} + +impl Region { + pub const ALL: [Region; 4] = [ + Region::Farm, + Region::EastPath, + Region::Square, + Region::SouthRiver, + ]; +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Direction { + Up, + Down, + Left, + Right, +} + +impl fmt::Display for Direction { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Direction::Up => write!(f, "up"), + Direction::Down => write!(f, "down"), + Direction::Left => write!(f, "left"), + Direction::Right => write!(f, "right"), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Weather { + Sunny, + Cloudy, + Rainy, +} + +impl Weather { + pub fn icon(&self, is_night: bool) -> &str { + if is_night { + "\u{1f319}" // 🌙 + } else { + match self { + Weather::Sunny => "\u{2600}\u{fe0f}", // ☀️ + Weather::Cloudy => "\u{26c5}", // ⛅ + Weather::Rainy => "\u{1f327}", // 🌧 + } + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Season { + Spring, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum CropType { + Carrot, + Strawberry, + Cauliflower, +} + +impl CropType { + pub fn emoji(&self) -> &str { + match self { + CropType::Carrot => "\u{1f955}", // 🥕 + CropType::Strawberry => "\u{1f353}", // 🍓 + CropType::Cauliflower => "\u{1f966}", // 🥦 + } + } + + pub fn name(&self) -> &str { + match self { + CropType::Carrot => "carrot", + CropType::Strawberry => "strawberry", + CropType::Cauliflower => "cauliflower", + } + } + + pub fn sell_price(&self) -> i32 { + match self { + CropType::Carrot => 15, + CropType::Strawberry => 20, + CropType::Cauliflower => 25, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum TileType { + Boundary, + Grass, + Soil, + Plant { + crop: CropType, + days_grown: u8, + watered: bool, + }, + House, + PathEast, + PathFarm, + PathSquare, + PathSouthRiver, + PathSouthRiverGate, + Flower, + Mushroom, + Fountain, + River, + RiverBubble, + Wonder, +} + +impl TileType { + pub fn is_walkable(&self) -> bool { + match self { + TileType::Grass + | TileType::Soil + | TileType::PathEast + | TileType::PathFarm + | TileType::PathSquare + | TileType::PathSouthRiver + | TileType::PathSouthRiverGate => true, + TileType::Plant { days_grown, .. } => *days_grown < 1, + _ => false, + } + } + + pub fn emoji(&self) -> &str { + match self { + TileType::Boundary => "\u{1f333}", // 🌳 + TileType::Grass => "\u{1f33f}", // 🌿 + TileType::Soil => "\u{1f343}", // 🍃 + TileType::Plant { days_grown, crop, .. } => { + if *days_grown >= 1 { + crop.emoji() + } else { + "\u{1f331}" // 🌱 + } + } + TileType::House => "\u{1f3e0}", // 🏠 + TileType::PathEast + | TileType::PathFarm + | TileType::PathSquare + | TileType::PathSouthRiver + | TileType::PathSouthRiverGate => "\u{1f33f}", // 🌿 + TileType::Flower => "\u{1f33a}", // 🌺 + TileType::Mushroom => "\u{1f344}", // 🍄 + TileType::Fountain => "\u{26f2}", // ⛲ + TileType::River => "\u{1f30a}", // 🌊 + TileType::RiverBubble => "\u{1fab7}", // 🫧 + TileType::Wonder => "\u{1f98b}", // 🦋 + } + } + + pub fn is_fishable(&self) -> bool { + matches!(self, TileType::River | TileType::RiverBubble) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Player { + pub x: usize, + pub y: usize, + pub region: Region, + pub direction: Direction, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct Inventory { + pub seeds: u32, + pub carrots: u32, + pub strawberries: u32, + pub cauliflowers: u32, + pub mushrooms: u32, + pub flowers: u32, + pub common_fish: u32, + pub rare_fish: u32, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct RegionMaps { + pub farm: Vec>, + pub east_path: Vec>, + pub square: Vec>, + pub south_river: Vec>, +} + +impl RegionMaps { + pub fn get(&self, region: &Region) -> &Vec> { + match region { + Region::Farm => &self.farm, + Region::EastPath => &self.east_path, + Region::Square => &self.square, + Region::SouthRiver => &self.south_river, + } + } + + pub fn get_mut(&mut self, region: &Region) -> &mut Vec> { + match region { + Region::Farm => &mut self.farm, + Region::EastPath => &mut self.east_path, + Region::Square => &mut self.square, + Region::SouthRiver => &mut self.south_river, + } + } +} + +pub fn parse_direction(s: &str) -> Option { + match s { + "up" => Some(Direction::Up), + "down" => Some(Direction::Down), + "left" => Some(Direction::Left), + "right" => Some(Direction::Right), + _ => None, + } +} + +pub fn target_pos( + x: usize, + y: usize, + dir: Direction, + width: usize, + height: usize, +) -> Option<(usize, usize)> { + match dir { + Direction::Up => { + if y > 0 { + Some((x, y - 1)) + } else { + None + } + } + Direction::Down => { + if y + 1 < height { + Some((x, y + 1)) + } else { + None + } + } + Direction::Left => { + if x > 0 { + Some((x - 1, y)) + } else { + None + } + } + Direction::Right => { + if x + 1 < width { + Some((x + 1, y)) + } else { + None + } + } + } +} + +pub fn format_time(time_minutes: u32) -> String { + let m = time_minutes % 1440; + format!("{:02}:{:02}", m / 60, m % 60) +} + +pub fn is_night(time_minutes: u32) -> bool { + let m = time_minutes % 1440; + m >= 1080 || m < 360 +} diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..e27790f --- /dev/null +++ b/src/ui.rs @@ -0,0 +1,98 @@ +use crate::state::GameState; +use crate::types::*; + +pub fn render_status(state: &GameState) { + print!("{}", format_status(state)); +} + +pub fn format_status(state: &GameState) -> String { + let mut out = String::new(); + + // Top line: tinydew day