Merge branch 'dev' into 'main'

Merge Sprint 4

Closes #42

See merge request softwaregrundprojekt/2025-2026/einzelprojekt/tutorium-moritz/bernroider-dominik/bernroider-dominik!47
This commit is contained in:
Moritz Peter Maile
2025-12-12 22:41:25 +00:00
83 changed files with 3164 additions and 517 deletions

582
Cargo.lock generated
View File

@@ -210,7 +210,7 @@ dependencies = [
"flate2", "flate2",
"itertools 0.13.0", "itertools 0.13.0",
"nom", "nom",
"strum", "strum 0.26.3",
"thiserror 1.0.69", "thiserror 1.0.69",
] ]
@@ -1638,6 +1638,15 @@ version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]] [[package]]
name = "block2" name = "block2"
version = "0.5.1" version = "0.5.1"
@@ -1892,7 +1901,7 @@ dependencies = [
"bitflags 1.3.2", "bitflags 1.3.2",
"core-foundation 0.9.4", "core-foundation 0.9.4",
"core-graphics-types 0.1.3", "core-graphics-types 0.1.3",
"foreign-types", "foreign-types 0.5.0",
"libc", "libc",
] ]
@@ -1984,6 +1993,15 @@ dependencies = [
"windows 0.54.0", "windows 0.54.0",
] ]
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "crc32fast" name = "crc32fast"
version = "1.5.0" version = "1.5.0"
@@ -2029,6 +2047,16 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]] [[package]]
name = "ctrlc" name = "ctrlc"
version = "3.5.0" version = "3.5.0"
@@ -2079,6 +2107,16 @@ dependencies = [
"unicode-xid", "unicode-xid",
] ]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]] [[package]]
name = "directories" name = "directories"
version = "6.0.0" version = "6.0.0"
@@ -2106,6 +2144,17 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b"
[[package]]
name = "displaydoc"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "disqualified" name = "disqualified"
version = "1.0.0" version = "1.0.0"
@@ -2186,6 +2235,15 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@@ -2330,6 +2388,15 @@ dependencies = [
"ttf-parser 0.20.0", "ttf-parser 0.20.0",
] ]
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared 0.1.1",
]
[[package]] [[package]]
name = "foreign-types" name = "foreign-types"
version = "0.5.0" version = "0.5.0"
@@ -2337,7 +2404,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
dependencies = [ dependencies = [
"foreign-types-macros", "foreign-types-macros",
"foreign-types-shared", "foreign-types-shared 0.3.1",
] ]
[[package]] [[package]]
@@ -2351,12 +2418,27 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]] [[package]]
name = "foreign-types-shared" name = "foreign-types-shared"
version = "0.3.1" version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
dependencies = [
"percent-encoding",
]
[[package]] [[package]]
name = "futures-channel" name = "futures-channel"
version = "0.3.31" version = "0.3.31"
@@ -2391,6 +2473,16 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]] [[package]]
name = "gethostname" name = "gethostname"
version = "1.1.0" version = "1.1.0"
@@ -2693,6 +2785,124 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df"
[[package]]
name = "http"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
dependencies = [
"bytes",
"itoa",
]
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "icu_collections"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43"
dependencies = [
"displaydoc",
"potential_utf",
"yoke",
"zerofrom",
"zerovec",
]
[[package]]
name = "icu_locale_core"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6"
dependencies = [
"displaydoc",
"litemap",
"tinystr",
"writeable",
"zerovec",
]
[[package]]
name = "icu_normalizer"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599"
dependencies = [
"icu_collections",
"icu_normalizer_data",
"icu_properties",
"icu_provider",
"smallvec",
"zerovec",
]
[[package]]
name = "icu_normalizer_data"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a"
[[package]]
name = "icu_properties"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99"
dependencies = [
"icu_collections",
"icu_locale_core",
"icu_properties_data",
"icu_provider",
"zerotrie",
"zerovec",
]
[[package]]
name = "icu_properties_data"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899"
[[package]]
name = "icu_provider"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614"
dependencies = [
"displaydoc",
"icu_locale_core",
"writeable",
"yoke",
"zerofrom",
"zerotrie",
"zerovec",
]
[[package]]
name = "idna"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
dependencies = [
"idna_adapter",
"smallvec",
"utf8_iter",
]
[[package]]
name = "idna_adapter"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344"
dependencies = [
"icu_normalizer",
"icu_properties",
]
[[package]] [[package]]
name = "image" name = "image"
version = "0.25.8" version = "0.25.8"
@@ -2927,6 +3137,12 @@ version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
[[package]]
name = "litemap"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
[[package]] [[package]]
name = "litrs" name = "litrs"
version = "0.4.2" version = "0.4.2"
@@ -2999,7 +3215,7 @@ dependencies = [
"bitflags 2.9.4", "bitflags 2.9.4",
"block", "block",
"core-graphics-types 0.2.0", "core-graphics-types 0.2.0",
"foreign-types", "foreign-types 0.5.0",
"log", "log",
"objc", "objc",
"paste", "paste",
@@ -3075,6 +3291,23 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "native-tls"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]] [[package]]
name = "ndk" name = "ndk"
version = "0.8.0" version = "0.8.0"
@@ -3496,6 +3729,50 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "openssl"
version = "0.10.74"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24ad14dd45412269e1a30f52ad8f0664f0f4f4a89ee8fe28c3b3527021ebb654"
dependencies = [
"bitflags 2.9.4",
"cfg-if",
"foreign-types 0.3.2",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "openssl-probe"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]]
name = "openssl-sys"
version = "0.9.110"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a9f0075ba3c21b09f8e8b2026584b1d18d49388648f2fbbf3c97ea8deced8e2"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]] [[package]]
name = "option-ext" name = "option-ext"
version = "0.2.0" version = "0.2.0"
@@ -3663,6 +3940,10 @@ dependencies = [
"directories", "directories",
"serde", "serde",
"serde_json", "serde_json",
"strum 0.27.2",
"strum_macros 0.27.2",
"tungstenite",
"url",
"uuid", "uuid",
] ]
@@ -3681,6 +3962,15 @@ dependencies = [
"portable-atomic", "portable-atomic",
] ]
[[package]]
name = "potential_utf"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77"
dependencies = [
"zerovec",
]
[[package]] [[package]]
name = "pp-rs" name = "pp-rs"
version = "0.2.1" version = "0.2.1"
@@ -3913,6 +4203,7 @@ checksum = "e7ceb6607dd738c99bc8cb28eff249b7cd5c8ec88b9db96c0608c1480d140fb1"
dependencies = [ dependencies = [
"cpal", "cpal",
"lewton", "lewton",
"symphonia",
] ]
[[package]] [[package]]
@@ -4019,6 +4310,15 @@ dependencies = [
"winapi-util", "winapi-util",
] ]
[[package]]
name = "schannel"
version = "0.1.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
dependencies = [
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "scoped-tls" name = "scoped-tls"
version = "1.0.1" version = "1.0.1"
@@ -4044,6 +4344,29 @@ dependencies = [
"tiny-skia", "tiny-skia",
] ]
[[package]]
name = "security-framework"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
"bitflags 2.9.4",
"core-foundation 0.9.4",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]] [[package]]
name = "self_cell" name = "self_cell"
version = "1.2.0" version = "1.2.0"
@@ -4099,6 +4422,17 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]] [[package]]
name = "sharded-slab" name = "sharded-slab"
version = "0.1.7" version = "0.1.7"
@@ -4233,9 +4567,15 @@ version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
dependencies = [ dependencies = [
"strum_macros", "strum_macros 0.26.4",
] ]
[[package]]
name = "strum"
version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
[[package]] [[package]]
name = "strum_macros" name = "strum_macros"
version = "0.26.4" version = "0.26.4"
@@ -4249,6 +4589,18 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "strum_macros"
version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "svg_fmt" name = "svg_fmt"
version = "0.4.5" version = "0.4.5"
@@ -4266,6 +4618,55 @@ dependencies = [
"zeno", "zeno",
] ]
[[package]]
name = "symphonia"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039"
dependencies = [
"lazy_static",
"symphonia-bundle-mp3",
"symphonia-core",
"symphonia-metadata",
]
[[package]]
name = "symphonia-bundle-mp3"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed"
dependencies = [
"lazy_static",
"log",
"symphonia-core",
"symphonia-metadata",
]
[[package]]
name = "symphonia-core"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af"
dependencies = [
"arrayvec",
"bitflags 1.3.2",
"bytemuck",
"lazy_static",
"log",
]
[[package]]
name = "symphonia-metadata"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16"
dependencies = [
"encoding_rs",
"lazy_static",
"log",
"symphonia-core",
]
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.106" version = "2.0.106"
@@ -4277,6 +4678,17 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "synstructure"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "sys-locale" name = "sys-locale"
version = "0.3.2" version = "0.3.2"
@@ -4312,6 +4724,19 @@ dependencies = [
"slotmap", "slotmap",
] ]
[[package]]
name = "tempfile"
version = "3.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"once_cell",
"rustix 1.1.2",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "termcolor" name = "termcolor"
version = "1.4.1" version = "1.4.1"
@@ -4395,6 +4820,16 @@ dependencies = [
"strict-num", "strict-num",
] ]
[[package]]
name = "tinystr"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869"
dependencies = [
"displaydoc",
"zerovec",
]
[[package]] [[package]]
name = "tinyvec" name = "tinyvec"
version = "1.10.0" version = "1.10.0"
@@ -4542,6 +4977,24 @@ version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
[[package]]
name = "tungstenite"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442"
dependencies = [
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"native-tls",
"rand",
"sha1",
"thiserror 2.0.17",
"utf-8",
]
[[package]] [[package]]
name = "twox-hash" name = "twox-hash"
version = "2.1.2" version = "2.1.2"
@@ -4554,6 +5007,12 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
[[package]]
name = "typenum"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]] [[package]]
name = "typewit" name = "typewit"
version = "1.14.2" version = "1.14.2"
@@ -4620,6 +5079,30 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "url"
version = "2.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b"
dependencies = [
"form_urlencoded",
"idna",
"percent-encoding",
"serde",
]
[[package]]
name = "utf-8"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8_iter"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]] [[package]]
name = "uuid" name = "uuid"
version = "1.18.1" version = "1.18.1"
@@ -4649,6 +5132,12 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]] [[package]]
name = "vec_map" name = "vec_map"
version = "0.8.2" version = "0.8.2"
@@ -5581,6 +6070,12 @@ version = "0.46.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
[[package]]
name = "writeable"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
[[package]] [[package]]
name = "x11-dl" name = "x11-dl"
version = "2.21.0" version = "2.21.0"
@@ -5650,6 +6145,29 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5"
[[package]]
name = "yoke"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954"
dependencies = [
"stable_deref_trait",
"yoke-derive",
"zerofrom",
]
[[package]]
name = "yoke-derive"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d"
dependencies = [
"proc-macro2",
"quote",
"syn",
"synstructure",
]
[[package]] [[package]]
name = "zeno" name = "zeno"
version = "0.3.3" version = "0.3.3"
@@ -5675,3 +6193,57 @@ dependencies = [
"quote", "quote",
"syn", "syn",
] ]
[[package]]
name = "zerofrom"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5"
dependencies = [
"zerofrom-derive",
]
[[package]]
name = "zerofrom-derive"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
dependencies = [
"proc-macro2",
"quote",
"syn",
"synstructure",
]
[[package]]
name = "zerotrie"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851"
dependencies = [
"displaydoc",
"yoke",
"zerofrom",
]
[[package]]
name = "zerovec"
version = "0.11.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002"
dependencies = [
"yoke",
"zerofrom",
"zerovec-derive",
]
[[package]]
name = "zerovec-derive"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3"
dependencies = [
"proc-macro2",
"quote",
"syn",
]

View File

@@ -14,12 +14,16 @@ codegen-units = 1
lto = "thin" lto = "thin"
[dependencies] [dependencies]
bevy = "0.17.2" bevy = { version = "0.17.2", features = ["mp3"] }
bevy_aseprite_ultra = "0.7.0" bevy_aseprite_ultra = "0.7.0"
bevy_dev_tools = "0.17.2" bevy_dev_tools = "0.17.2"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
directories = "6.0" directories = "6.0"
tungstenite = { version = "0.28.0", features = ["native-tls"] }
url = "2.5.7"
strum = "0.27.2"
strum_macros = "0.27.2"
[dev-dependencies] [dev-dependencies]
uuid = "1.18.1" uuid = "1.18.1"

View File

@@ -30,6 +30,7 @@ cargo run
1. [Install Rust](https://rust-lang.org/tools/install/) 1. [Install Rust](https://rust-lang.org/tools/install/)
2. [Install Bevy OS depedencies](https://bevy.org/learn/quick-start/getting-started/setup/#installing-os-dependencies) 2. [Install Bevy OS depedencies](https://bevy.org/learn/quick-start/getting-started/setup/#installing-os-dependencies)
3. Install `openssl`
```sh ```sh
git clone https://gitlab.uni-ulm.de/softwaregrundprojekt/2025-2026/einzelprojekt/tutorium-moritz/bernroider-dominik/bernroider-dominik pomomon-garden git clone https://gitlab.uni-ulm.de/softwaregrundprojekt/2025-2026/einzelprojekt/tutorium-moritz/bernroider-dominik/bernroider-dominik pomomon-garden

BIN
assets/achievement.aseprite Normal file

Binary file not shown.

View File

@@ -1,9 +1,9 @@
{ {
"grid_width": 12, "grid_width": 15,
"grid_height": 4, "grid_height": 5,
"pom_speed": 1.5, "pom_speed": 1.5,
"shovel_base_price": 10, "shovel_base_price": 10,
"shovel_rate": 0.5, "shovel_rate": 0.2,
"berry_seeds": [ "berry_seeds": [
{ {
"name": "Normale Samen", "name": "Normale Samen",
@@ -26,5 +26,7 @@
"slice": "Seed3", "slice": "Seed3",
"growth_stages": 6 "growth_stages": 6
} }
] ],
"wonder_event_url": "wss://pomomon.farm/ws",
"berries_per_focus_minute": 1
} }

BIN
assets/shovel.aseprite Normal file

Binary file not shown.

BIN
assets/sounds/beep.mp3 Normal file

Binary file not shown.

View File

@@ -48,6 +48,8 @@
libxkbcommon libxkbcommon
# linker # linker
lld lld
openssl
]; ];
runtimeLibs = pkgs.lib.makeLibraryPath bevyDeps; runtimeLibs = pkgs.lib.makeLibraryPath bevyDeps;

View File

@@ -0,0 +1,85 @@
use crate::{features::phase::components::SessionTracker, prelude::*};
use std::collections::HashMap;
use strum_macros::EnumIter;
/// Represents an unlockable achievement.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, EnumIter)]
pub enum AchievementId {
// Berry achievements
FirstSteps,
MasterFarmer,
BerryTycoon,
// Focus achievements,
GettingStarted,
FocusMaster,
ZenMaster,
// Withered achievements
Negligent,
CompostKing,
}
impl AchievementId {
/// Title to be displayed ingame
pub fn title(&self) -> String {
match self {
AchievementId::FirstSteps => "Erste Schritte",
AchievementId::MasterFarmer => "Meisterbauer",
AchievementId::BerryTycoon => "Beeren-Tycoon",
AchievementId::GettingStarted => "Aller Anfang",
AchievementId::FocusMaster => "Fokus-Meister",
AchievementId::ZenMaster => "Zen-Meister",
AchievementId::Negligent => "Nachlässig",
AchievementId::CompostKing => "Kompost-König",
}
.into()
}
/// Description to be displayed ingame
pub fn description(&self) -> String {
match self {
AchievementId::FirstSteps => "Verdiene eine Beere.",
AchievementId::MasterFarmer => "Verdiene 100 Beeren.",
AchievementId::BerryTycoon => "Verdiene 1.000 Beeren.",
AchievementId::GettingStarted => "Schließe deine erste Fokus-Phase ab.",
AchievementId::FocusMaster => "Schließe 10 Fokus-Phasen ab.",
AchievementId::ZenMaster => "Schließe 50 Fokus-Phasen ab.",
AchievementId::Negligent => "Lasse eine Pflanze verdorren.",
AchievementId::CompostKing => "Lasse 10 Pflanzen verdorren.",
}
.into()
}
/// Label to be displayed ingame (Title: Description)
pub fn label(&self) -> String {
format!("{}: {}", self.title(), self.description())
}
/// Checks if an achievement's conditions are met
pub fn conditions_met(&self, tracker: &SessionTracker) -> bool {
match self {
AchievementId::FirstSteps => tracker.total_berries_earned >= 1,
AchievementId::MasterFarmer => tracker.total_berries_earned >= 100,
AchievementId::BerryTycoon => tracker.total_berries_earned >= 1000,
AchievementId::GettingStarted => tracker.completed_focus_phases >= 1,
AchievementId::FocusMaster => tracker.completed_focus_phases >= 10,
AchievementId::ZenMaster => tracker.completed_focus_phases >= 50,
AchievementId::Negligent => tracker.total_plants_withered >= 1,
AchievementId::CompostKing => tracker.total_plants_withered >= 10,
}
}
}
#[derive(Resource, Default, Debug, Serialize, Deserialize, Clone)]
pub struct AchievementProgress {
pub unlocked: HashMap<AchievementId, bool>,
}
impl AchievementProgress {
pub fn is_unlocked(&self, id: &AchievementId) -> bool {
*self.unlocked.get(id).unwrap_or(&false)
}
pub fn unlock(&mut self, id: AchievementId) {
self.unlocked.insert(id, true);
}
}

View File

@@ -0,0 +1,33 @@
use crate::features::notification::components::Notifications;
use crate::features::phase::components::SessionTracker;
use crate::prelude::*;
use components::{AchievementId, AchievementProgress};
use strum::IntoEnumIterator;
pub mod components;
pub mod ui;
pub struct AchievementPlugin;
impl Plugin for AchievementPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<AchievementProgress>();
app.add_systems(
Update,
check_achievements.run_if(in_state(AppState::GameScreen)),
);
}
}
pub fn check_achievements(
tracker: Res<SessionTracker>,
mut progress: ResMut<AchievementProgress>,
mut notifications: ResMut<Notifications>,
) {
for achievement in AchievementId::iter() {
if !progress.is_unlocked(&achievement) && achievement.conditions_met(&tracker) {
progress.unlock(achievement.clone());
notifications.info(Some("Erfolg freigeschaltet!"), achievement.label());
}
}
}

View File

@@ -0,0 +1,90 @@
use crate::features::achievement::components::{AchievementId, AchievementProgress};
use crate::features::ui::ui::popups::spawn_popup;
use crate::prelude::*;
use strum::IntoEnumIterator;
#[derive(Component)]
pub enum AchievementRootMarker {
Menu,
}
pub fn open_achievements_menu(commands: &mut Commands, progress: &AchievementProgress) {
spawn_popup(
commands,
AchievementRootMarker::Menu,
"Erfolge",
Node {
width: px(700),
height: px(500),
..default()
},
|parent| {
// Scrollable Content
parent
.spawn((Node {
width: percent(100),
height: percent(100),
flex_direction: FlexDirection::Column,
overflow: Overflow::scroll_y(),
padding: UiRect::all(px(10)),
row_gap: px(10),
..default()
},))
.with_children(|list| {
for id in AchievementId::iter() {
let unlocked = progress.is_unlocked(&id);
let color = if unlocked {
Color::WHITE
} else {
Color::srgb(0.5, 0.5, 0.5)
};
let bg_color = if unlocked {
Color::srgb(0.2, 0.3, 0.2) // Dark Greenish for unlocked
} else {
Color::srgb(0.1, 0.1, 0.1) // Dark Grey for locked
};
list.spawn((
Node {
width: percent(100),
padding: UiRect::all(px(10)),
flex_direction: FlexDirection::Column,
row_gap: px(5),
border: UiRect::all(px(2)),
..default()
},
BackgroundColor(bg_color),
BorderColor::all(if unlocked {
Color::srgb(0.4, 0.8, 0.4)
} else {
Color::BLACK
}),
BorderRadius::all(px(5)),
))
.with_children(|item| {
// Title
item.spawn(text(id.title(), 20.0, color));
// Description
item.spawn(text(id.description(), 16.0, color));
// Status Text
let status = if unlocked {
"Freigeschaltet"
} else {
"Gesperrt"
};
item.spawn(text(
status,
12.0,
if unlocked {
Color::srgb(0.6, 1.0, 0.6)
} else {
Color::srgb(0.7, 0.3, 0.3)
},
));
});
}
});
},
);
}

View File

@@ -2,6 +2,7 @@ use crate::prelude::*;
use std::fs::File; use std::fs::File;
use std::io::BufReader; use std::io::BufReader;
/// Global configuration loaded from file, containing balancing numbers and paths.
#[derive(Resource, Deserialize, Debug)] #[derive(Resource, Deserialize, Debug)]
pub struct GameConfig { pub struct GameConfig {
pub grid_width: u32, pub grid_width: u32,
@@ -10,8 +11,11 @@ pub struct GameConfig {
pub shovel_base_price: u32, pub shovel_base_price: u32,
pub shovel_rate: f32, pub shovel_rate: f32,
pub berry_seeds: Vec<BerrySeedConfig>, pub berry_seeds: Vec<BerrySeedConfig>,
pub wonder_event_url: String,
pub berries_per_focus_minute: u32,
} }
/// Configuration for a specific type of seed.
#[derive(Deserialize, Debug, Clone)] #[derive(Deserialize, Debug, Clone)]
pub struct BerrySeedConfig { pub struct BerrySeedConfig {
pub name: String, pub name: String,
@@ -52,15 +56,19 @@ impl Default for GameConfig {
growth_stages: 6, growth_stages: 6,
}, },
], ],
wonder_event_url: "wss://pomomon.farm/ws".into(),
berries_per_focus_minute: 1,
} }
} }
} }
impl GameConfig { impl GameConfig {
/// Reads `config.json` from assets.
pub fn read_config() -> Option<Self> { pub fn read_config() -> Option<Self> {
Self::read_from_path(std::path::Path::new("assets/config.json")) Self::read_from_path(std::path::Path::new("assets/config.json"))
} }
/// Reads configuration from a specific path.
pub fn read_from_path(path: &std::path::Path) -> Option<Self> { pub fn read_from_path(path: &std::path::Path) -> Option<Self> {
let file = File::open(path).ok()?; let file = File::open(path).ok()?;
let reader = BufReader::new(file); let reader = BufReader::new(file);

View File

@@ -2,6 +2,7 @@ use crate::prelude::*;
pub mod states; pub mod states;
/// Handles core engine setup like camera and initial state.
pub struct CorePlugin; pub struct CorePlugin;
impl Plugin for CorePlugin { impl Plugin for CorePlugin {
@@ -11,6 +12,7 @@ impl Plugin for CorePlugin {
} }
} }
/// Spawns the main 2D camera.
fn setup_camera(mut commands: Commands) { fn setup_camera(mut commands: Commands) {
commands.spawn(Camera2d::default()); commands.spawn(Camera2d::default());
} }

View File

@@ -1,5 +1,6 @@
use crate::prelude::*; use crate::prelude::*;
/// Global states of the application.
#[derive(States, Clone, PartialEq, Eq, Debug, Hash, Default)] #[derive(States, Clone, PartialEq, Eq, Debug, Hash, Default)]
pub enum AppState { pub enum AppState {
#[default] #[default]

View File

@@ -1,5 +1,6 @@
use crate::prelude::*; use crate::prelude::*;
/// Plugin for the main game screen, managing the game loop and environment.
pub struct GameScreenPlugin; pub struct GameScreenPlugin;
impl Plugin for GameScreenPlugin { impl Plugin for GameScreenPlugin {
@@ -9,10 +10,12 @@ impl Plugin for GameScreenPlugin {
} }
} }
/// Sets up the game screen environment (e.g., background color).
fn setup(mut clear_color: ResMut<ClearColor>) { fn setup(mut clear_color: ResMut<ClearColor>) {
*clear_color = ClearColor(Color::srgb(0.294, 0.412, 0.184)); *clear_color = ClearColor(Color::srgb(0.294, 0.412, 0.184));
} }
/// Cleans up resources when exiting the game screen.
fn cleanup(mut clear_color: ResMut<ClearColor>) { fn cleanup(mut clear_color: ResMut<ClearColor>) {
*clear_color = ClearColor(Color::srgb(0.2, 0.2, 0.2)); *clear_color = ClearColor(Color::srgb(0.2, 0.2, 0.2));
} }

View File

@@ -1,19 +1,23 @@
use super::errors::GridError; use super::errors::GridError;
use crate::prelude::*; use crate::prelude::*;
/// Component representing a single tile on the grid.
#[derive(Component)] #[derive(Component)]
pub struct Tile { pub struct Tile {
pub x: u32, pub x: u32,
pub y: u32, pub y: u32,
} }
/// Visual marker component for the crop on a tile.
#[derive(Component)] #[derive(Component)]
pub struct CropVisual; pub struct CropVisual;
/// Visual marker component for the water on a tile.
#[derive(Component)] #[derive(Component)]
pub struct WaterVisual; pub struct WaterVisual;
#[derive(Component, Default, Serialize, Deserialize, Clone, Debug)] /// The logical state of a tile.
#[derive(Component, Default, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub enum TileState { pub enum TileState {
#[default] #[default]
Unclaimed, Unclaimed,
@@ -38,6 +42,7 @@ impl TileState {
} }
} }
/// Resource containing grid dimensions and tile entities.
#[derive(Resource)] #[derive(Resource)]
pub struct Grid { pub struct Grid {
pub width: u32, pub width: u32,
@@ -46,6 +51,7 @@ pub struct Grid {
} }
impl Grid { impl Grid {
/// Returns the entity of the tile at the given position.
pub fn get_tile(&self, pos: (u32, u32)) -> Result<Entity, GridError> { pub fn get_tile(&self, pos: (u32, u32)) -> Result<Entity, GridError> {
if pos.0 >= self.width || pos.1 >= self.height { if pos.0 >= self.width || pos.1 >= self.height {
return Err(GridError::OutOfBounds { return Err(GridError::OutOfBounds {
@@ -56,6 +62,7 @@ impl Grid {
Ok(self.tiles[pos.0 as usize][pos.1 as usize]) Ok(self.tiles[pos.0 as usize][pos.1 as usize])
} }
/// Modifies the state of a tile using a mapping function.
pub fn map_tile_state<F>( pub fn map_tile_state<F>(
&self, &self,
pos: (u32, u32), pos: (u32, u32),
@@ -74,4 +81,19 @@ impl Grid {
*tile_state = mapper(&*tile_state); *tile_state = mapper(&*tile_state);
Ok(()) Ok(())
} }
/// Counts the number of tiles that are not unclaimed.
pub fn count_claimed_tiles(&self, tile_query: &Query<&TileState>) -> u32 {
self.tiles
.iter()
.flatten()
.filter(|&entity| {
if let Ok(state) = tile_query.get(*entity) {
!matches!(state, TileState::Unclaimed)
} else {
false
}
})
.count() as u32
}
} }

View File

@@ -1 +1,2 @@
/// The pixel size of a tile (width and height).
pub const TILE_SIZE: f32 = 32.0; pub const TILE_SIZE: f32 = 32.0;

View File

@@ -1,5 +1,6 @@
use std::{error::Error, fmt}; use std::{error::Error, fmt};
/// Errors related to grid operations.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum GridError { pub enum GridError {
OutOfBounds { x: i32, y: i32 }, OutOfBounds { x: i32, y: i32 },

View File

@@ -1,11 +1,13 @@
use crate::prelude::*; use crate::prelude::*;
use components::{CropVisual, WaterVisual}; use components::{CropVisual, WaterVisual};
use std::collections::HashSet;
pub mod components; pub mod components;
pub mod consts; pub mod consts;
pub mod errors; pub mod errors;
pub mod utils; pub mod utils;
/// Manages the game grid, including tiles, visuals, and updates.
pub struct GridPlugin; pub struct GridPlugin;
impl Plugin for GridPlugin { impl Plugin for GridPlugin {
@@ -17,6 +19,7 @@ impl Plugin for GridPlugin {
} }
} }
/// Initializes the grid and spawns tile entities.
fn setup(mut commands: Commands, asset_server: Res<AssetServer>, config: Res<GameConfig>) { fn setup(mut commands: Commands, asset_server: Res<AssetServer>, config: Res<GameConfig>) {
let grid_width = config.grid_width; let grid_width = config.grid_width;
let grid_height = config.grid_height; let grid_height = config.grid_height;
@@ -26,13 +29,28 @@ fn setup(mut commands: Commands, asset_server: Res<AssetServer>, config: Res<Gam
let mut column = Vec::with_capacity(grid_height as usize); let mut column = Vec::with_capacity(grid_height as usize);
for y in 0..grid_height { for y in 0..grid_height {
let initial_state = if x == 1 && y == 1 {
TileState::Empty
} else {
TileState::Unclaimed
};
let tile_entity = commands let tile_entity = commands
.spawn(( .spawn((
Tile { x, y }, Tile { x, y },
TileState::Unclaimed, initial_state.clone(),
AseSlice { AseSlice {
name: "Unclaimed".into(), name: match initial_state {
aseprite: asset_server.load("tiles/tile-unclaimed.aseprite"), TileState::Unclaimed => "Unclaimed",
TileState::Empty => "Empty",
_ => unreachable!(),
}
.into(),
aseprite: asset_server.load(match initial_state {
TileState::Unclaimed => "tiles/tile-unclaimed.aseprite",
TileState::Empty => "tiles/tile-empty.aseprite",
_ => unreachable!(),
}),
}, },
Sprite::default(), Sprite::default(),
Transform::from_translation(grid_to_world_coords( Transform::from_translation(grid_to_world_coords(
@@ -80,6 +98,7 @@ fn setup(mut commands: Commands, asset_server: Res<AssetServer>, config: Res<Gam
}); });
} }
/// Despawns all grid entities and removes resources.
fn cleanup(mut commands: Commands, tile_query: Query<Entity, With<Tile>>) { fn cleanup(mut commands: Commands, tile_query: Query<Entity, With<Tile>>) {
for tile_entity in tile_query.iter() { for tile_entity in tile_query.iter() {
commands.entity(tile_entity).despawn(); commands.entity(tile_entity).despawn();
@@ -87,8 +106,12 @@ fn cleanup(mut commands: Commands, tile_query: Query<Entity, With<Tile>>) {
commands.remove_resource::<Grid>(); commands.remove_resource::<Grid>();
} }
/// Updates tile visuals based on their state (e.g., crop growth, highlighting).
fn update_tiles( fn update_tiles(
mut query: Query<(&TileState, &mut AseSlice, &Children), (With<Tile>, Without<CropVisual>)>, mut query: Query<
(&TileState, &mut AseSlice, &Children, &Tile),
(With<Tile>, Without<CropVisual>),
>,
mut crop_query: Query< mut crop_query: Query<
(&mut Visibility, &mut Transform, &mut AseSlice), (&mut Visibility, &mut Transform, &mut AseSlice),
(With<CropVisual>, Without<WaterVisual>, Without<Tile>), (With<CropVisual>, Without<WaterVisual>, Without<Tile>),
@@ -99,8 +122,27 @@ fn update_tiles(
>, >,
asset_server: Res<AssetServer>, asset_server: Res<AssetServer>,
game_config: Res<GameConfig>, game_config: Res<GameConfig>,
inventory: Res<Inventory>,
item_stacks: Query<&ItemStack>,
grid: Res<Grid>,
mut sprite_query: Query<&mut Sprite, With<Tile>>,
) { ) {
for (state, mut slice, children) in &mut query { let has_shovel = inventory.has_item_type(&item_stacks, ItemType::Shovel);
let owned_tiles: HashSet<(u32, u32)> = query
.iter()
.filter_map(|(state, _, _, tile)| {
if !matches!(state, TileState::Unclaimed) {
Some((tile.x, tile.y))
} else {
None
}
})
.collect();
for (state, mut slice, children, tile) in &mut query {
let entity = grid.get_tile((tile.x, tile.y)).unwrap(); // Get entity for sprite query
slice.name = match state { slice.name = match state {
TileState::Unclaimed => "Unclaimed", TileState::Unclaimed => "Unclaimed",
TileState::Empty => "Empty", TileState::Empty => "Empty",
@@ -133,6 +175,34 @@ fn update_tiles(
_ => Vec3::ONE, _ => Vec3::ONE,
}; };
let mut is_highlighted = false;
if has_shovel && matches!(state, TileState::Unclaimed) {
// Check if not on edge
if tile.x > 0 && tile.x < grid.width - 1 && tile.y > 0 && tile.y < grid.height - 1 {
// Check neighbors
let neighbors = [
(tile.x + 1, tile.y),
(tile.x.saturating_sub(1), tile.y),
(tile.x, tile.y + 1),
(tile.x, tile.y.saturating_sub(1)),
];
for n in neighbors.iter() {
if owned_tiles.contains(n) {
is_highlighted = true;
break;
}
}
}
}
if let Ok(mut sprite) = sprite_query.get_mut(entity) {
if is_highlighted {
sprite.color = Color::srgb(0.3, 1.0, 0.3); // Green tint
} else {
sprite.color = Color::WHITE;
}
}
for child in children.iter() { for child in children.iter() {
if let Ok((mut visibility, mut transform, mut sprite)) = crop_query.get_mut(child) { if let Ok((mut visibility, mut transform, mut sprite)) = crop_query.get_mut(child) {
*visibility = match state { *visibility = match state {

View File

@@ -1,14 +1,17 @@
use super::errors::GridError; use super::errors::GridError;
use crate::prelude::*; use crate::prelude::*;
/// Calculates the starting X coordinate for centering the grid.
pub fn grid_start_x(grid_width: u32) -> f32 { pub fn grid_start_x(grid_width: u32) -> f32 {
-(grid_width as f32 * TILE_SIZE) / 2.0 + TILE_SIZE / 2.0 -(grid_width as f32 * TILE_SIZE) / 2.0 + TILE_SIZE / 2.0
} }
/// Calculates the starting Y coordinate for centering the grid.
pub fn grid_start_y(grid_height: u32) -> f32 { pub fn grid_start_y(grid_height: u32) -> f32 {
-(grid_height as f32 * TILE_SIZE) / 2.0 + TILE_SIZE / 2.0 -(grid_height as f32 * TILE_SIZE) / 2.0 + TILE_SIZE / 2.0
} }
/// Converts world coordinates to grid coordinates.
pub fn world_to_grid_coords( pub fn world_to_grid_coords(
world_pos: Vec3, world_pos: Vec3,
grid_width: u32, grid_width: u32,
@@ -30,6 +33,7 @@ pub fn world_to_grid_coords(
Ok((x as u32, y as u32)) Ok((x as u32, y as u32))
} }
/// Converts grid coordinates to world coordinates.
pub fn grid_to_world_coords( pub fn grid_to_world_coords(
grid_x: u32, grid_x: u32,
grid_y: u32, grid_y: u32,

View File

@@ -1,29 +1,34 @@
use crate::{features::phase::components::TimerSettings, prelude::*}; use crate::{features::phase::components::TimerSettings, prelude::*};
/// Markers for root UI nodes.
#[derive(Component)] #[derive(Component)]
pub enum RootMarker { pub enum RootMarker {
Status, Status,
Settings, Settings,
ShovelOverlay,
} }
/// Markers for text components in the HUD.
#[derive(Component)] #[derive(Component)]
pub enum TextType { pub enum TextType {
Phase, Phase,
Timer, Timer,
} }
/// Markers for buttons in the HUD and settings.
#[derive(Component)] #[derive(Component)]
pub enum ButtonType { pub enum ButtonType {
SettingsOpen, SettingsOpen,
SettingsClose,
SettingsExit, SettingsExit,
SettingsSave, SettingsSave,
SettingsAchievements,
SettingsTimerChange { SettingsTimerChange {
input: SettingsTimerInput, input: SettingsTimerInput,
amount: i32, amount: i32,
}, },
} }
/// Types of timers available in the game.
#[derive(Clone)] #[derive(Clone)]
pub enum TimerType { pub enum TimerType {
Focus, Focus,
@@ -32,6 +37,7 @@ pub enum TimerType {
} }
impl TimerSettings { impl TimerSettings {
/// Changes the duration of a specific timer.
pub fn change(&mut self, timer_type: &TimerType, amount: i32) { pub fn change(&mut self, timer_type: &TimerType, amount: i32) {
match timer_type { match timer_type {
TimerType::Focus => { TimerType::Focus => {
@@ -59,6 +65,7 @@ impl TimerSettings {
} }
} }
/// Input types for adjusting timer settings.
#[derive(Component, Clone)] #[derive(Component, Clone)]
pub enum SettingsTimerInput { pub enum SettingsTimerInput {
Minutes(TimerType), Minutes(TimerType),

View File

@@ -1,3 +1,4 @@
use crate::features::achievement::{components::AchievementProgress, ui::open_achievements_menu};
use crate::features::phase::components::TimerSettings; use crate::features::phase::components::TimerSettings;
use crate::features::savegame::messages::SavegameDumpMessage; use crate::features::savegame::messages::SavegameDumpMessage;
use crate::features::{inventory, shop}; use crate::features::{inventory, shop};
@@ -8,6 +9,7 @@ use ui::*;
pub mod components; pub mod components;
pub mod ui; pub mod ui;
/// Plugin for the Head-Up Display (HUD) containing status bars and buttons.
pub struct HudPlugin; pub struct HudPlugin;
impl Plugin for HudPlugin { impl Plugin for HudPlugin {
@@ -16,12 +18,19 @@ impl Plugin for HudPlugin {
app.add_systems(OnExit(AppState::GameScreen), cleanup); app.add_systems(OnExit(AppState::GameScreen), cleanup);
app.add_systems( app.add_systems(
Update, Update,
(update_status, buttons, update_timer_settings).run_if(in_state(AppState::GameScreen)), (
update_status,
buttons,
update_timer_settings,
update_shovel_overlay_visibility,
)
.run_if(in_state(AppState::GameScreen)),
); );
} }
} }
fn setup(mut commands: Commands) { /// Initializes the HUD UI.
fn setup(mut commands: Commands, game_config: Res<GameConfig>, asset_server: Res<AssetServer>) {
commands.spawn(( commands.spawn((
RootMarker::Status, RootMarker::Status,
Node { Node {
@@ -44,34 +53,83 @@ fn setup(mut commands: Commands) {
button( button(
shop::components::ButtonType::ShopOpen, shop::components::ButtonType::ShopOpen,
ButtonVariant::Secondary, ButtonVariant::Secondary,
Node { Node::from_padding(UiRect::all(px(10))),
padding: UiRect::all(px(10)),
..default()
},
|color| text("Shop [P]", 16.0, color) |color| text("Shop [P]", 16.0, color)
), ),
button( button(
inventory::components::ButtonType::InventoryOpen, inventory::components::ButtonType::InventoryOpen,
ButtonVariant::Secondary, ButtonVariant::Secondary,
Node { Node::from_padding(UiRect::all(px(10))),
padding: UiRect::all(px(10)), |color| text("Inventar [I]", 16.0, color)
..default()
},
|color| text("Inventar", 16.0, color)
), ),
button( button(
ButtonType::SettingsOpen, ButtonType::SettingsOpen,
ButtonVariant::Secondary, ButtonVariant::Secondary,
Node { Node::from_padding(UiRect::all(px(10))),
padding: UiRect::all(px(10)), |color| text("Einstellungen [Esc]", 16.0, color)
..default()
},
|color| text("Einstellungen", 16.0, color)
) )
], ],
)); ));
// Shovel Overlay
commands.spawn((
RootMarker::ShovelOverlay,
Node {
position_type: PositionType::Absolute,
top: px(20),
left: px(0),
right: px(0),
width: percent(100),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
flex_direction: FlexDirection::Column,
row_gap: px(5),
..default()
},
Visibility::Hidden,
children![(
Node {
flex_direction: FlexDirection::Column,
align_items: AlignItems::Center,
padding: UiRect::all(px(10)),
row_gap: px(5),
..default()
},
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.7)),
BorderRadius::all(px(10)),
children![
(
Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: px(10),
..default()
},
children![
(
Node {
width: px(32),
height: px(32),
..default()
},
inventory::components::ItemType::Shovel
.get_sprite(&asset_server, &game_config),
ImageNode::default()
),
text("Schaufel-Modus", 20.0, Color::WHITE)
]
),
text(
"Klicke auf ein freies Feld, um es freizuschalten.",
14.0,
Color::WHITE
)
]
)],
));
} }
/// Updates the status text (phase and timer).
fn update_status(phase_res: Res<CurrentPhase>, mut text_query: Query<(&mut Text, &TextType)>) { fn update_status(phase_res: Res<CurrentPhase>, mut text_query: Query<(&mut Text, &TextType)>) {
if !phase_res.is_changed() { if !phase_res.is_changed() {
return; return;
@@ -86,28 +144,29 @@ fn update_status(phase_res: Res<CurrentPhase>, mut text_query: Query<(&mut Text,
} }
} }
/// Handles HUD button interactions.
fn buttons( fn buttons(
mut commands: Commands, mut commands: Commands,
mut interaction_query: Query<(&Interaction, &ButtonType), (Changed<Interaction>, With<Button>)>, mut interaction_query: Query<(&Interaction, &ButtonType), (Changed<Interaction>, With<Button>)>,
root_query: Query<(Entity, &RootMarker)>,
mut savegame_messages: MessageWriter<SavegameDumpMessage>, mut savegame_messages: MessageWriter<SavegameDumpMessage>,
mut next_state: ResMut<NextState<AppState>>, mut next_state: ResMut<NextState<AppState>>,
mut timer_settings: ResMut<TimerSettings>, mut timer_settings: ResMut<TimerSettings>,
keys: Res<ButtonInput<KeyCode>>,
achievement_progress: Res<AchievementProgress>,
root_query: Query<(Entity, &RootMarker)>,
) { ) {
let shift_multiplier = if keys.any_pressed([KeyCode::ShiftLeft, KeyCode::ShiftRight]) {
10
} else {
1
};
for (interaction, button_type) in &mut interaction_query { for (interaction, button_type) in &mut interaction_query {
match *interaction { match *interaction {
Interaction::Pressed => match button_type { Interaction::Pressed => match button_type {
ButtonType::SettingsOpen => { ButtonType::SettingsOpen => {
open_settings(&mut commands); open_settings(&mut commands);
} }
ButtonType::SettingsClose => {
for (entity, root) in root_query.iter() {
match *root {
RootMarker::Settings => commands.entity(entity).despawn(),
_ => {}
}
}
}
ButtonType::SettingsExit => { ButtonType::SettingsExit => {
savegame_messages.write(SavegameDumpMessage); savegame_messages.write(SavegameDumpMessage);
next_state.set(AppState::StartScreen); next_state.set(AppState::StartScreen);
@@ -115,12 +174,20 @@ fn buttons(
ButtonType::SettingsSave => { ButtonType::SettingsSave => {
savegame_messages.write(SavegameDumpMessage); savegame_messages.write(SavegameDumpMessage);
} }
ButtonType::SettingsAchievements => {
open_achievements_menu(&mut commands, &achievement_progress);
for (entity, root) in root_query.iter() {
if let RootMarker::Settings = root {
commands.entity(entity).despawn();
}
}
}
ButtonType::SettingsTimerChange { input, amount } => match input { ButtonType::SettingsTimerChange { input, amount } => match input {
SettingsTimerInput::Minutes(timer_type) => { SettingsTimerInput::Minutes(timer_type) => {
timer_settings.change(timer_type, 60 * amount) timer_settings.change(timer_type, 60 * amount * shift_multiplier)
} }
SettingsTimerInput::Seconds(timer_type) => { SettingsTimerInput::Seconds(timer_type) => {
timer_settings.change(timer_type, *amount) timer_settings.change(timer_type, *amount * shift_multiplier)
} }
}, },
}, },
@@ -129,12 +196,14 @@ fn buttons(
} }
} }
/// Cleans up HUD resources.
fn cleanup(mut commands: Commands, query: Query<Entity, With<RootMarker>>) { fn cleanup(mut commands: Commands, query: Query<Entity, With<RootMarker>>) {
for entity in query.iter() { for entity in query.iter() {
commands.entity(entity).despawn(); commands.entity(entity).despawn();
} }
} }
/// Updates the timer settings display in the settings menu.
fn update_timer_settings( fn update_timer_settings(
timer_settings: ResMut<TimerSettings>, timer_settings: ResMut<TimerSettings>,
mut query: Query<(&SettingsTimerInput, &mut Text)>, mut query: Query<(&SettingsTimerInput, &mut Text)>,
@@ -162,3 +231,22 @@ fn update_timer_settings(
} }
} }
} }
/// Updates the visibility of the shovel overlay based on inventory.
fn update_shovel_overlay_visibility(
inventory: Res<inventory::components::Inventory>,
item_stacks: Query<&inventory::components::ItemStack>,
mut overlay_query: Query<(&RootMarker, &mut Visibility)>,
) {
let has_shovel = inventory.has_item_type(&item_stacks, inventory::components::ItemType::Shovel);
for (marker, mut vis) in overlay_query.iter_mut() {
if let RootMarker::ShovelOverlay = marker {
*vis = if has_shovel {
Visibility::Inherited
} else {
Visibility::Hidden
};
}
}
}

View File

@@ -2,76 +2,38 @@ use super::super::components::*;
use super::timer_settings::timer_settings; use super::timer_settings::timer_settings;
use crate::prelude::*; use crate::prelude::*;
/// Spawns the settings popup.
pub fn open_settings(commands: &mut Commands) { pub fn open_settings(commands: &mut Commands) {
commands spawn_popup(
.spawn(( commands,
RootMarker::Settings, RootMarker::Settings,
Node { "Spiel Einstellungen",
position_type: PositionType::Absolute,
width: percent(100),
height: percent(100),
..Node::center()
},
ZIndex(1),
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.8)),
GlobalTransform::default(),
))
.with_children(|parent| {
parent
.spawn((
Node { Node {
width: px(700), width: px(700),
padding: UiRect::all(px(20.0)),
..Node::vstack(px(20))
},
BackgroundColor(Color::srgb(0.2, 0.2, 0.2)),
BorderRadius::all(px(10.0)),
))
.with_children(|parent| {
parent.spawn((
Node {
justify_content: JustifyContent::SpaceBetween,
..Node::hstack(px(20))
},
children![
text("Spiel Einstellungen", 40.0, Color::WHITE),
pill_button(
ButtonType::SettingsClose,
ButtonVariant::Destructive,
Node {
width: px(40),
height: px(40),
..default() ..default()
}, },
|color| text("X", 24.0, color) |parent| {
), parent.spawn((
], Node::vstack(px(10)),
)); children![
button(
parent
.spawn(Node::vstack(px(10)))
.with_children(|parent| {
parent.spawn(button(
ButtonType::SettingsExit, ButtonType::SettingsExit,
ButtonVariant::Secondary, ButtonVariant::Secondary,
Node { Node::from_padding(UiRect::all(px(10))),
padding: UiRect::all(px(10)),
..default()
},
|color| text("Spiel verlassen", 24.0, color) |color| text("Spiel verlassen", 24.0, color)
)); ),
button(
parent.spawn(button(
ButtonType::SettingsSave, ButtonType::SettingsSave,
ButtonVariant::Secondary, ButtonVariant::Secondary,
Node { Node::from_padding(UiRect::all(px(10))),
padding: UiRect::all(px(10)),
..default()
},
|color| text("Spiel speichern", 24.0, color) |color| text("Spiel speichern", 24.0, color)
)); ),
button(
parent.spawn(( ButtonType::SettingsAchievements,
ButtonVariant::Secondary,
Node::from_padding(UiRect::all(px(10))),
|color| text("Erfolge", 24.0, color)
),(
Node { Node {
justify_content: JustifyContent::Center, justify_content: JustifyContent::Center,
..Node::hstack(px(30)) ..Node::hstack(px(30))
@@ -122,8 +84,9 @@ pub fn open_settings(commands: &mut Commands) {
] ]
) )
], ],
)
],
)); ));
}); },
}); );
});
} }

View File

@@ -1,6 +1,7 @@
use super::super::components::*; use super::super::components::*;
use crate::prelude::*; use crate::prelude::*;
/// Creates a UI bundle for a specific timer setting.
pub fn timer_settings(timer_type: TimerType) -> impl Bundle { pub fn timer_settings(timer_type: TimerType) -> impl Bundle {
( (
Node { Node {

View File

@@ -1,8 +1,11 @@
use crate::features::{ use crate::features::{
hud::ui::settings::open_settings,
input::utils::mouse_to_grid, input::utils::mouse_to_grid,
inventory::{components::ItemStack, ui::open_inventory},
phase::messages::{NextPhaseMessage, PhaseTimerPauseMessage}, phase::messages::{NextPhaseMessage, PhaseTimerPauseMessage},
pom::messages::InvalidMoveMessage, pom::messages::InvalidMoveMessage,
shop::ui::open_shop, shop::ui::open_shop,
ui::{messages::ClosePopupMessage, ui::popups::PopupRoot},
}; };
use crate::prelude::*; use crate::prelude::*;
use bevy::input::mouse::MouseButton; use bevy::input::mouse::MouseButton;
@@ -10,6 +13,7 @@ use bevy::window::PrimaryWindow;
pub mod utils; pub mod utils;
/// Handles user input for the game.
pub struct InputPlugin; pub struct InputPlugin;
impl Plugin for InputPlugin { impl Plugin for InputPlugin {
@@ -38,9 +42,17 @@ impl Plugin for InputPlugin {
app.add_systems(Update, next_phase.run_if(in_state(AppState::GameScreen))); app.add_systems(Update, next_phase.run_if(in_state(AppState::GameScreen)));
app.add_systems(Update, shop_keybind.run_if(in_state(AppState::GameScreen))); app.add_systems(Update, shop_keybind.run_if(in_state(AppState::GameScreen)));
app.add_systems(
Update,
inventory_keybind.run_if(in_state(AppState::GameScreen)),
);
app.add_message::<ClosePopupMessage>();
app.add_systems(Update, popup_keybind);
} }
} }
/// Handles right-click movement input.
fn move_click( fn move_click(
mut move_messages: MessageWriter<MoveMessage>, mut move_messages: MessageWriter<MoveMessage>,
mouse_btn: Res<ButtonInput<MouseButton>>, mouse_btn: Res<ButtonInput<MouseButton>>,
@@ -49,7 +61,13 @@ fn move_click(
config: Res<GameConfig>, config: Res<GameConfig>,
phase: Res<CurrentPhase>, phase: Res<CurrentPhase>,
ui_query: Query<(&ComputedNode, &GlobalTransform), With<Node>>, ui_query: Query<(&ComputedNode, &GlobalTransform), With<Node>>,
inventory: Res<Inventory>,
item_stacks: Query<&ItemStack>,
) { ) {
if inventory.has_item_type(&item_stacks, ItemType::Shovel) {
return; // Block movement if player has a shovel
}
match phase.0 { match phase.0 {
Phase::Focus { .. } => return, Phase::Focus { .. } => return,
_ => {} _ => {}
@@ -65,6 +83,7 @@ fn move_click(
} }
} }
/// Handles left-click interactions (tile selection, shovel).
fn interact_click( fn interact_click(
mut tile_click_messages: MessageWriter<TileClickMessage>, mut tile_click_messages: MessageWriter<TileClickMessage>,
mouse_btn: Res<ButtonInput<MouseButton>>, mouse_btn: Res<ButtonInput<MouseButton>>,
@@ -74,12 +93,25 @@ fn interact_click(
config: Res<GameConfig>, config: Res<GameConfig>,
phase: Res<CurrentPhase>, phase: Res<CurrentPhase>,
ui_query: Query<(&ComputedNode, &GlobalTransform), With<Node>>, ui_query: Query<(&ComputedNode, &GlobalTransform), With<Node>>,
mut commands: Commands,
mut inventory: ResMut<Inventory>,
mut item_stacks: Query<&mut ItemStack>,
grid: Res<Grid>,
mut tile_states: Query<&mut TileState>,
) { ) {
match phase.0 { match phase.0 {
Phase::Focus { .. } => return, Phase::Focus { .. } => return,
_ => {} _ => {}
} }
let has_shovel = inventory.items.iter().any(|&entity| {
if let Ok(stack) = item_stacks.get(entity) {
stack.item_type == ItemType::Shovel
} else {
false
}
});
if mouse_btn.just_pressed(MouseButton::Left) { if mouse_btn.just_pressed(MouseButton::Left) {
if keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight) { if keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight) {
return; return;
@@ -88,10 +120,71 @@ fn interact_click(
return; return;
}; };
if has_shovel {
// Shovel interaction logic
let tile_entity = match grid.get_tile((x, y)) {
Ok(entity) => entity,
Err(_) => return, // Clicked outside grid
};
// Before getting mutable tile_state, check neighbors with immutable borrow
let mut has_claimed_neighbor = false;
// Check if not on edge first for early exit to avoid checking neighbors outside grid.
if x == 0 || x == grid.width - 1 || y == 0 || y == grid.height - 1 {
return;
}
let neighbors = [
(x + 1, y),
(x.saturating_sub(1), y),
(x, y + 1),
(x, y.saturating_sub(1)),
];
for (nx, ny) in neighbors.iter() {
// Ensure neighbor coordinates are within grid boundaries before attempting to get tile
if *nx < grid.width && *ny < grid.height {
if let Ok(neighbor_entity) = grid.get_tile((*nx, *ny)) {
if let Ok(neighbor_state) = tile_states.get(neighbor_entity) {
if !matches!(*neighbor_state, TileState::Unclaimed) {
has_claimed_neighbor = true;
break;
}
}
}
}
}
if !has_claimed_neighbor {
return; // No claimed neighbor, cannot unlock
}
// Now get mutable tile_state, after all immutable neighbor checks are done
let mut tile_state = match tile_states.get_mut(tile_entity) {
Ok(state) => state,
Err(_) => return, // Should not happen
};
// Check if unclaimed AFTER determining neighbor status
if !matches!(*tile_state, TileState::Unclaimed) {
return;
}
if has_claimed_neighbor {
// This check is redundant due to early return above, but kept for clarity
// Unlock tile
*tile_state = TileState::Empty;
inventory.update_item_stack(&mut commands, &mut item_stacks, ItemType::Shovel, -1);
}
return; // Consume click event, prevent normal tile_click_messages
} else {
// Normal interaction
tile_click_messages.write(TileClickMessage { x, y }); tile_click_messages.write(TileClickMessage { x, y });
} }
}
} }
/// Handles debug interactions (shift + left click).
#[cfg(debug_assertions)]
fn debug_click( fn debug_click(
mouse_btn: Res<ButtonInput<MouseButton>>, mouse_btn: Res<ButtonInput<MouseButton>>,
keys: Res<ButtonInput<KeyCode>>, keys: Res<ButtonInput<KeyCode>>,
@@ -138,6 +231,7 @@ fn debug_click(
} }
} }
/// Pauses/resumes the phase timer on Space press.
fn phase_timer_pause( fn phase_timer_pause(
mut pause_messages: MessageWriter<PhaseTimerPauseMessage>, mut pause_messages: MessageWriter<PhaseTimerPauseMessage>,
keys: Res<ButtonInput<KeyCode>>, keys: Res<ButtonInput<KeyCode>>,
@@ -147,19 +241,58 @@ fn phase_timer_pause(
} }
} }
/// Skips to the next phase on Enter press.
fn next_phase(mut messages: MessageWriter<NextPhaseMessage>, keys: Res<ButtonInput<KeyCode>>) { fn next_phase(mut messages: MessageWriter<NextPhaseMessage>, keys: Res<ButtonInput<KeyCode>>) {
if keys.just_pressed(KeyCode::Enter) { if keys.just_pressed(KeyCode::Enter) {
messages.write(NextPhaseMessage); messages.write(NextPhaseMessage);
} }
} }
/// Opens the shop on 'P' press.
fn shop_keybind( fn shop_keybind(
keys: Res<ButtonInput<KeyCode>>, keys: Res<ButtonInput<KeyCode>>,
mut commands: Commands, mut commands: Commands,
game_config: Res<GameConfig>, game_config: Res<GameConfig>,
asset_server: Res<AssetServer>, asset_server: Res<AssetServer>,
grid: Res<Grid>,
tile_query: Query<&TileState>,
) { ) {
if keys.just_pressed(KeyCode::KeyP) { if keys.just_pressed(KeyCode::KeyP) {
open_shop(&mut commands, &game_config, &asset_server); open_shop(
&mut commands,
&game_config,
&asset_server,
&grid,
&tile_query,
);
}
}
/// Opens the inventory on 'I' press.
fn inventory_keybind(
keys: Res<ButtonInput<KeyCode>>,
mut commands: Commands,
item_stacks: Query<&ItemStack>,
game_config: Res<GameConfig>,
asset_server: Res<AssetServer>,
) {
if keys.just_pressed(KeyCode::KeyI) {
open_inventory(&mut commands, item_stacks, &game_config, &asset_server);
}
}
/// Closes popups on Escape press or opens settings if no popup is open.
fn popup_keybind(
mut close_popup_messages: MessageWriter<ClosePopupMessage>,
keys: Res<ButtonInput<KeyCode>>,
popup_query: Query<Entity, With<PopupRoot>>,
mut commands: Commands,
) {
if keys.just_pressed(KeyCode::Escape) {
if !popup_query.is_empty() {
close_popup_messages.write(ClosePopupMessage);
} else {
open_settings(&mut commands);
}
} }
} }

View File

@@ -1,6 +1,7 @@
use crate::prelude::*; use crate::prelude::*;
use bevy::window::PrimaryWindow; use bevy::window::PrimaryWindow;
/// Converts mouse position to grid coordinates, respecting UI blocks.
pub fn mouse_to_grid( pub fn mouse_to_grid(
window: Single<&Window, With<PrimaryWindow>>, window: Single<&Window, With<PrimaryWindow>>,
camera: Single<(&Camera, &GlobalTransform), With<Camera2d>>, camera: Single<(&Camera, &GlobalTransform), With<Camera2d>>,

View File

@@ -1,6 +1,7 @@
use crate::features::config::components::BerrySeedConfig; use crate::features::config::components::BerrySeedConfig;
use crate::prelude::*; use crate::prelude::*;
/// Types of items available in the game.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)] #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
pub enum ItemType { pub enum ItemType {
Berry, Berry,
@@ -80,8 +81,8 @@ impl ItemType {
aseprite: asset_server.load("berry.aseprite"), aseprite: asset_server.load("berry.aseprite"),
}, },
ItemType::Shovel => AseSlice { ItemType::Shovel => AseSlice {
name: "Berry".into(), name: "Shovel".into(),
aseprite: asset_server.load("berry.aseprite"), aseprite: asset_server.load("shovel.aseprite"),
}, },
ItemType::BerrySeed { name } => { ItemType::BerrySeed { name } => {
let seed_config = game_config.berry_seeds.iter().find(|s| s.name == *name); let seed_config = game_config.berry_seeds.iter().find(|s| s.name == *name);
@@ -102,26 +103,32 @@ impl ItemType {
} }
} }
#[derive(Component, Serialize, Deserialize, Clone)] /// A stack of items of a specific type.
#[derive(Component, Serialize, Deserialize, Clone, Debug)]
pub struct ItemStack { pub struct ItemStack {
pub item_type: ItemType, pub item_type: ItemType,
pub amount: u32, pub amount: u32,
} }
/// Resource containing all items owned by the player.
#[derive(Resource, Default, Serialize, Deserialize)] #[derive(Resource, Default, Serialize, Deserialize)]
pub struct Inventory { pub struct Inventory {
pub items: Vec<Entity>, pub items: Vec<Entity>,
} }
impl Inventory { impl Inventory {
pub fn has_item(&self, items_query: Query<&ItemStack>) -> bool { /// Checks if the inventory contains a specific item type.
self.items pub fn has_item_type(&self, items_query: &Query<&ItemStack>, item_type: ItemType) -> bool {
.iter() self.items.iter().any(|&entity| {
.map(|entity| items_query.get(*entity).ok()) if let Ok(stack) = items_query.get(entity) {
.find(|option| option.is_some()) stack.item_type == item_type
.is_some() } else {
false
}
})
} }
/// Adds or removes items from the inventory.
pub fn update_item_stack( pub fn update_item_stack(
&mut self, &mut self,
commands: &mut Commands, commands: &mut Commands,
@@ -191,13 +198,14 @@ impl Inventory {
} }
} }
/// Markers for inventory UI root nodes.
#[derive(Component)] #[derive(Component)]
pub enum RootMarker { pub enum RootMarker {
Inventory, Inventory,
} }
/// Markers for inventory-related buttons.
#[derive(Component)] #[derive(Component)]
pub enum ButtonType { pub enum ButtonType {
InventoryOpen, InventoryOpen,
InventoryClose,
} }

View File

@@ -1,9 +1,12 @@
#[cfg(debug_assertions)]
use crate::features::phase::components::SessionTracker;
use crate::{features::inventory::ui::open_inventory, prelude::*}; use crate::{features::inventory::ui::open_inventory, prelude::*};
use components::*; use components::*;
pub mod components; pub mod components;
pub mod ui; pub mod ui;
/// Plugin for the inventory system, including storage and UI.
pub struct InventoryPlugin; pub struct InventoryPlugin;
impl Plugin for InventoryPlugin { impl Plugin for InventoryPlugin {
@@ -17,11 +20,11 @@ impl Plugin for InventoryPlugin {
} }
} }
/// Handles inventory button interactions.
fn buttons( fn buttons(
mut commands: Commands, mut commands: Commands,
mut interaction_query: Query<(&Interaction, &ButtonType), (Changed<Interaction>, With<Button>)>, mut interaction_query: Query<(&Interaction, &ButtonType), (Changed<Interaction>, With<Button>)>,
itemstack_query: Query<&ItemStack>, itemstack_query: Query<&ItemStack>,
root_query: Query<(Entity, &RootMarker)>,
game_config: Res<GameConfig>, game_config: Res<GameConfig>,
asset_server: Res<AssetServer>, asset_server: Res<AssetServer>,
) { ) {
@@ -31,30 +34,27 @@ fn buttons(
ButtonType::InventoryOpen => { ButtonType::InventoryOpen => {
open_inventory(&mut commands, itemstack_query, &game_config, &asset_server); open_inventory(&mut commands, itemstack_query, &game_config, &asset_server);
} }
ButtonType::InventoryClose => {
for (entity, root) in root_query.iter() {
match *root {
RootMarker::Inventory => commands.entity(entity).despawn(),
}
}
}
}, },
_ => {} _ => {}
} }
} }
} }
/// Debug system to add/remove berries with arrow keys.
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
fn debug_modify_berries( fn debug_modify_berries(
mut commands: Commands, mut commands: Commands,
mut inventory: ResMut<Inventory>, mut inventory: ResMut<Inventory>,
mut items: Query<&mut ItemStack>, mut items: Query<&mut ItemStack>,
keys: Res<ButtonInput<KeyCode>>, keys: Res<ButtonInput<KeyCode>>,
mut session_tracker: ResMut<SessionTracker>,
) { ) {
if keys.any_pressed([KeyCode::ShiftLeft, KeyCode::ShiftRight]) { if keys.any_pressed([KeyCode::ShiftLeft, KeyCode::ShiftRight]) {
if keys.just_pressed(KeyCode::ArrowUp) { if keys.just_pressed(KeyCode::ArrowUp) {
println!("Adding 1 berry using debug bind"); println!("Adding 1 berry using debug bind");
inventory.update_item_stack(&mut commands, &mut items, ItemType::Berry, 1); if inventory.update_item_stack(&mut commands, &mut items, ItemType::Berry, 1) {
session_tracker.total_berries_earned += 1;
}
} else if keys.just_pressed(KeyCode::ArrowDown) { } else if keys.just_pressed(KeyCode::ArrowDown) {
println!("Removing 1 berry using debug bind"); println!("Removing 1 berry using debug bind");
inventory.update_item_stack(&mut commands, &mut items, ItemType::Berry, -1); inventory.update_item_stack(&mut commands, &mut items, ItemType::Berry, -1);

View File

@@ -1,59 +1,20 @@
use super::super::components::{ButtonType, RootMarker}; use super::super::components::RootMarker;
use crate::prelude::GameConfig; use crate::prelude::GameConfig;
use crate::{features::inventory::ui::list_itemstack, prelude::*}; use crate::{features::inventory::ui::list_itemstack, prelude::*};
/// Spawns the inventory popup.
pub fn open_inventory( pub fn open_inventory(
commands: &mut Commands, commands: &mut Commands,
items: Query<&ItemStack>, items: Query<&ItemStack>,
game_config: &Res<GameConfig>, game_config: &Res<GameConfig>,
asset_server: &Res<AssetServer>, asset_server: &Res<AssetServer>,
) { ) {
commands spawn_popup(
.spawn(( commands,
RootMarker::Inventory, RootMarker::Inventory,
Node { "Inventar",
position_type: PositionType::Absolute, Node::default(),
width: percent(100), |parent| {
height: percent(100),
..Node::center()
},
ZIndex(1),
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.8)),
GlobalTransform::default(),
))
.with_children(|parent| {
parent
.spawn((
Node {
padding: UiRect::all(px(20.0)),
..Node::vstack(px(0))
},
BackgroundColor(Color::srgb(0.2, 0.2, 0.2)),
BorderRadius::all(px(10.0)),
))
.with_children(|parent| {
parent.spawn((
Node {
width: percent(100.0),
justify_content: JustifyContent::SpaceBetween,
margin: UiRect::bottom(px(20.0)),
..Node::hstack(px(20))
},
children![
text("Inventar", 40.0, Color::WHITE),
pill_button(
ButtonType::InventoryClose,
ButtonVariant::Destructive,
Node {
width: px(40),
height: px(40),
..default()
},
|color| text("X", 24.0, color)
),
],
));
parent parent
.spawn(Node { .spawn(Node {
width: percent(100), width: percent(100),
@@ -61,10 +22,13 @@ pub fn open_inventory(
..Node::vstack(px(10)) ..Node::vstack(px(10))
}) })
.with_children(|parent| { .with_children(|parent| {
for itemstack in items.iter() { items
parent.spawn(list_itemstack(itemstack, game_config, asset_server)); .iter()
} .map(|item| list_itemstack(item, game_config, asset_server))
}); .for_each(|itemstack| {
parent.spawn(itemstack);
}); });
}); });
},
);
} }

View File

@@ -1,5 +1,6 @@
use crate::prelude::*; use crate::prelude::*;
/// Creates a UI bundle for a single item stack in the inventory list.
pub fn list_itemstack( pub fn list_itemstack(
itemstack: &ItemStack, itemstack: &ItemStack,
game_config: &GameConfig, game_config: &GameConfig,

View File

@@ -1,3 +1,4 @@
pub mod achievement;
pub mod config; pub mod config;
pub mod core; pub mod core;
pub mod game_screen; pub mod game_screen;
@@ -5,22 +6,27 @@ pub mod grid;
pub mod hud; pub mod hud;
pub mod input; pub mod input;
pub mod inventory; pub mod inventory;
pub mod notification;
pub mod phase; pub mod phase;
pub mod pom; pub mod pom;
pub mod savegame; pub mod savegame;
pub mod shop; pub mod shop;
pub mod start_screen; pub mod start_screen;
pub mod ui; pub mod ui;
pub mod wonderevent;
pub use achievement::AchievementPlugin;
pub use core::CorePlugin; pub use core::CorePlugin;
pub use game_screen::GameScreenPlugin; pub use game_screen::GameScreenPlugin;
pub use grid::GridPlugin; pub use grid::GridPlugin;
pub use hud::HudPlugin; pub use hud::HudPlugin;
pub use input::InputPlugin; pub use input::InputPlugin;
pub use inventory::InventoryPlugin; pub use inventory::InventoryPlugin;
pub use notification::NotificationPlugin;
pub use phase::PhasePlugin; pub use phase::PhasePlugin;
pub use pom::PomPlugin; pub use pom::PomPlugin;
pub use savegame::SavegamePlugin; pub use savegame::SavegamePlugin;
pub use shop::ShopPlugin; pub use shop::ShopPlugin;
pub use start_screen::StartScreenPlugin; pub use start_screen::StartScreenPlugin;
pub use ui::UiPlugin; pub use ui::UiPlugin;
pub use wonderevent::WonderEventPlugin;

View File

@@ -0,0 +1,79 @@
use crate::prelude::*;
/// Resource managing active notifications.
#[derive(Resource)]
pub struct Notifications {
items: Vec<(Notification, NotificationLevel)>,
}
impl Default for Notifications {
fn default() -> Self {
Self { items: Vec::new() }
}
}
impl Notifications {
fn new(
&mut self,
level: NotificationLevel,
title: Option<impl Into<String>>,
message: impl Into<String>,
) {
self.items.push((
Notification {
title: title.map(|s| s.into()),
message: message.into(),
},
level,
));
}
pub fn drain(&mut self) -> std::vec::Drain<'_, (Notification, NotificationLevel)> {
self.items.drain(..)
}
pub fn info(&mut self, title: Option<impl Into<String>>, message: impl Into<String>) {
self.new(NotificationLevel::Info, title, message);
}
pub fn warn(&mut self, title: Option<impl Into<String>>, message: impl Into<String>) {
self.new(NotificationLevel::Warning, title, message);
}
pub fn error(&mut self, title: Option<impl Into<String>>, message: impl Into<String>) {
self.new(NotificationLevel::Error, title, message);
}
}
/// Marker for the UI node containing notifications.
#[derive(Component)]
pub struct NotificationContainer;
/// Component tracking the lifetime of a displayed notification.
#[derive(Component)]
pub struct NotificationLifetime(pub Timer);
/// Data for a single notification.
#[derive(Component)]
pub struct Notification {
pub title: Option<String>,
pub message: String,
}
/// Severity level of a notification.
#[derive(Component)]
pub enum NotificationLevel {
Info,
Warning,
Error,
}
impl NotificationLevel {
pub fn bg_color(&self) -> Color {
match self {
NotificationLevel::Info => Color::srgba(0.0, 0.0, 0.0, 0.7),
NotificationLevel::Warning => Color::srgba(1.0, 1.0, 0.0, 0.7),
NotificationLevel::Error => Color::srgba(1.0, 0.0, 0.0, 0.7),
}
}
}

View File

@@ -0,0 +1,59 @@
use crate::{
features::notification::ui::{notification_container, spawn_notification},
prelude::*,
};
use components::{NotificationContainer, NotificationLifetime, Notifications};
pub mod components;
pub mod ui;
/// Plugin for the notification system.
pub struct NotificationPlugin;
impl Plugin for NotificationPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<Notifications>()
.add_systems(Startup, setup)
.add_systems(Update, handle_notification);
}
}
/// Spawns the notification container up.
fn setup(mut commands: Commands) {
commands.spawn(notification_container());
}
/// Spawns/Despawns UI elements for each item in the `Notifications` Resource.
fn handle_notification(
mut commands: Commands,
mut notifications: ResMut<Notifications>,
container_q: Query<Entity, With<NotificationContainer>>,
mut notification_entities: Query<(Entity, &mut NotificationLifetime)>,
time: Res<Time>,
) {
if let Some(container) = container_q.iter().next() {
let mut spawned_entities = Vec::new();
for (content, level) in notifications.drain() {
let entity = spawn_notification(&mut commands, content, level);
commands.entity(container).add_child(entity);
spawned_entities.push(entity);
}
for entity in spawned_entities {
commands
.entity(entity)
.insert(NotificationLifetime(Timer::from_seconds(
5.0,
TimerMode::Once,
)));
}
}
for (entity, mut timer) in notification_entities.iter_mut() {
timer.0.tick(time.delta());
if timer.0.is_finished() {
commands.entity(entity).despawn();
}
}
}

View File

@@ -0,0 +1,62 @@
use super::components::*;
use crate::prelude::*;
/// Creates the notification container UI bundle.
pub fn notification_container() -> impl Bundle {
(
NotificationContainer,
Node {
position_type: PositionType::Absolute,
top: px(0),
left: px(0),
padding: UiRect::all(px(5)),
flex_direction: FlexDirection::Column,
row_gap: px(5),
..default()
},
)
}
/// Spawns a single notification UI element.
pub fn spawn_notification(
commands: &mut Commands,
content: Notification,
level: NotificationLevel,
) -> Entity {
let entity = commands
.spawn((
Node {
flex_direction: FlexDirection::Column,
row_gap: px(5),
padding: UiRect::all(px(10)),
margin: UiRect::all(px(2)),
width: px(400),
..default()
},
BackgroundColor(level.bg_color()),
BorderRadius::all(px(4)),
))
.with_children(|p| {
if let Some(title) = content.title {
p.spawn((
Text::new(format!("{}\n", title)),
TextFont {
font_size: 20.0,
..default()
},
TextColor(Color::WHITE),
));
}
p.spawn((
Text::new(content.message),
TextFont {
font_size: 16.0,
..default()
},
TextColor(Color::WHITE),
));
})
.id();
entity
}

View File

@@ -1,6 +1,7 @@
use super::utils::format_time; use super::utils::format_time;
use crate::prelude::*; use crate::prelude::*;
/// Represents the different states of the Pomodoro timer.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum Phase { pub enum Phase {
Break { duration: f32 }, Break { duration: f32 },
@@ -37,9 +38,11 @@ impl Phase {
} }
} }
/// Resource holding the current phase state.
#[derive(Resource, Debug, Serialize, Deserialize, Clone)] #[derive(Resource, Debug, Serialize, Deserialize, Clone)]
pub struct CurrentPhase(pub Phase); pub struct CurrentPhase(pub Phase);
/// Configuration for phase durations.
#[derive(Resource, Debug, Serialize, Deserialize, Clone)] #[derive(Resource, Debug, Serialize, Deserialize, Clone)]
pub struct TimerSettings { pub struct TimerSettings {
pub focus_duration: u32, pub focus_duration: u32,
@@ -59,7 +62,10 @@ impl Default for TimerSettings {
} }
} }
/// Tracks statistics for the current session.
#[derive(Resource, Debug, Default, Serialize, Deserialize, Clone)] #[derive(Resource, Debug, Default, Serialize, Deserialize, Clone)]
pub struct SessionTracker { pub struct SessionTracker {
pub completed_focus_phases: u32, pub completed_focus_phases: u32,
pub total_berries_earned: u32,
pub total_plants_withered: u32,
} }

View File

@@ -1,12 +1,15 @@
use crate::prelude::*; use crate::prelude::*;
/// Message sent when a phase timer reaches zero.
#[derive(Message)] #[derive(Message)]
pub struct PhaseTimerFinishedMessage { pub struct PhaseTimerFinishedMessage {
pub phase: Phase, pub phase: Phase,
} }
/// Message to toggle pause state.
#[derive(Message)] #[derive(Message)]
pub struct PhaseTimerPauseMessage; pub struct PhaseTimerPauseMessage;
/// Message to proceed to the next phase.
#[derive(Message)] #[derive(Message)]
pub struct NextPhaseMessage; pub struct NextPhaseMessage;

View File

@@ -7,6 +7,7 @@ pub mod components;
pub mod messages; pub mod messages;
pub mod utils; pub mod utils;
/// Plugin managing the Pomodoro phase timer and state.
pub struct PhasePlugin; pub struct PhasePlugin;
impl Plugin for PhasePlugin { impl Plugin for PhasePlugin {
@@ -22,7 +23,13 @@ impl Plugin for PhasePlugin {
app.add_systems(OnEnter(AppState::GameScreen), load_rules); app.add_systems(OnEnter(AppState::GameScreen), load_rules);
app.add_systems( app.add_systems(
Update, Update,
(tick_timer, handle_pause, handle_continue).run_if(in_state(AppState::GameScreen)), (
tick_timer,
handle_pause,
handle_continue,
grant_focus_rewards,
)
.run_if(in_state(AppState::GameScreen)),
); );
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
@@ -30,6 +37,7 @@ impl Plugin for PhasePlugin {
} }
} }
/// Debug system to shorten phase duration for testing.
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
fn debug_short_phase_duration( fn debug_short_phase_duration(
mut phase_res: ResMut<CurrentPhase>, mut phase_res: ResMut<CurrentPhase>,
@@ -49,6 +57,7 @@ fn debug_short_phase_duration(
} }
} }
/// Updates the current phase duration from settings.
fn load_rules(mut phase_res: ResMut<CurrentPhase>, settings: Res<TimerSettings>) { fn load_rules(mut phase_res: ResMut<CurrentPhase>, settings: Res<TimerSettings>) {
let phase = &mut phase_res.0; let phase = &mut phase_res.0;
@@ -65,7 +74,10 @@ fn load_rules(mut phase_res: ResMut<CurrentPhase>, settings: Res<TimerSettings>)
} }
} }
/// Ticks the phase timer and handles completion.
fn tick_timer( fn tick_timer(
mut commands: Commands,
asset_server: Res<AssetServer>,
time: Res<Time>, time: Res<Time>,
mut phase_res: ResMut<CurrentPhase>, mut phase_res: ResMut<CurrentPhase>,
mut finish_writer: MessageWriter<PhaseTimerFinishedMessage>, mut finish_writer: MessageWriter<PhaseTimerFinishedMessage>,
@@ -87,7 +99,11 @@ fn tick_timer(
completed_phase: Box::new(completed), completed_phase: Box::new(completed),
}; };
println!("phase ended"); println!("Phase ended");
commands.spawn((
AudioPlayer::new(asset_server.load("sounds/beep.mp3")),
PlaybackSettings::DESPAWN,
));
savegame_messages.write(SavegameDumpMessage); savegame_messages.write(SavegameDumpMessage);
} }
} }
@@ -95,6 +111,48 @@ fn tick_timer(
} }
} }
/// Rewards the player at the end of a focus phase with `berries_per_focus_minute` * `focus_duration`.
fn grant_focus_rewards(
mut messages: MessageReader<PhaseTimerFinishedMessage>,
config: Res<GameConfig>,
mut inventory: ResMut<Inventory>,
mut commands: Commands,
mut items_query: Query<&mut ItemStack>,
mut session_tracker: ResMut<SessionTracker>,
game_config: Res<GameConfig>,
mut notifications: ResMut<Notifications>,
timer_settings: Res<TimerSettings>,
) {
for message in messages.read() {
if matches!(message.phase, Phase::Focus { .. }) {
let berries = config.berries_per_focus_minute
* (timer_settings.focus_duration as f32 / 60.0).floor() as u32;
inventory.update_item_stack(
&mut commands,
&mut items_query,
ItemType::Berry,
berries as i32,
);
session_tracker.total_berries_earned += berries;
session_tracker.completed_focus_phases += 1;
let berries_name = match berries {
1 => ItemType::Berry.singular(&game_config),
_ => ItemType::Berry.plural(&game_config),
};
notifications.info(
Some("Fokus Belohnung"),
format!(
"Du hast {} {} als Belohnung für das Abschließen einer Fokus-Phase erhalten!",
berries, berries_name
),
);
}
}
}
/// Toggles pause state of the timer.
fn handle_pause( fn handle_pause(
mut messages: MessageReader<PhaseTimerPauseMessage>, mut messages: MessageReader<PhaseTimerPauseMessage>,
mut phase_res: ResMut<CurrentPhase>, mut phase_res: ResMut<CurrentPhase>,
@@ -119,16 +177,15 @@ fn handle_pause(
} }
} }
/// Transitions to the next phase based on current state.
pub fn next_phase( pub fn next_phase(
current_phase: &mut CurrentPhase, current_phase: &mut CurrentPhase,
session_tracker: &mut SessionTracker, session_tracker: &SessionTracker,
settings: &TimerSettings, settings: &TimerSettings,
) { ) {
if let Phase::Finished { completed_phase } = &current_phase.0 { if let Phase::Finished { completed_phase } = &current_phase.0 {
match **completed_phase { match **completed_phase {
Phase::Focus { .. } => { Phase::Focus { .. } => {
session_tracker.completed_focus_phases += 1;
let is_long_break = session_tracker.completed_focus_phases > 0 let is_long_break = session_tracker.completed_focus_phases > 0
&& session_tracker.completed_focus_phases % settings.long_break_interval == 0; && session_tracker.completed_focus_phases % settings.long_break_interval == 0;
@@ -152,6 +209,7 @@ pub fn next_phase(
} }
} }
/// Handles transition to the next phase after user confirmation.
pub fn handle_continue( pub fn handle_continue(
mut messages: MessageReader<NextPhaseMessage>, mut messages: MessageReader<NextPhaseMessage>,
mut phase_res: ResMut<CurrentPhase>, mut phase_res: ResMut<CurrentPhase>,
@@ -167,7 +225,7 @@ pub fn handle_continue(
false false
}; };
next_phase(&mut phase_res, &mut session_tracker, &settings); next_phase(&mut phase_res, &session_tracker, &settings);
if entering_break { if entering_break {
println!("Growing crops and resetting watered state."); println!("Growing crops and resetting watered state.");
@@ -198,6 +256,10 @@ pub fn handle_continue(
} }
} }
if new_withered && !*withered {
session_tracker.total_plants_withered += 1;
}
*state = TileState::Occupied { *state = TileState::Occupied {
seed: seed.clone(), seed: seed.clone(),
watered: false, watered: false,

View File

@@ -1,3 +1,4 @@
/// Formats seconds into MM:SS or HH:MM:SS string.
pub fn format_time(seconds: f32) -> String { pub fn format_time(seconds: f32) -> String {
let seconds = seconds.max(0.0) as u32; let seconds = seconds.max(0.0) as u32;

View File

@@ -1,5 +1,6 @@
use crate::prelude::*; use crate::{features::phase::components::SessionTracker, prelude::*};
/// Actions Pom can perform on tiles.
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
pub enum InteractionAction { pub enum InteractionAction {
Plant(ItemType), Plant(ItemType),
@@ -88,6 +89,7 @@ impl InteractionAction {
item_stack_query: &mut Query<&mut ItemStack>, item_stack_query: &mut Query<&mut ItemStack>,
commands: &mut Commands, commands: &mut Commands,
game_config: &GameConfig, game_config: &GameConfig,
session_tracker: &mut SessionTracker,
) { ) {
let Ok(tile_entity) = grid.get_tile(pos) else { let Ok(tile_entity) = grid.get_tile(pos) else {
println!("Error during interaction: Couldn't get tile_entity"); println!("Error during interaction: Couldn't get tile_entity");
@@ -165,6 +167,7 @@ impl InteractionAction {
ItemType::Berry, ItemType::Berry,
config.grants as i32, config.grants as i32,
); );
session_tracker.total_berries_earned += config.grants;
} }
} }
} }

View File

@@ -2,20 +2,24 @@ use crate::features::pom::actions::InteractionAction;
use crate::prelude::*; use crate::prelude::*;
use std::collections::VecDeque; use std::collections::VecDeque;
/// Marker component for the main character.
#[derive(Component)] #[derive(Component)]
pub struct Pom; pub struct Pom;
#[derive(Component, Serialize, Deserialize, Clone, Copy)] /// Current logical position on the grid.
#[derive(Component, Serialize, Deserialize, Clone, Copy, Default)]
pub struct GridPosition { pub struct GridPosition {
pub x: u32, pub x: u32,
pub y: u32, pub y: u32,
} }
/// Queue of grid positions to visit.
#[derive(Component, Default)] #[derive(Component, Default)]
pub struct PathQueue { pub struct PathQueue {
pub steps: VecDeque<(u32, u32)>, pub steps: VecDeque<(u32, u32)>,
} }
/// Movement direction state for animation.
#[derive(Component, Default)] #[derive(Component, Default)]
pub enum MovingState { pub enum MovingState {
#[default] #[default]
@@ -38,6 +42,7 @@ impl MovingState {
} }
} }
/// Target tile and action for pending interaction.
#[derive(Component, Default)] #[derive(Component, Default)]
pub struct InteractionTarget { pub struct InteractionTarget {
pub target: Option<(u32, u32)>, pub target: Option<(u32, u32)>,

View File

@@ -1,17 +1,20 @@
use crate::features::pom::actions::InteractionAction; use crate::features::pom::actions::InteractionAction;
use crate::prelude::*; use crate::prelude::*;
/// Request to move Pom to a specific tile.
#[derive(Message)] #[derive(Message)]
pub struct MoveMessage { pub struct MoveMessage {
pub x: u32, pub x: u32,
pub y: u32, pub y: u32,
} }
/// Notification that a move request was invalid.
#[derive(Message)] #[derive(Message)]
pub struct InvalidMoveMessage { pub struct InvalidMoveMessage {
pub message: String, pub message: String,
} }
/// Request to start an interaction sequence.
#[derive(Message)] #[derive(Message)]
pub struct InteractStartMessage { pub struct InteractStartMessage {
pub x: u32, pub x: u32,
@@ -19,6 +22,7 @@ pub struct InteractStartMessage {
pub action: InteractionAction, pub action: InteractionAction,
} }
/// Notification that a tile was clicked (requesting context menu).
#[derive(Message)] #[derive(Message)]
pub struct TileClickMessage { pub struct TileClickMessage {
pub x: u32, pub x: u32,

View File

@@ -11,6 +11,7 @@ pub mod messages;
pub mod ui; pub mod ui;
pub mod utils; pub mod utils;
/// Plugin controlling the main character (Pom) behavior.
pub struct PomPlugin; pub struct PomPlugin;
impl Plugin for PomPlugin { impl Plugin for PomPlugin {
@@ -34,6 +35,7 @@ impl Plugin for PomPlugin {
} }
} }
/// Draws the path Pom will follow using gizmos.
fn draw_path( fn draw_path(
mut gizmos: Gizmos, mut gizmos: Gizmos,
query: Query<(&Transform, &PathQueue), With<Pom>>, query: Query<(&Transform, &PathQueue), With<Pom>>,
@@ -65,6 +67,7 @@ fn draw_path(
} }
} }
/// Spawns the Pom character.
fn setup(mut commands: Commands, asset_server: Res<AssetServer>, config: Res<GameConfig>) { fn setup(mut commands: Commands, asset_server: Res<AssetServer>, config: Res<GameConfig>) {
commands.spawn(( commands.spawn((
Pom, Pom,
@@ -87,12 +90,14 @@ fn setup(mut commands: Commands, asset_server: Res<AssetServer>, config: Res<Gam
)); ));
} }
/// Despawns the Pom character.
fn cleanup(mut commands: Commands, pom_query: Query<Entity, With<Pom>>) { fn cleanup(mut commands: Commands, pom_query: Query<Entity, With<Pom>>) {
for pom_entity in pom_query.iter() { for pom_entity in pom_query.iter() {
commands.entity(pom_entity).despawn(); commands.entity(pom_entity).despawn();
} }
} }
/// Calculates path for manual movement requests.
fn handle_move( fn handle_move(
mut move_messages: MessageReader<MoveMessage>, mut move_messages: MessageReader<MoveMessage>,
mut invalid_move_messages: MessageWriter<InvalidMoveMessage>, mut invalid_move_messages: MessageWriter<InvalidMoveMessage>,
@@ -127,6 +132,7 @@ fn handle_move(
} }
} }
/// Calculates path to interaction target.
fn handle_interact( fn handle_interact(
mut interact_messages: MessageReader<InteractStartMessage>, mut interact_messages: MessageReader<InteractStartMessage>,
mut pom_query: Query<(&GridPosition, &mut PathQueue, &mut InteractionTarget)>, mut pom_query: Query<(&GridPosition, &mut PathQueue, &mut InteractionTarget)>,
@@ -184,7 +190,8 @@ fn handle_interact(
} }
} }
fn perform_interaction( /// Executes the pending interaction when target is reached.
pub fn perform_interaction(
mut pom_query: Query<(&GridPosition, &mut InteractionTarget, &PathQueue)>, mut pom_query: Query<(&GridPosition, &mut InteractionTarget, &PathQueue)>,
grid: Res<Grid>, grid: Res<Grid>,
mut tile_query: Query<&mut TileState>, mut tile_query: Query<&mut TileState>,
@@ -192,6 +199,8 @@ fn perform_interaction(
mut item_stack_query: Query<&mut ItemStack>, mut item_stack_query: Query<&mut ItemStack>,
mut commands: Commands, mut commands: Commands,
config: Res<GameConfig>, config: Res<GameConfig>,
mut session_tracker: ResMut<crate::features::phase::components::SessionTracker>,
mut notifications: ResMut<Notifications>,
) { ) {
for (pos, mut target_component, path_queue) in pom_query.iter_mut() { for (pos, mut target_component, path_queue) in pom_query.iter_mut() {
if let Some(target) = target_component.target { if let Some(target) = target_component.target {
@@ -201,6 +210,17 @@ fn perform_interaction(
} }
if manhattan_distance(pos.x, pos.y, target.0, target.1) == 1 { if manhattan_distance(pos.x, pos.y, target.0, target.1) == 1 {
if let Some(actions::InteractionAction::Plant(_)) = &target_component.action {
if utils::is_trapped((pos.x, pos.y), target, &grid, |e| {
tile_query.get(e).ok().cloned()
}) {
notifications.error(None::<String>, "That would trap you!");
target_component.target = None;
target_component.action = None;
continue;
}
}
println!( println!(
"Performing interaction on tile ({}, {})", "Performing interaction on tile ({}, {})",
target.0, target.1 target.0, target.1
@@ -215,6 +235,7 @@ fn perform_interaction(
&mut item_stack_query, &mut item_stack_query,
&mut commands, &mut commands,
&config, &config,
&mut session_tracker,
); );
} }
} }
@@ -225,6 +246,7 @@ fn perform_interaction(
} }
} }
/// Moves the Pom character along the path.
fn move_pom( fn move_pom(
time: Res<Time>, time: Res<Time>,
mut query: Query<( mut query: Query<(
@@ -274,6 +296,7 @@ fn move_pom(
} }
} }
/// Updates Pom animation based on movement state.
fn update_pom(asset_server: Res<AssetServer>, mut query: Query<(&MovingState, &mut AseAnimation)>) { fn update_pom(asset_server: Res<AssetServer>, mut query: Query<(&MovingState, &mut AseAnimation)>) {
for (moving_state, mut animation) in query.iter_mut() { for (moving_state, mut animation) in query.iter_mut() {
match moving_state { match moving_state {

View File

@@ -1,13 +1,16 @@
use crate::features::pom::actions::InteractionAction; use crate::features::pom::actions::InteractionAction;
use crate::features::ui::messages::ClosePopupMessage;
use crate::features::ui::utils::ui_blocks; use crate::features::ui::utils::ui_blocks;
use crate::prelude::*; use crate::prelude::*;
use bevy::window::PrimaryWindow; use bevy::window::PrimaryWindow;
/// Marker for context menu UI root.
#[derive(Component)] #[derive(Component)]
pub enum RootMarker { pub enum RootMarker {
ContextMenu, ContextMenu,
} }
/// Buttons available in the context menu.
#[derive(Component)] #[derive(Component)]
pub enum ButtonType { pub enum ButtonType {
Interact { Interact {
@@ -18,7 +21,8 @@ pub enum ButtonType {
Cancel, Cancel,
} }
pub fn spawn_context_menu( /// Spawns the context menu at the clicked tile position.
pub fn open_context_menu(
mut commands: Commands, mut commands: Commands,
mut tile_click_messages: MessageReader<TileClickMessage>, mut tile_click_messages: MessageReader<TileClickMessage>,
root_query: Query<Entity, With<RootMarker>>, root_query: Query<Entity, With<RootMarker>>,
@@ -56,22 +60,11 @@ pub fn spawn_context_menu(
let options = let options =
InteractionAction::list_options(tile_state, &inventory, item_query, &game_config); InteractionAction::list_options(tile_state, &inventory, item_query, &game_config);
commands spawn_context_menu(
.spawn(( &mut commands,
Node {
position_type: PositionType::Absolute,
left: px(screen_pos.x),
top: px(screen_pos.y),
padding: UiRect::all(px(5.0)),
..Node::vstack(px(5.0))
},
ZIndex(100),
BackgroundColor(Color::srgb(0.1, 0.1, 0.1)),
BorderRadius::all(px(5)),
RootMarker::ContextMenu, RootMarker::ContextMenu,
GlobalTransform::default(), screen_pos,
)) |parent| {
.with_children(|parent| {
for option in options { for option in options {
parent.spawn(button( parent.spawn(button(
ButtonType::Interact { ButtonType::Interact {
@@ -80,10 +73,7 @@ pub fn spawn_context_menu(
action: option.clone(), action: option.clone(),
}, },
ButtonVariant::Primary, ButtonVariant::Primary,
Node { Node::from_padding(UiRect::all(px(5))),
padding: UiRect::all(px(5)),
..default()
},
|c| text(option.clone().get_name(&game_config), 20.0, c), // TODO: add sprite |c| text(option.clone().get_name(&game_config), 20.0, c), // TODO: add sprite
)); ));
} }
@@ -91,17 +81,16 @@ pub fn spawn_context_menu(
parent.spawn(button( parent.spawn(button(
ButtonType::Cancel, ButtonType::Cancel,
ButtonVariant::Destructive, ButtonVariant::Destructive,
Node { Node::from_padding(UiRect::all(px(5))),
padding: UiRect::all(px(5)),
..default()
},
|c| text("Abbrechen", 20.0, c), |c| text("Abbrechen", 20.0, c),
)); ));
}); },
);
} }
} }
} }
/// Closes context menu when clicking elsewhere.
pub fn click_outside_context_menu( pub fn click_outside_context_menu(
mut commands: Commands, mut commands: Commands,
mouse_btn: Res<ButtonInput<MouseButton>>, mouse_btn: Res<ButtonInput<MouseButton>>,
@@ -122,6 +111,7 @@ pub fn click_outside_context_menu(
} }
} }
/// Handles context menu button clicks.
pub fn buttons( pub fn buttons(
mut commands: Commands, mut commands: Commands,
mut button_query: Query<(&Interaction, &ButtonType), (Changed<Interaction>, With<Button>)>, mut button_query: Query<(&Interaction, &ButtonType), (Changed<Interaction>, With<Button>)>,
@@ -147,3 +137,16 @@ pub fn buttons(
} }
} }
} }
/// Closes context menu on ClosePopupMessage.
pub fn close_context_menu(
mut commands: Commands,
mut close_popup_reader: MessageReader<ClosePopupMessage>,
root_query: Query<Entity, With<RootMarker>>,
) {
for _ in close_popup_reader.read() {
for entity in root_query.iter() {
commands.entity(entity).despawn();
}
}
}

View File

@@ -2,13 +2,14 @@ use crate::prelude::*;
pub mod context_menu; pub mod context_menu;
/// Plugin for Pom-related UI (context menu).
pub struct PomUiPlugin; pub struct PomUiPlugin;
impl Plugin for PomUiPlugin { impl Plugin for PomUiPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.add_systems( app.add_systems(
Update, Update,
context_menu::spawn_context_menu.run_if(in_state(AppState::GameScreen)), context_menu::open_context_menu.run_if(in_state(AppState::GameScreen)),
); );
app.add_systems( app.add_systems(
Update, Update,
@@ -18,5 +19,9 @@ impl Plugin for PomUiPlugin {
Update, Update,
context_menu::buttons.run_if(in_state(AppState::GameScreen)), context_menu::buttons.run_if(in_state(AppState::GameScreen)),
); );
app.add_systems(
Update,
context_menu::close_context_menu.run_if(in_state(AppState::GameScreen)),
);
} }
} }

View File

@@ -1,7 +1,8 @@
use crate::prelude::*; use crate::prelude::*;
use std::cmp::Ordering; use std::cmp::Ordering;
use std::collections::{BinaryHeap, HashMap, VecDeque}; use std::collections::{BinaryHeap, HashMap, HashSet, VecDeque};
/// A star pathfinding node.
#[derive(Copy, Clone, Eq, PartialEq)] #[derive(Copy, Clone, Eq, PartialEq)]
pub struct Node { pub struct Node {
x: u32, x: u32,
@@ -25,10 +26,81 @@ impl PartialOrd for Node {
} }
} }
/// Calculates Manhattan distance between two points.
pub fn manhattan_distance(x1: u32, y1: u32, x2: u32, y2: u32) -> u32 { pub fn manhattan_distance(x1: u32, y1: u32, x2: u32, y2: u32) -> u32 {
x1.abs_diff(x2) + y1.abs_diff(y2) x1.abs_diff(x2) + y1.abs_diff(y2)
} }
/// Checks if Pom would be trapped in a small, isolated area if a specific tile is blocked.
pub fn is_trapped<F>(
start: (u32, u32),
blocked_pos: (u32, u32),
grid: &Grid,
get_tile_state: F,
) -> bool
where
F: Fn(Entity) -> Option<TileState>,
{
let mut open_set = VecDeque::new();
let mut visited = HashSet::new();
open_set.push_back(start);
visited.insert(start);
if start == blocked_pos {
return true;
}
let max_search = 50;
let min_safe_area = 5;
while let Some(current) = open_set.pop_front() {
if visited.len() >= max_search {
return false;
}
let neighbors = [
(current.0 as i32 + 1, current.1 as i32),
(current.0 as i32 - 1, current.1 as i32),
(current.0 as i32, current.1 as i32 + 1),
(current.0 as i32, current.1 as i32 - 1),
];
for (nx, ny) in neighbors {
if nx < 0 || ny < 0 {
continue;
}
let next_pos = (nx as u32, ny as u32);
if next_pos == blocked_pos {
continue;
}
if visited.contains(&next_pos) {
continue;
}
let tile_entity = match grid.get_tile(next_pos) {
Ok(e) => e,
Err(_) => continue,
};
if let Some(state) = get_tile_state(tile_entity) {
if let TileState::Unclaimed = state {
return false;
}
if !state.is_blocking() {
visited.insert(next_pos);
open_set.push_back(next_pos);
}
}
}
}
visited.len() < min_safe_area
}
/// Finds a path from start to end using A* algorithm.
pub fn find_path( pub fn find_path(
start: (u32, u32), start: (u32, u32),
end: (u32, u32), end: (u32, u32),

View File

@@ -1,29 +1,39 @@
use crate::features::achievement::components::AchievementProgress;
use crate::prelude::*; use crate::prelude::*;
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
/// Resource containing the path to the current save file.
#[derive(Resource, Clone, Debug)] #[derive(Resource, Clone, Debug)]
pub struct SavegamePath(pub PathBuf); pub struct SavegamePath(pub PathBuf);
#[derive(Debug)] /// Metadata about a savegame.
#[derive(Debug, Clone)]
pub struct SavegameInfo { pub struct SavegameInfo {
pub path: SavegamePath, pub path: SavegamePath,
pub index: u32, pub index: u32,
pub total_berries: u32, pub total_berries: u32,
pub completed_focus: u32, pub completed_focus: u32,
pub achievement_progress: AchievementProgress,
} }
/// Helper for partial JSON deserialization.
#[derive(Deserialize)] #[derive(Deserialize)]
struct PartialSaveData { struct PartialSaveData {
session_tracker: PartialSessionTracker, session_tracker: PartialSessionTracker,
achievement_progress: AchievementProgress,
} }
/// Helper for partial JSON deserialization of session stats.
#[derive(Deserialize)] #[derive(Deserialize)]
struct PartialSessionTracker { struct PartialSessionTracker {
completed_focus_phases: u32, completed_focus_phases: u32,
#[serde(default)]
total_berries_earned: u32,
} }
impl SavegamePath { impl SavegamePath {
/// Constructs a new path for a specific save index.
pub fn new(index: u32) -> Self { pub fn new(index: u32) -> Self {
let base_path = get_internal_path().unwrap_or_else(|| { let base_path = get_internal_path().unwrap_or_else(|| {
println!( println!(
@@ -39,6 +49,7 @@ impl SavegamePath {
Self(base_path.join(format!("savegame-{}.json", index))) Self(base_path.join(format!("savegame-{}.json", index)))
} }
/// Lists all available savegames.
pub fn list() -> Vec<SavegameInfo> { pub fn list() -> Vec<SavegameInfo> {
let mut savegames = Vec::new(); let mut savegames = Vec::new();
@@ -77,8 +88,9 @@ impl SavegamePath {
savegames.push(SavegameInfo { savegames.push(SavegameInfo {
path: SavegamePath(path), path: SavegamePath(path),
index, index,
total_berries: 0, // TODO: add total_berries total_berries: data.session_tracker.total_berries_earned,
completed_focus: data.session_tracker.completed_focus_phases, completed_focus: data.session_tracker.completed_focus_phases,
achievement_progress: data.achievement_progress,
}); });
} }
@@ -86,6 +98,7 @@ impl SavegamePath {
savegames savegames
} }
/// Returns a path for a new savegame (incremented index).
pub fn next() -> Self { pub fn next() -> Self {
let savegames = Self::list(); let savegames = Self::list();
let next_index = savegames.last().map(|s| s.index + 1).unwrap_or(0); let next_index = savegames.last().map(|s| s.index + 1).unwrap_or(0);
@@ -93,14 +106,16 @@ impl SavegamePath {
} }
} }
/// Markers for savegame UI.
#[derive(Component)] #[derive(Component)]
pub enum RootMarker { pub enum RootMarker {
PopupSavegameLoad, PopupSavegameLoad,
} }
/// Buttons for savegame management.
#[derive(Component)] #[derive(Component)]
pub enum ButtonType { pub enum ButtonType {
SavegameLoad { savegame_path: SavegamePath }, SavegameLoad { savegame_path: SavegamePath },
SavegameDelete { savegame_path: SavegamePath }, SavegameDelete { savegame_path: SavegamePath },
PopupClose, Achievements { savegame: SavegameInfo },
} }

View File

@@ -1,7 +1,9 @@
use crate::prelude::*; use crate::prelude::*;
/// Trigger to save the current game.
#[derive(Message)] #[derive(Message)]
pub struct SavegameDumpMessage; pub struct SavegameDumpMessage;
/// Trigger to load a game from disk.
#[derive(Message)] #[derive(Message)]
pub struct SavegameLoadMessage; pub struct SavegameLoadMessage;

View File

@@ -1,6 +1,8 @@
use crate::features::achievement::components::AchievementProgress;
use crate::features::phase::components::{SessionTracker, TimerSettings}; use crate::features::phase::components::{SessionTracker, TimerSettings};
use crate::features::savegame::ui::load_popup_handler; use crate::features::savegame::ui::load_popup_handler;
use crate::prelude::*; use crate::prelude::*;
use components::*;
use messages::*; use messages::*;
use std::fs::File; use std::fs::File;
use std::io::{Read, Write}; use std::io::{Read, Write};
@@ -9,6 +11,7 @@ pub mod components;
pub mod messages; pub mod messages;
pub mod ui; pub mod ui;
/// Plugin dealing with savegame loading and saving.
pub struct SavegamePlugin; pub struct SavegamePlugin;
impl Plugin for SavegamePlugin { impl Plugin for SavegamePlugin {
@@ -18,11 +21,13 @@ impl Plugin for SavegamePlugin {
app.add_systems(Update, dump_savegame.run_if(in_state(AppState::GameScreen))); app.add_systems(Update, dump_savegame.run_if(in_state(AppState::GameScreen)));
app.add_systems(Update, load_savegame.run_if(in_state(AppState::GameScreen))); app.add_systems(Update, load_savegame.run_if(in_state(AppState::GameScreen)));
app.add_systems(OnExit(AppState::GameScreen), reset_savegame);
app.add_systems(Update, load_popup_handler); app.add_systems(Update, load_popup_handler);
} }
} }
/// The structure of a save file.
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
struct SaveData { struct SaveData {
grid_width: u32, grid_width: u32,
@@ -30,11 +35,13 @@ struct SaveData {
tiles: Vec<Vec<TileState>>, tiles: Vec<Vec<TileState>>,
current_phase: CurrentPhase, current_phase: CurrentPhase,
session_tracker: SessionTracker, session_tracker: SessionTracker,
achievement_progress: AchievementProgress,
timer_settings: TimerSettings, timer_settings: TimerSettings,
pom_position: GridPosition, pom_position: GridPosition,
inventory: Vec<ItemStack>, inventory: Vec<ItemStack>,
} }
/// Serializes game state and writes it to a file.
fn dump_savegame( fn dump_savegame(
mut messages: MessageReader<SavegameDumpMessage>, mut messages: MessageReader<SavegameDumpMessage>,
save_path: Res<SavegamePath>, save_path: Res<SavegamePath>,
@@ -42,6 +49,7 @@ fn dump_savegame(
tile_query: Query<&TileState>, tile_query: Query<&TileState>,
phase: Res<CurrentPhase>, phase: Res<CurrentPhase>,
tracker: Res<SessionTracker>, tracker: Res<SessionTracker>,
achievement_progress: Res<AchievementProgress>,
settings: Res<TimerSettings>, settings: Res<TimerSettings>,
pom_query: Query<&GridPosition, With<Pom>>, pom_query: Query<&GridPosition, With<Pom>>,
inventory: Res<Inventory>, inventory: Res<Inventory>,
@@ -80,6 +88,7 @@ fn dump_savegame(
tiles: tile_states, tiles: tile_states,
current_phase: phase.clone(), current_phase: phase.clone(),
session_tracker: tracker.clone(), session_tracker: tracker.clone(),
achievement_progress: achievement_progress.clone(),
timer_settings: settings.clone(), timer_settings: settings.clone(),
pom_position: *pom_pos, pom_position: *pom_pos,
inventory: item_stacks, inventory: item_stacks,
@@ -104,6 +113,7 @@ fn dump_savegame(
} }
} }
/// Reads a save file and restores game state.
fn load_savegame( fn load_savegame(
mut commands: Commands, mut commands: Commands,
mut messages: MessageReader<SavegameLoadMessage>, mut messages: MessageReader<SavegameLoadMessage>,
@@ -112,6 +122,7 @@ fn load_savegame(
mut tile_query: Query<&mut TileState>, mut tile_query: Query<&mut TileState>,
mut phase: ResMut<CurrentPhase>, mut phase: ResMut<CurrentPhase>,
mut tracker: ResMut<SessionTracker>, mut tracker: ResMut<SessionTracker>,
mut achievement_progress: ResMut<AchievementProgress>,
mut settings: ResMut<TimerSettings>, mut settings: ResMut<TimerSettings>,
mut pom_query: Query<(&mut GridPosition, &mut Transform), With<Pom>>, mut pom_query: Query<(&mut GridPosition, &mut Transform), With<Pom>>,
mut inventory: ResMut<Inventory>, mut inventory: ResMut<Inventory>,
@@ -128,6 +139,7 @@ fn load_savegame(
Ok(save_data) => { Ok(save_data) => {
*phase = save_data.current_phase; *phase = save_data.current_phase;
*tracker = save_data.session_tracker; *tracker = save_data.session_tracker;
*achievement_progress = save_data.achievement_progress;
*settings = save_data.timer_settings; *settings = save_data.timer_settings;
if let Ok((mut pom_pos, mut pom_transform)) = pom_query.single_mut() { if let Ok((mut pom_pos, mut pom_transform)) = pom_query.single_mut() {
@@ -171,3 +183,38 @@ fn load_savegame(
} }
} }
} }
/// Resets all components/resources loaded by `load_savegame`.
fn reset_savegame(
mut commands: Commands,
grid: Res<Grid>,
mut tile_query: Query<&mut TileState>,
mut phase: ResMut<CurrentPhase>,
mut tracker: ResMut<SessionTracker>,
mut achievement_progress: ResMut<AchievementProgress>,
mut settings: ResMut<TimerSettings>,
mut pom_query: Query<(&mut GridPosition, &mut Transform), With<Pom>>,
mut inventory: ResMut<Inventory>,
) {
*tracker = SessionTracker::default();
*achievement_progress = AchievementProgress::default();
*settings = TimerSettings::default();
*phase = CurrentPhase(Phase::Focus {
duration: settings.focus_duration as f32,
});
inventory
.items
.iter()
.for_each(|entity| commands.entity(*entity).despawn());
inventory.items.clear();
if let Ok((mut pom_pos, mut pom_transform)) = pom_query.single_mut() {
*pom_pos = GridPosition::default();
pom_transform.translation = grid_to_world_coords(0, 0, Some(1.0), grid.width, grid.height);
}
tile_query
.iter_mut()
.for_each(|mut state| *state = TileState::default());
}

View File

@@ -1,84 +1,49 @@
use super::super::components::{ButtonType, RootMarker}; use super::super::components::{ButtonType, RootMarker};
use crate::features::achievement::ui::open_achievements_menu;
use crate::{features::savegame::messages::SavegameLoadMessage, prelude::*}; use crate::{features::savegame::messages::SavegameLoadMessage, prelude::*};
pub fn spawn_load_popup(commands: &mut Commands) { /// Spawns the "Load Game" popup.
commands pub fn spawn_load_popup(commands: &mut Commands, asset_server: &AssetServer) {
.spawn(( spawn_popup(
commands,
RootMarker::PopupSavegameLoad, RootMarker::PopupSavegameLoad,
"Spielstand wählen",
Node { Node {
position_type: PositionType::Absolute, width: px(600),
width: percent(100), height: px(500),
height: percent(100),
..Node::center()
},
ZIndex(1),
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.8)),
GlobalTransform::default(),
))
.with_children(|parent| {
parent
.spawn((
Node {
width: px(600.0),
height: px(500.0),
padding: UiRect::all(px(20.0)),
align_items: AlignItems::Center,
..Node::vstack(px(10))
},
BackgroundColor(Color::srgb(0.2, 0.2, 0.2)),
BorderRadius::all(px(10.0)),
))
.with_children(|parent| {
parent.spawn((
Node {
width: percent(100.0),
justify_content: JustifyContent::SpaceBetween,
align_items: AlignItems::Center,
margin: UiRect::bottom(px(20.0)),
..default() ..default()
}, },
children![ |parent| {
text("Spielstand Auswahl", 40.0, Color::WHITE),
pill_button(
ButtonType::PopupClose,
ButtonVariant::Destructive,
Node {
width: px(40),
height: px(40),
..default()
},
|color| text("X", 24.0, color)
)
],
));
parent parent
.spawn(Node { .spawn(Node {
width: percent(100), width: percent(100),
flex_direction: FlexDirection::Column, flex_direction: FlexDirection::Column,
overflow: Overflow::scroll_y(), overflow: Overflow::scroll_y(),
margin: UiRect::all(px(20.0)), padding: UiRect::all(px(10)),
row_gap: px(10.0), row_gap: px(10),
..default() ..default()
}) })
.with_children(|parent| { .with_children(|parent| {
for savegame in SavegamePath::list() { for savegame in SavegamePath::list() {
parent.spawn( parent.spawn(button(
button( ButtonType::SavegameLoad {
ButtonType::SavegameLoad { savegame_path: savegame.path.clone() }, savegame_path: savegame.path.clone(),
},
ButtonVariant::Secondary, ButtonVariant::Secondary,
Node { Node {
width: percent(100), width: percent(100),
padding: UiRect::all(px(10)), padding: UiRect::all(px(10)),
..Node::center() ..Node::center()
}, },
|color| ( |color| {
(
Node { Node {
width: percent(100), width: percent(100),
align_items: AlignItems::Center, align_items: AlignItems::Center,
..Node::hstack(px(10)) ..Node::hstack(px(10))
}, },
children![( children![
(
Node { Node {
width: percent(100), width: percent(100),
height: percent(100), height: percent(100),
@@ -103,7 +68,26 @@ pub fn spawn_load_popup(commands: &mut Commands) {
), ),
] ]
), ),
pill_button( button(
ButtonType::Achievements {
savegame: savegame.clone()
},
ButtonVariant::Primary,
Node {
width: px(40),
height: px(40),
..default()
},
|_| (
ImageNode::default(),
AseSlice {
aseprite: asset_server
.load("achievement.aseprite"),
name: "Achievement".into()
}
)
),
button(
ButtonType::SavegameDelete { ButtonType::SavegameDelete {
savegame_path: savegame.path.clone() savegame_path: savegame.path.clone()
}, },
@@ -114,16 +98,18 @@ pub fn spawn_load_popup(commands: &mut Commands) {
..default() ..default()
}, },
|color| text("X", 24.0, color) |color| text("X", 24.0, color)
)]
) )
), ],
); )
},
));
} }
}); });
}); },
}); );
} }
/// Handles interactions in the load popup.
pub fn load_popup_handler( pub fn load_popup_handler(
mut commands: Commands, mut commands: Commands,
mut next_state: ResMut<NextState<AppState>>, mut next_state: ResMut<NextState<AppState>>,
@@ -135,7 +121,6 @@ pub fn load_popup_handler(
match *interaction { match *interaction {
Interaction::Pressed => { Interaction::Pressed => {
match button_type { match button_type {
ButtonType::PopupClose => {}
ButtonType::SavegameLoad { savegame_path } => { ButtonType::SavegameLoad { savegame_path } => {
commands.insert_resource(savegame_path.clone()); commands.insert_resource(savegame_path.clone());
next_state.set(AppState::GameScreen); next_state.set(AppState::GameScreen);
@@ -146,6 +131,9 @@ pub fn load_popup_handler(
println!("Error while deleting savegame: {:?}", e); println!("Error while deleting savegame: {:?}", e);
} }
} }
ButtonType::Achievements { savegame } => {
open_achievements_menu(&mut commands, &savegame.achievement_progress);
}
}; };
for (entity, root) in root_query.iter() { for (entity, root) in root_query.iter() {

View File

@@ -1,17 +1,19 @@
use crate::prelude::*; use crate::prelude::*;
/// Markers for shop UI.
#[derive(Component)] #[derive(Component)]
pub enum RootMarker { pub enum RootMarker {
Shop, Shop,
} }
/// Buttons in the shop.
#[derive(Component)] #[derive(Component)]
pub enum ButtonType { pub enum ButtonType {
ShopOpen, ShopOpen,
ShopClose,
ShopBuyItem(ShopOffer), ShopBuyItem(ShopOffer),
} }
/// An item available for purchase.
#[derive(Clone)] #[derive(Clone)]
pub struct ShopOffer { pub struct ShopOffer {
pub item: ItemStack, pub item: ItemStack,
@@ -19,6 +21,7 @@ pub struct ShopOffer {
} }
impl ShopOffer { impl ShopOffer {
/// Generates a list of all current offers.
pub fn list_all(game_config: &GameConfig, tile_count: u32) -> Vec<ShopOffer> { pub fn list_all(game_config: &GameConfig, tile_count: u32) -> Vec<ShopOffer> {
let mut offers = Vec::new(); let mut offers = Vec::new();
@@ -51,6 +54,7 @@ impl ShopOffer {
offers offers
} }
/// Attempts to purchase the offer.
pub fn buy( pub fn buy(
&self, &self,
inventory: &mut Inventory, inventory: &mut Inventory,
@@ -71,4 +75,3 @@ impl ShopOffer {
} }
} }
} }

View File

@@ -5,6 +5,7 @@ use ui::open_shop;
pub mod components; pub mod components;
pub mod ui; pub mod ui;
/// Plugin for the in-game shop.
pub struct ShopPlugin; pub struct ShopPlugin;
impl Plugin for ShopPlugin { impl Plugin for ShopPlugin {
@@ -13,6 +14,7 @@ impl Plugin for ShopPlugin {
} }
} }
/// Handles shop button interactions.
fn buttons( fn buttons(
mut commands: Commands, mut commands: Commands,
mut interaction_query: Query<(&Interaction, &ButtonType), (Changed<Interaction>, With<Button>)>, mut interaction_query: Query<(&Interaction, &ButtonType), (Changed<Interaction>, With<Button>)>,
@@ -21,19 +23,14 @@ fn buttons(
asset_server: Res<AssetServer>, asset_server: Res<AssetServer>,
mut inventory: ResMut<Inventory>, mut inventory: ResMut<Inventory>,
mut items: Query<&mut ItemStack>, mut items: Query<&mut ItemStack>,
grid: Res<Grid>,
tile_query: Query<&TileState>,
) { ) {
for (interaction, button_type) in &mut interaction_query { for (interaction, button_type) in &mut interaction_query {
match *interaction { match *interaction {
Interaction::Pressed => match button_type { Interaction::Pressed => match button_type {
ButtonType::ShopOpen => { ButtonType::ShopOpen => {
open_shop(&mut commands, &game_config, &asset_server); open_shop(&mut commands, &game_config, &asset_server, &grid, &tile_query);
}
ButtonType::ShopClose => {
for (entity, root) in root_query.iter() {
match *root {
RootMarker::Shop => commands.entity(entity).despawn(),
}
}
} }
ButtonType::ShopBuyItem(offer) => { ButtonType::ShopBuyItem(offer) => {
if offer.buy(&mut inventory, &mut commands, &mut items) { if offer.buy(&mut inventory, &mut commands, &mut items) {

View File

@@ -1,6 +1,7 @@
use super::super::components::*; use super::super::components::*;
use crate::{features::inventory::ui::item::list_itemstack, prelude::*}; use crate::{features::inventory::ui::item::list_itemstack, prelude::*};
/// Creates the UI bundle for a shop offer.
pub fn shop_offer( pub fn shop_offer(
offer: &ShopOffer, offer: &ShopOffer,
game_config: &GameConfig, game_config: &GameConfig,
@@ -26,6 +27,7 @@ pub fn shop_offer(
) )
} }
/// Creates the UI bundle for displaying a price.
pub fn shop_price( pub fn shop_price(
price: u32, price: u32,
asset_server: &Res<AssetServer>, asset_server: &Res<AssetServer>,

View File

@@ -1,64 +1,31 @@
use super::super::components::*; use super::super::components::*;
use crate::{features::shop::ui::shop_offer, prelude::*}; use crate::{features::shop::ui::shop_offer, prelude::*};
/// Spawns the shop popup.
pub fn open_shop( pub fn open_shop(
commands: &mut Commands, commands: &mut Commands,
game_config: &GameConfig, game_config: &GameConfig,
asset_server: &Res<AssetServer>, asset_server: &Res<AssetServer>,
grid: &Grid,
tile_query: &Query<&TileState>,
) { ) {
// TODO: calculate tile_count let tile_count = grid.count_claimed_tiles(tile_query);
let offers = ShopOffer::list_all(game_config, 0); let offers = ShopOffer::list_all(game_config, tile_count);
commands spawn_popup(
.spawn(( commands,
RootMarker::Shop, RootMarker::Shop,
Node { "Einkaufsladen",
position_type: PositionType::Absolute,
width: percent(100),
height: percent(100),
..Node::center()
},
ZIndex(1),
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.8)),
GlobalTransform::default(),
))
.with_children(|parent| {
parent
.spawn((
Node { Node {
width: px(700), width: px(700),
padding: UiRect::all(px(20.0)),
..Node::vstack(px(20))
},
BackgroundColor(Color::srgb(0.2, 0.2, 0.2)),
BorderRadius::all(px(10.0)),
))
.with_children(|parent| {
parent.spawn((
Node {
justify_content: JustifyContent::SpaceBetween,
..Node::hstack(px(20))
},
children![
text("Shop", 40.0, Color::WHITE),
pill_button(
ButtonType::ShopClose,
ButtonVariant::Destructive,
Node {
width: px(40),
height: px(40),
..default() ..default()
}, },
|color| text("X", 24.0, color) |parent| {
),
],
));
parent.spawn(Node::vstack(px(10))).with_children(|parent| { parent.spawn(Node::vstack(px(10))).with_children(|parent| {
for offer in offers { for offer in offers {
parent.spawn(shop_offer(&offer, game_config, asset_server)); parent.spawn(shop_offer(&offer, game_config, asset_server));
} }
}); });
}); },
}); );
} }

View File

@@ -1,13 +1,20 @@
use crate::features::phase::components::TimerSettings;
use crate::prelude::*; use crate::prelude::*;
/// Markers for main menu UI.
#[derive(Component)] #[derive(Component)]
pub enum RootMarker { pub enum RootMarker {
MainMenu, MainMenu,
Settings,
} }
/// Buttons in the main menu.
#[derive(Component)] #[derive(Component)]
pub enum ButtonType { pub enum ButtonType {
LoadGame, LoadGame,
NewGame, NewGame,
Settings, Settings,
} }
#[derive(Resource, Default, Debug)]
pub struct StartScreenTimerSettings(pub Option<TimerSettings>);

View File

@@ -1,19 +1,34 @@
use crate::features::hud::components::{SettingsTimerInput, TimerType};
use crate::features::phase::components::TimerSettings;
use crate::features::savegame::ui::spawn_load_popup; use crate::features::savegame::ui::spawn_load_popup;
use crate::prelude::*; use crate::prelude::*;
use components::*; use components::*;
use ui::settings::open_settings_menu;
pub mod components; pub mod components;
pub mod ui;
/// Plugin for the main menu screen.
pub struct StartScreenPlugin; pub struct StartScreenPlugin;
impl Plugin for StartScreenPlugin { impl Plugin for StartScreenPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.init_resource::<StartScreenTimerSettings>();
app.add_systems(OnEnter(AppState::StartScreen), setup); app.add_systems(OnEnter(AppState::StartScreen), setup);
app.add_systems(OnExit(AppState::StartScreen), cleanup); app.add_systems(OnExit(AppState::StartScreen), cleanup);
app.add_systems(Update, menu.run_if(in_state(AppState::StartScreen))); app.add_systems(
Update,
(menu, handle_settings_buttons, update_timer_settings_display)
.run_if(in_state(AppState::StartScreen)),
);
app.add_systems(
PostUpdate,
apply_start_screen_settings.run_if(in_state(AppState::GameScreen)),
);
} }
} }
/// Spawns the main menu UI.
fn setup(mut commands: Commands) { fn setup(mut commands: Commands) {
commands.spawn(( commands.spawn((
RootMarker::MainMenu, RootMarker::MainMenu,
@@ -60,32 +75,111 @@ fn setup(mut commands: Commands) {
)); ));
} }
/// Handles main menu button interactions.
fn menu( fn menu(
mut commands: Commands, mut commands: Commands,
mut next_state: ResMut<NextState<AppState>>, mut next_state: ResMut<NextState<AppState>>,
mut interaction_query: Query<(&Interaction, &ButtonType), (Changed<Interaction>, With<Button>)>, mut interaction_query: Query<(&Interaction, &ButtonType), (Changed<Interaction>, With<Button>)>,
asset_server: Res<AssetServer>,
) { ) {
for (interaction, button_type) in &mut interaction_query { for (interaction, button_type) in &mut interaction_query {
match *interaction { match *interaction {
Interaction::Pressed => { Interaction::Pressed => match button_type {
match button_type {
ButtonType::LoadGame => { ButtonType::LoadGame => {
spawn_load_popup(&mut commands); spawn_load_popup(&mut commands, &asset_server);
} }
ButtonType::NewGame => { ButtonType::NewGame => {
commands.insert_resource(SavegamePath::next()); commands.insert_resource(SavegamePath::next());
next_state.set(AppState::GameScreen); next_state.set(AppState::GameScreen);
} }
ButtonType::Settings => todo!(), ButtonType::Settings => {
}; open_settings_menu(&mut commands);
} }
},
_ => (), _ => (),
} }
} }
} }
/// Cleans up main menu resources.
fn cleanup(mut commands: Commands, query: Query<Entity, With<RootMarker>>) { fn cleanup(mut commands: Commands, query: Query<Entity, With<RootMarker>>) {
for entity in query.iter() { for entity in query.iter() {
commands.entity(entity).despawn(); commands.entity(entity).despawn();
} }
} }
/// Handles button interactions within the settings menu.
fn handle_settings_buttons(
mut interaction_query: Query<
(&Interaction, &crate::features::hud::components::ButtonType),
(Changed<Interaction>, With<Button>),
>,
mut timer_settings: ResMut<TimerSettings>,
mut ss_timer_settings: ResMut<StartScreenTimerSettings>,
keys: Res<ButtonInput<KeyCode>>,
) {
let shift_multiplier = if keys.any_pressed([KeyCode::ShiftLeft, KeyCode::ShiftRight]) {
10
} else {
1
};
for (interaction, button_type) in &mut interaction_query {
if *interaction == Interaction::Pressed {
if let crate::features::hud::components::ButtonType::SettingsTimerChange {
input,
amount,
} = button_type
{
match input {
SettingsTimerInput::Minutes(timer_type) => {
timer_settings.change(timer_type, 60 * amount * shift_multiplier)
}
SettingsTimerInput::Seconds(timer_type) => {
timer_settings.change(timer_type, *amount * shift_multiplier)
}
}
ss_timer_settings.0 = Some(timer_settings.clone());
}
}
}
}
/// Updates the timer settings display in the settings menu.
fn update_timer_settings_display(
timer_settings: Res<TimerSettings>,
mut query: Query<(&SettingsTimerInput, &mut Text)>,
) {
for (input_type, mut text) in query.iter_mut() {
match input_type {
SettingsTimerInput::Minutes(timer_type) => {
let value = match timer_type {
TimerType::Focus => timer_settings.focus_duration,
TimerType::ShortBreak => timer_settings.short_break_duration,
TimerType::LongBreak => timer_settings.long_break_duration,
} as f32;
text.0 = format!("{:0>2}", (value / 60.0).floor());
}
SettingsTimerInput::Seconds(timer_type) => {
let value = match timer_type {
TimerType::Focus => timer_settings.focus_duration,
TimerType::ShortBreak => timer_settings.short_break_duration,
TimerType::LongBreak => timer_settings.long_break_duration,
};
text.0 = format!("{:0>2}", (value % 60));
}
}
}
}
/// Applies the timer settings from the start screen once the game screen is entered.
fn apply_start_screen_settings(
mut settings: ResMut<TimerSettings>,
mut ss_settings: ResMut<StartScreenTimerSettings>,
) {
if let Some(new_settings) = ss_settings.0.take() {
*settings = new_settings;
}
}

View File

@@ -0,0 +1 @@
pub mod settings;

View File

@@ -0,0 +1,71 @@
use crate::features::hud::components::TimerType;
use crate::features::hud::ui::timer_settings::timer_settings;
use crate::features::start_screen::components::RootMarker;
use crate::prelude::*;
/// Spawns the settings popup for the start screen.
pub fn open_settings_menu(commands: &mut Commands) {
spawn_popup(
commands,
RootMarker::Settings,
"Einstellungen",
Node {
width: px(700),
..default()
},
|parent| {
parent.spawn((
Node {
justify_content: JustifyContent::Center,
..Node::hstack(px(30))
},
children![
(
Node {
width: percent(40),
..Node::vstack(px(10))
},
children![
text("Timer Einstellungen", 18.0, Color::WHITE),
text(
"Tipp: Benutze [Umschalt] um in 10er Schritten zu inkrementieren oder dekrementieren!",
16.0,
Color::WHITE
),
]
),
(
Node {
align_items: AlignItems::Center,
..Node::vstack(px(10))
},
children![
text("Fokus Phase", 12.0, Color::WHITE),
timer_settings(TimerType::Focus)
]
),
(
Node {
align_items: AlignItems::Center,
..Node::vstack(px(10))
},
children![
text("Kurze Pause", 12.0, Color::WHITE),
timer_settings(TimerType::ShortBreak)
]
),
(
Node {
align_items: AlignItems::Center,
..Node::vstack(px(10))
},
children![
text("Lange Pause", 12.0, Color::WHITE),
timer_settings(TimerType::LongBreak)
]
)
],
));
},
);
}

View File

@@ -1,5 +1,6 @@
use crate::prelude::*; use crate::prelude::*;
/// Event triggering a scroll action on an entity.
#[derive(EntityEvent, Debug)] #[derive(EntityEvent, Debug)]
#[entity_event(propagate, auto_propagate)] #[entity_event(propagate, auto_propagate)]
pub struct Scroll { pub struct Scroll {
@@ -7,6 +8,7 @@ pub struct Scroll {
pub delta: Vec2, pub delta: Vec2,
} }
/// Visual styles for buttons.
#[derive(Component, Clone)] #[derive(Component, Clone)]
pub enum ButtonVariant { pub enum ButtonVariant {
Primary, Primary,

View File

@@ -1,6 +1,10 @@
use crate::prelude::*; use crate::prelude::*;
/// Pixel height of a single scroll line.
pub const LINE_HEIGHT: f32 = 21.0; pub const LINE_HEIGHT: f32 = 21.0;
/// Default background color for buttons.
pub const NORMAL_BUTTON: Color = Color::srgb(0.15, 0.15, 0.15); pub const NORMAL_BUTTON: Color = Color::srgb(0.15, 0.15, 0.15);
/// Background color when hovering a button.
pub const HOVERED_BUTTON: Color = Color::srgb(0.25, 0.25, 0.25); pub const HOVERED_BUTTON: Color = Color::srgb(0.25, 0.25, 0.25);
/// Background color when pressing a button.
pub const PRESSED_BUTTON: Color = Color::srgb(0.35, 0.75, 0.35); pub const PRESSED_BUTTON: Color = Color::srgb(0.35, 0.75, 0.35);

View File

@@ -0,0 +1,5 @@
use crate::prelude::*;
/// Message to close any open popup.
#[derive(Message)]
pub struct ClosePopupMessage;

View File

@@ -1,11 +1,13 @@
use crate::prelude::{button::update_buttons, *}; use crate::prelude::{button::update_buttons, popups::handle_popup_close, *};
use bevy::{input::mouse::*, picking::hover::HoverMap}; use bevy::{input::mouse::*, picking::hover::HoverMap};
pub mod components; pub mod components;
pub mod consts; pub mod consts;
pub mod messages;
pub mod ui; pub mod ui;
pub mod utils; pub mod utils;
/// Plugin for general UI behavior like scrolling and button states.
pub struct UiPlugin; pub struct UiPlugin;
impl Plugin for UiPlugin { impl Plugin for UiPlugin {
@@ -14,9 +16,12 @@ impl Plugin for UiPlugin {
app.add_observer(on_scroll_handler); app.add_observer(on_scroll_handler);
app.add_systems(Update, update_buttons); app.add_systems(Update, update_buttons);
app.add_systems(Update, handle_popup_close);
} }
} }
/// Reads mouse wheel events and triggers scroll actions.
fn scroll_events( fn scroll_events(
mut mouse_wheel_reader: MessageReader<MouseWheel>, mut mouse_wheel_reader: MessageReader<MouseWheel>,
hover_map: Res<HoverMap>, hover_map: Res<HoverMap>,
@@ -42,6 +47,7 @@ fn scroll_events(
} }
} }
/// Updates scroll position based on scroll events.
fn on_scroll_handler( fn on_scroll_handler(
mut scroll: On<components::Scroll>, mut scroll: On<components::Scroll>,
mut query: Query<(&mut ScrollPosition, &Node, &ComputedNode)>, mut query: Query<(&mut ScrollPosition, &Node, &ComputedNode)>,

View File

@@ -1,5 +1,6 @@
use crate::prelude::*; use crate::prelude::*;
/// Creates a standard button UI bundle.
pub fn button<C, R>( pub fn button<C, R>(
button_type: impl Component, button_type: impl Component,
variant: ButtonVariant, variant: ButtonVariant,
@@ -24,6 +25,7 @@ where
) )
} }
/// Creates a rounded pill-shaped button UI bundle.
pub fn pill_button<C, R>( pub fn pill_button<C, R>(
button_type: impl Component, button_type: impl Component,
variant: ButtonVariant, variant: ButtonVariant,
@@ -48,6 +50,7 @@ where
) )
} }
/// Updates button colors based on interaction state.
pub fn update_buttons( pub fn update_buttons(
mut interaction_query: Query< mut interaction_query: Query<
(&Interaction, &ButtonVariant, &mut BackgroundColor), (&Interaction, &ButtonVariant, &mut BackgroundColor),

View File

@@ -1,9 +1,11 @@
use crate::prelude::*; use crate::prelude::*;
/// Trait for easy flexbox layout construction.
pub trait Flexbox { pub trait Flexbox {
fn hstack(spacing: Val) -> Self; fn hstack(spacing: Val) -> Self;
fn vstack(spacing: Val) -> Self; fn vstack(spacing: Val) -> Self;
fn center() -> Self; fn center() -> Self;
fn from_padding(padding: UiRect) -> Self;
} }
impl Flexbox for Node { impl Flexbox for Node {
@@ -30,4 +32,11 @@ impl Flexbox for Node {
..default() ..default()
} }
} }
fn from_padding(padding: UiRect) -> Self {
Node {
padding,
..default()
}
}
} }

View File

@@ -1,7 +1,9 @@
pub mod button; pub mod button;
pub mod flexbox; pub mod flexbox;
pub mod popups;
pub mod texts; pub mod texts;
pub use button::{button, pill_button}; pub use button::{button, pill_button};
pub use flexbox::Flexbox; pub use flexbox::Flexbox;
pub use popups::{spawn_context_menu, spawn_popup};
pub use texts::{text, text_with_component}; pub use texts::{text, text_with_component};

View File

@@ -0,0 +1,119 @@
use bevy::ecs::relationship::RelatedSpawnerCommands;
use super::super::messages::ClosePopupMessage;
use crate::prelude::*;
/// Marker for popup root nodes.
#[derive(Component)]
pub struct PopupRoot;
/// Marker for the close button in a popup.
#[derive(Component)]
pub struct PopupCloseButton;
/// Spawns a generic popup window.
pub fn spawn_popup(
commands: &mut Commands,
root: impl Component,
title: impl Into<String>,
mut node: Node,
child: impl FnOnce(&mut RelatedSpawnerCommands<ChildOf>),
) {
node.flex_direction = FlexDirection::Column;
node.row_gap = px(10);
node.padding = UiRect::all(px(20));
commands
.spawn((
PopupRoot,
root,
Node {
position_type: PositionType::Absolute,
width: percent(100),
height: percent(100),
..Node::center()
},
ZIndex(100),
BackgroundColor(Color::srgba(0., 0., 0., 0.8)),
GlobalTransform::default(),
))
.with_children(|parent| {
parent
.spawn((
node,
BackgroundColor(Color::srgb(0.2, 0.2, 0.2)),
BorderRadius::all(px(10)),
))
.with_children(|parent| {
parent.spawn((
Node {
justify_content: JustifyContent::SpaceBetween,
align_items: AlignItems::Center,
..Node::hstack(px(20))
},
children![
text(title, 40.0, Color::WHITE),
button(
PopupCloseButton,
ButtonVariant::Destructive,
Node {
width: px(40),
height: px(40),
..Node::center()
},
|color| text("X", 24.0, color)
)
],
));
child(parent);
});
});
}
/// Spawns a context menu at a specific position.
pub fn spawn_context_menu(
commands: &mut Commands,
root: impl Component,
position: Vec2,
child: impl FnOnce(&mut RelatedSpawnerCommands<ChildOf>),
) {
commands
.spawn((
PopupRoot,
root,
Node {
position_type: PositionType::Absolute,
left: px(position.x),
top: px(position.y),
padding: UiRect::all(px(5)),
..Node::vstack(px(5))
},
ZIndex(100),
BackgroundColor(Color::srgb(0.1, 0.1, 0.1)),
BorderRadius::all(px(5)),
GlobalTransform::default(),
))
.with_children(child);
}
/// Handles closing popups via message or close button.
pub fn handle_popup_close(
mut commands: Commands,
root_query: Query<Entity, With<PopupRoot>>,
mut messages: MessageReader<ClosePopupMessage>,
buttons: Query<&Interaction, (Changed<Interaction>, With<PopupCloseButton>)>,
) {
for _ in messages.read() {
root_query
.iter()
.for_each(|root| commands.entity(root).despawn());
}
buttons.iter().for_each(|i| match i {
Interaction::Pressed => root_query
.iter()
.for_each(|root| commands.entity(root).despawn()),
_ => (),
});
}

View File

@@ -1,5 +1,6 @@
pub use crate::prelude::*; pub use crate::prelude::*;
/// Creates a basic text bundle.
pub fn text(content: impl Into<String>, size: f32, color: Color) -> (Text, TextFont, TextColor) { pub fn text(content: impl Into<String>, size: f32, color: Color) -> (Text, TextFont, TextColor) {
( (
Text::new(content), Text::new(content),
@@ -8,6 +9,7 @@ pub fn text(content: impl Into<String>, size: f32, color: Color) -> (Text, TextF
) )
} }
/// Creates a text bundle with an additional component attached.
pub fn text_with_component( pub fn text_with_component(
component: impl Component, component: impl Component,
content: impl Into<String>, content: impl Into<String>,

View File

@@ -1,6 +1,7 @@
use crate::prelude::*; use crate::prelude::*;
use bevy::window::PrimaryWindow; use bevy::window::PrimaryWindow;
/// Checks if the cursor is hovering over any UI element.
pub fn ui_blocks( pub fn ui_blocks(
window: Single<&Window, With<PrimaryWindow>>, window: Single<&Window, With<PrimaryWindow>>,
cursor_pos: Vec2, cursor_pos: Vec2,

View File

@@ -0,0 +1,39 @@
use crate::prelude::*;
/// Defines the bounds of the grid for the server.
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
pub struct MaxFieldSize {
#[serde(rename = "minX")]
pub min_x: u32,
#[serde(rename = "maxX")]
pub max_x: u32,
#[serde(rename = "minY")]
pub min_y: u32,
#[serde(rename = "maxY")]
pub max_y: u32,
}
/// Coordinates for a wonder event target.
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
pub struct Position {
pub x: u32,
pub y: u32,
}
/// WebSocket messages exchanged with the wonder event server.
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
#[serde(tag = "messageType")]
pub enum WonderEventMessage {
#[serde(rename = "WONDER_REQUEST")]
WonderRequest {
#[serde(rename = "maxFieldSize")]
max_field_size: MaxFieldSize,
},
#[serde(rename = "REQUEST_ERROR")]
RequestError { error: String },
#[serde(rename = "NO_WONDER")]
NoWonder,
#[serde(rename = "WONDER_GRANTED")]
WonderGranted { position: Position },
}

View File

@@ -0,0 +1,267 @@
use self::components::{MaxFieldSize, WonderEventMessage};
use crate::features::phase::components::{CurrentPhase, Phase, TimerSettings};
use crate::prelude::*;
use std::sync::Mutex;
use std::sync::mpsc::{Receiver, Sender, channel};
use std::thread;
use tungstenite::{Message as WsMessage, connect};
use url::Url;
pub mod components;
/// Plugin managing random wonder events via WebSocket.
pub struct WonderEventPlugin;
/// Message to initiate a wonder event request.
#[derive(Message)]
pub struct RequestWonderEvent {
pub url_str: String,
pub max_field_size: MaxFieldSize,
}
/// Resource holding the channel sender for wonder events.
#[derive(Resource)]
pub struct WonderEventSender(pub Sender<WonderEventMessage>);
/// Resource holding the channel receiver for wonder events.
#[derive(Resource)]
pub struct WonderEventReceiver(pub Mutex<Receiver<WonderEventMessage>>);
/// Component for animated floating text effects.
#[derive(Component)]
struct FloatingText {
timer: Timer,
velocity: Vec3,
}
impl Plugin for WonderEventPlugin {
fn build(&self, app: &mut App) {
let (tx, rx) = channel();
app.insert_resource(WonderEventSender(tx));
app.insert_resource(WonderEventReceiver(Mutex::new(rx)));
app.init_resource::<WonderEventState>();
app.add_message::<RequestWonderEvent>();
app.add_systems(
Update,
(
check_halfway_focus.run_if(in_state(AppState::GameScreen)),
handle_wonder_event_trigger,
handle_wonder_event_response.run_if(in_state(AppState::GameScreen)),
animate_floating_text,
),
);
}
}
/// Tracks if an event has already triggered this phase.
#[derive(Resource, Default)]
struct WonderEventState {
triggered_for_current_phase: bool,
}
/// Checks if the focus phase is halfway through to trigger an event.
fn check_halfway_focus(
mut state: ResMut<WonderEventState>,
phase_res: Res<CurrentPhase>,
settings: Res<TimerSettings>,
mut messages: MessageWriter<RequestWonderEvent>,
config: Res<GameConfig>,
grid: Res<Grid>,
) {
match &phase_res.0 {
Phase::Focus { duration } => {
let half_duration = settings.focus_duration as f32 / 2.0;
if *duration <= half_duration && !state.triggered_for_current_phase {
state.triggered_for_current_phase = true;
println!("WonderEvent: Halfway point reached! Requesting event...");
let max_field_size = MaxFieldSize {
min_x: 0,
max_x: if grid.width > 0 { grid.width - 1 } else { 0 },
min_y: 0,
max_y: if grid.height > 0 { grid.height - 1 } else { 0 },
};
messages.write(RequestWonderEvent {
url_str: config.wonder_event_url.clone(),
max_field_size,
});
}
}
Phase::Paused { previous_phase } => {
// If the paused phase was not Focus we can reset
if !matches!(**previous_phase, Phase::Focus { .. }) {
state.triggered_for_current_phase = false;
}
}
_ => {
// Break, Finished
state.triggered_for_current_phase = false;
}
}
}
/// Spawns a thread to request a wonder event from the server.
fn handle_wonder_event_trigger(
mut events: MessageReader<RequestWonderEvent>,
sender: Res<WonderEventSender>,
) {
for event in events.read() {
let url_str = event.url_str.clone();
let max_field_size = event.max_field_size.clone();
let tx = sender.0.clone();
thread::spawn(move || {
println!("WonderEvent: Connecting to {}", url_str);
match Url::parse(&url_str) {
Ok(url) => match connect(url.to_string()) {
Ok((mut socket, response)) => {
println!("WonderEvent: Connected to the server");
println!("Response HTTP code: {}", response.status());
let request = WonderEventMessage::WonderRequest { max_field_size };
if let Ok(json_msg) = serde_json::to_string(&request) {
let msg = WsMessage::Text(json_msg.into());
if let Err(e) = socket.send(msg) {
eprintln!("WonderEvent: Error sending message: {}", e);
} else {
println!("WonderEvent: Request sent successfully");
// Read response
if let Ok(msg) = socket.read() {
if let Ok(text) = msg.into_text() {
println!("WonderEvent: Received response: {}", text);
if let Ok(response) =
serde_json::from_str::<WonderEventMessage>(&text)
{
if let Err(e) = tx.send(response) {
eprintln!(
"WonderEvent: Failed to send response to main thread: {}",
e
);
}
} else {
eprintln!("WonderEvent: Failed to parse response JSON");
}
}
}
}
} else {
eprintln!("WonderEvent: Error serializing request");
}
let _ = socket.close(None);
}
Err(e) => {
eprintln!(
"WonderEvent: Could not connect (is the server running?): {}",
e
);
}
},
Err(e) => eprintln!("WonderEvent: Invalid URL: {}", e),
}
});
}
}
/// Processes responses from the wonder event server.
fn handle_wonder_event_response(
receiver: Res<WonderEventReceiver>,
grid: Res<Grid>,
mut tile_query: Query<&mut TileState>,
game_config: Res<GameConfig>,
mut commands: Commands,
mut notifications: ResMut<Notifications>,
) {
if let Ok(rx) = receiver.0.try_lock() {
while let Ok(msg) = rx.try_recv() {
match msg {
WonderEventMessage::WonderGranted { position } => {
println!("WonderEvent: GRANTED at ({}, {})", position.x, position.y);
// Update Tile State
if let Ok(_) = grid.map_tile_state(
(position.x, position.y),
|state| {
if let TileState::Occupied {
seed,
watered,
growth_stage,
withered,
dry_counter,
} = state
{
// Progress growth stage
let mut new_stage = *growth_stage;
if let Some(config) = seed.get_seed_config(&game_config) {
if new_stage < config.growth_stages {
new_stage += 1;
}
}
notifications.info(Some("Wunder Ereignis"), "Es ist ein Wunder passiert! Eine deiner Pflanzen ist auf magischer Weise gewachsen.");
TileState::Occupied {
seed: seed.clone(),
watered: *watered,
growth_stage: new_stage,
withered: *withered,
dry_counter: *dry_counter,
}
} else {
state.clone()
}
},
tile_query.reborrow(),
) {
let world_pos = grid_to_world_coords(
position.x,
position.y,
None,
grid.width,
grid.height,
);
commands.spawn((
Text2d::new("Wunder!"),
TextFont {
font_size: 20.0,
..Default::default()
},
TextColor(Color::srgb(1.0, 0.8, 0.2)),
Transform::from_translation(world_pos + Vec3::new(0.0, 20.0, 10.0)),
FloatingText {
timer: Timer::from_seconds(1.5, TimerMode::Once),
velocity: Vec3::new(0.0, 20.0, 0.0),
},
));
}
}
WonderEventMessage::RequestError { error } => {
println!("WonderEvent: REQUEST ERROR: {}", error);
}
WonderEventMessage::NoWonder => {
println!("WonderEvent: NO WONDER");
}
_ => {}
}
}
}
}
/// Updates floating text positions and lifetimes.
fn animate_floating_text(
mut commands: Commands,
time: Res<Time>,
mut query: Query<(Entity, &mut FloatingText, &mut Transform)>,
) {
for (entity, mut float, mut transform) in query.iter_mut() {
float.timer.tick(time.delta());
transform.translation += float.velocity * time.delta_secs();
if float.timer.is_finished() {
commands.entity(entity).despawn();
}
}
}

View File

@@ -1,15 +1,19 @@
use bevy_dev_tools::fps_overlay::*;
use pomomon_garden::prelude::*; use pomomon_garden::prelude::*;
fn main() { fn main() {
let config = GameConfig::read_config().unwrap_or(GameConfig::default()); let config = GameConfig::read_config().unwrap_or(GameConfig::default());
App::new() let mut app = App::new();
.add_plugins((
app.add_plugins((
DefaultPlugins.set(ImagePlugin::default_nearest()), DefaultPlugins.set(ImagePlugin::default_nearest()),
AsepriteUltraPlugin, AsepriteUltraPlugin,
)) ));
.add_plugins((FpsOverlayPlugin {
#[cfg(debug_assertions)]
{
use bevy_dev_tools::fps_overlay::*;
app.add_plugins(FpsOverlayPlugin {
config: FpsOverlayConfig { config: FpsOverlayConfig {
refresh_interval: core::time::Duration::from_millis(100), refresh_interval: core::time::Duration::from_millis(100),
enabled: true, enabled: true,
@@ -20,8 +24,10 @@ fn main() {
}, },
..default() ..default()
}, },
},)) });
.add_plugins(( }
app.add_plugins((
features::CorePlugin, features::CorePlugin,
features::StartScreenPlugin, features::StartScreenPlugin,
features::GameScreenPlugin, features::GameScreenPlugin,
@@ -34,10 +40,13 @@ fn main() {
features::UiPlugin, features::UiPlugin,
features::InventoryPlugin, features::InventoryPlugin,
features::ShopPlugin, features::ShopPlugin,
)) features::WonderEventPlugin,
.insert_resource(config) features::NotificationPlugin,
.add_systems(Startup, overwrite_default_font) features::AchievementPlugin,
.run(); ));
app.insert_resource(config);
app.add_systems(Startup, overwrite_default_font);
app.run();
} }
fn overwrite_default_font(mut fonts: ResMut<Assets<Font>>) { fn overwrite_default_font(mut fonts: ResMut<Assets<Font>>) {

View File

@@ -8,6 +8,7 @@ pub use crate::features::{
utils::{grid_to_world_coords, world_to_grid_coords}, utils::{grid_to_world_coords, world_to_grid_coords},
}, },
inventory::components::{Inventory, ItemStack, ItemType}, inventory::components::{Inventory, ItemStack, ItemType},
notification::components::Notifications,
phase::components::{CurrentPhase, Phase}, phase::components::{CurrentPhase, Phase},
pom::{ pom::{
components::{GridPosition, MovingState, Pom}, components::{GridPosition, MovingState, Pom},

View File

@@ -1,6 +1,7 @@
use directories::ProjectDirs; use directories::ProjectDirs;
use std::path::PathBuf; use std::path::PathBuf;
/// Returns the platform-specific data directory for the application.
pub fn get_internal_path() -> Option<PathBuf> { pub fn get_internal_path() -> Option<PathBuf> {
let project_dirs = ProjectDirs::from("de", "demenik", "pomomon-garden"); let project_dirs = ProjectDirs::from("de", "demenik", "pomomon-garden");

86
tests/achievement.rs Normal file
View File

@@ -0,0 +1,86 @@
use pomomon_garden::features::achievement::AchievementPlugin;
use pomomon_garden::features::achievement::components::{AchievementId, AchievementProgress};
use pomomon_garden::features::core::states::AppState;
use pomomon_garden::features::notification::components::Notifications;
use pomomon_garden::features::phase::components::SessionTracker;
use pomomon_garden::prelude::*;
use strum::IntoEnumIterator;
#[test]
fn test_achievement_unlock_logic() {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.add_plugins(bevy::state::app::StatesPlugin);
app.add_plugins(AchievementPlugin);
app.init_state::<AppState>();
app.insert_resource(Notifications::default());
app.insert_resource(SessionTracker::default());
{
let mut next_state = app.world_mut().resource_mut::<NextState<AppState>>();
next_state.set(AppState::GameScreen);
}
app.update();
// Helper to run systems and get progress
let mut run_check = |completed_focus: u32, total_berries: u32, total_withered: u32| {
app.insert_resource(SessionTracker {
completed_focus_phases: completed_focus,
total_berries_earned: total_berries,
total_plants_withered: total_withered,
});
app.update(); // Runs check_achievements
app.world().resource::<AchievementProgress>().clone()
};
// --- Initial State ---
let progress = run_check(0, 0, 0);
for id in AchievementId::iter() {
assert!(
!progress.is_unlocked(&id),
"Achievement {:?} should not be unlocked initially",
id
);
}
// --- First Steps (1 berry) ---
let progress = run_check(0, 1, 0);
assert!(progress.is_unlocked(&AchievementId::FirstSteps));
// --- Getting Started (1 focus phase) ---
let progress = run_check(1, 1, 0);
assert!(progress.is_unlocked(&AchievementId::GettingStarted));
// --- Negligent (1 withered plant) ---
let progress = run_check(1, 1, 1);
assert!(progress.is_unlocked(&AchievementId::Negligent));
// --- Focus Master (10 focus phases) ---
let progress = run_check(10, 1, 1);
assert!(progress.is_unlocked(&AchievementId::FocusMaster));
// --- Master Farmer (100 berries) ---
let progress = run_check(10, 100, 1);
assert!(progress.is_unlocked(&AchievementId::MasterFarmer));
// --- Zen Master (50 focus phases) ---
let progress = run_check(50, 100, 1);
assert!(progress.is_unlocked(&AchievementId::ZenMaster));
// --- Compost King (10 withered plants) ---
let progress = run_check(50, 100, 10);
assert!(progress.is_unlocked(&AchievementId::CompostKing));
// --- Berry Tycoon (1000 berries) ---
let progress = run_check(50, 1000, 10);
assert!(progress.is_unlocked(&AchievementId::BerryTycoon));
// --- Test idempotency: already unlocked should stay unlocked ---
let initial_progress = progress.clone();
let progress = run_check(50, 1000, 10);
assert_eq!(progress.unlocked.len(), initial_progress.unlocked.len()); // Same number unlocked
for id in AchievementId::iter() {
assert_eq!(progress.is_unlocked(&id), initial_progress.is_unlocked(&id));
}
}

View File

@@ -2,6 +2,7 @@ use bevy::prelude::*;
use pomomon_garden::features::config::components::{BerrySeedConfig, GameConfig}; use pomomon_garden::features::config::components::{BerrySeedConfig, GameConfig};
use pomomon_garden::features::grid::components::{Grid, Tile, TileState}; use pomomon_garden::features::grid::components::{Grid, Tile, TileState};
use pomomon_garden::features::inventory::components::{Inventory, ItemStack, ItemType}; use pomomon_garden::features::inventory::components::{Inventory, ItemStack, ItemType};
use pomomon_garden::features::phase::components::SessionTracker;
pub fn setup_app( pub fn setup_app(
grid_width: u32, grid_width: u32,
@@ -65,5 +66,7 @@ pub fn setup_app(
..Default::default() ..Default::default()
}); });
app.init_resource::<SessionTracker>();
app app
} }

View File

@@ -3,7 +3,7 @@ use std::fs;
use std::io::Write; use std::io::Write;
use uuid::Uuid; use uuid::Uuid;
// Helper function to create a temporary file with content /// Helper function to create a temporary file with content
fn create_temp_file(content: &str) -> (std::path::PathBuf, String) { fn create_temp_file(content: &str) -> (std::path::PathBuf, String) {
let filename = format!("test_config_{}.json", Uuid::new_v4()); let filename = format!("test_config_{}.json", Uuid::new_v4());
let temp_dir = std::env::temp_dir(); let temp_dir = std::env::temp_dir();
@@ -25,7 +25,9 @@ fn test_load_valid_config() {
"pom_speed": 2.0, "pom_speed": 2.0,
"shovel_base_price": 10, "shovel_base_price": 10,
"shovel_rate": 0.2, "shovel_rate": 0.2,
"berry_seeds": [] "berry_seeds": [],
"wonder_event_url": "wss://pomomon.farm/ws",
"berries_per_focus_minute": 1
}"#, }"#,
); );

116
tests/expansion.rs Normal file
View File

@@ -0,0 +1,116 @@
use bevy::ecs::system::RunSystemOnce;
use pomomon_garden::features::config::components::GameConfig;
use pomomon_garden::features::grid::components::{Grid, TileState};
use pomomon_garden::features::inventory::components::{Inventory, ItemStack, ItemType};
use pomomon_garden::prelude::*;
mod common;
use common::setup_app;
#[test]
fn test_shovel_expansion() {
let mut app = setup_app(
5,
5,
&[(2, 2, TileState::Empty)], // (2,2) is Empty, making it a "claimed" neighbor
vec![(ItemType::Shovel, 1)], // One shovel in inventory
None,
);
let target_pos = (2, 3); // Unclaimed tile next to (2,2), not on edge of 5x5 grid
let _ = app.world_mut().run_system_once(
move |
grid: Res<Grid>,
mut tile_states: Query<&mut TileState>,
mut inventory: ResMut<Inventory>,
mut item_stack_query: Query<&mut ItemStack>,
mut commands: Commands,
_game_config: Res<GameConfig> // Not directly used but required by signature
| {
let (x, y) = target_pos;
let tile_entity = match grid.get_tile((x, y)) {
Ok(entity) => entity,
Err(_) => panic!("Clicked outside grid at {:?}", (x,y)),
};
// Mimic the edge check from interact_click
if x == 0 || x == grid.width - 1 || y == 0 || y == grid.height - 1 {
panic!("Should not return early due to edge check for {:?} in a 5x5 grid", target_pos);
}
let neighbors = [
(x + 1, y),
(x.saturating_sub(1), y),
(x, y + 1),
(x, y.saturating_sub(1)),
];
let mut has_claimed_neighbor = false;
for (nx, ny) in neighbors.iter() {
if *nx < grid.width && *ny < grid.height {
if let Ok(neighbor_entity) = grid.get_tile((*nx, *ny)) {
if let Ok(neighbor_state) = tile_states.get(neighbor_entity) {
if !matches!(*neighbor_state, TileState::Unclaimed) {
has_claimed_neighbor = true;
break;
}
}
}
}
}
assert!(has_claimed_neighbor, "Target tile {:?} should have a claimed neighbor", target_pos);
let mut tile_state = match tile_states.get_mut(tile_entity) {
Ok(state) => state,
Err(_) => panic!("Failed to get mutable tile state for {:?}", target_pos),
};
assert!(matches!(*tile_state, TileState::Unclaimed), "Target tile {:?} should be Unclaimed initially, but was {:?}", target_pos, tile_state);
// Execute the expansion
if inventory.update_item_stack(
&mut commands,
&mut item_stack_query,
ItemType::Shovel,
-1,
) {
*tile_state = TileState::Empty;
} else {
panic!("Shovel not consumed or not found in inventory");
}
},
);
app.update(); // Apply commands
// Assert Tile State
let grid = app.world().resource::<Grid>();
let tile_entity = grid.get_tile(target_pos).unwrap();
let tile_state = app.world().entity(tile_entity).get::<TileState>().unwrap();
assert!(
matches!(*tile_state, TileState::Empty),
"Tile (1,2) should be Empty after expansion, but was {:?}",
tile_state
);
// Assert Inventory
let inventory = app.world().resource::<Inventory>();
let shovel_stack = inventory.items.iter().find_map(|&entity| {
let stack = app.world().entity(entity).get::<ItemStack>()?;
if stack.item_type == ItemType::Shovel {
Some(stack)
} else {
None
}
});
assert!(
shovel_stack.is_none() || shovel_stack.unwrap().amount == 0,
"Shovel should have been consumed, found {:?}",
shovel_stack
);
}

View File

@@ -2,6 +2,7 @@ use bevy::ecs::system::RunSystemOnce;
use pomomon_garden::features::config::components::{BerrySeedConfig, GameConfig}; use pomomon_garden::features::config::components::{BerrySeedConfig, GameConfig};
use pomomon_garden::features::grid::components::{Grid, TileState}; use pomomon_garden::features::grid::components::{Grid, TileState};
use pomomon_garden::features::inventory::components::{Inventory, ItemStack, ItemType}; use pomomon_garden::features::inventory::components::{Inventory, ItemStack, ItemType};
use pomomon_garden::features::phase::components::SessionTracker;
use pomomon_garden::features::pom::actions::InteractionAction; use pomomon_garden::features::pom::actions::InteractionAction;
use pomomon_garden::prelude::*; use pomomon_garden::prelude::*;
@@ -41,7 +42,8 @@ fn test_harvest_fully_grown() {
mut tile_query: Query<&mut TileState>, mut tile_query: Query<&mut TileState>,
mut inventory: ResMut<Inventory>, mut inventory: ResMut<Inventory>,
mut item_stack_query: Query<&mut ItemStack>, mut item_stack_query: Query<&mut ItemStack>,
config: Res<GameConfig>| { config: Res<GameConfig>,
mut session_tracker: ResMut<SessionTracker>| {
InteractionAction::Harvest.execute( InteractionAction::Harvest.execute(
(0, 0), (0, 0),
&grid, &grid,
@@ -50,6 +52,7 @@ fn test_harvest_fully_grown() {
&mut item_stack_query, &mut item_stack_query,
&mut commands, &mut commands,
&config, &config,
&mut session_tracker,
); );
}, },
); );
@@ -70,6 +73,10 @@ fn test_harvest_fully_grown() {
let stack = app.world().entity(stack_entity).get::<ItemStack>().unwrap(); let stack = app.world().entity(stack_entity).get::<ItemStack>().unwrap();
assert_eq!(stack.item_type, ItemType::Berry); assert_eq!(stack.item_type, ItemType::Berry);
assert_eq!(stack.amount, 5); assert_eq!(stack.amount, 5);
// Check Session Tracker
let tracker = app.world().resource::<SessionTracker>();
assert_eq!(tracker.total_berries_earned, 5);
} }
#[test] #[test]
@@ -105,7 +112,8 @@ fn test_harvest_withered() {
mut tile_query: Query<&mut TileState>, mut tile_query: Query<&mut TileState>,
mut inventory: ResMut<Inventory>, mut inventory: ResMut<Inventory>,
mut item_stack_query: Query<&mut ItemStack>, mut item_stack_query: Query<&mut ItemStack>,
config: Res<GameConfig>| { config: Res<GameConfig>,
mut session_tracker: ResMut<SessionTracker>| {
InteractionAction::Harvest.execute( InteractionAction::Harvest.execute(
(0, 0), (0, 0),
&grid, &grid,
@@ -114,6 +122,7 @@ fn test_harvest_withered() {
&mut item_stack_query, &mut item_stack_query,
&mut commands, &mut commands,
&config, &config,
&mut session_tracker,
); );
}, },
); );
@@ -164,7 +173,8 @@ fn test_cannot_harvest_growing() {
mut tile_query: Query<&mut TileState>, mut tile_query: Query<&mut TileState>,
mut inventory: ResMut<Inventory>, mut inventory: ResMut<Inventory>,
mut item_stack_query: Query<&mut ItemStack>, mut item_stack_query: Query<&mut ItemStack>,
config: Res<GameConfig>| { config: Res<GameConfig>,
mut session_tracker: ResMut<SessionTracker>| {
InteractionAction::Harvest.execute( InteractionAction::Harvest.execute(
(0, 0), (0, 0),
&grid, &grid,
@@ -173,6 +183,7 @@ fn test_cannot_harvest_growing() {
&mut item_stack_query, &mut item_stack_query,
&mut commands, &mut commands,
&config, &config,
&mut session_tracker,
); );
}, },
); );

View File

@@ -4,7 +4,7 @@ use pomomon_garden::features::pom::utils::find_path;
use pomomon_garden::prelude::*; use pomomon_garden::prelude::*;
use std::collections::VecDeque; use std::collections::VecDeque;
// Helper to set up a Bevy App for pathfinding tests /// Helper to set up a Bevy App for pathfinding tests
fn setup_pathfinding_app( fn setup_pathfinding_app(
grid_width: u32, grid_width: u32,
grid_height: u32, grid_height: u32,

View File

@@ -2,6 +2,7 @@ use bevy::ecs::system::RunSystemOnce;
use pomomon_garden::features::config::components::GameConfig; use pomomon_garden::features::config::components::GameConfig;
use pomomon_garden::features::grid::components::{Grid, TileState}; use pomomon_garden::features::grid::components::{Grid, TileState};
use pomomon_garden::features::inventory::components::{Inventory, ItemStack, ItemType}; use pomomon_garden::features::inventory::components::{Inventory, ItemStack, ItemType};
use pomomon_garden::features::phase::components::SessionTracker;
use pomomon_garden::features::pom::actions::InteractionAction; use pomomon_garden::features::pom::actions::InteractionAction;
use pomomon_garden::prelude::*; use pomomon_garden::prelude::*;
@@ -27,7 +28,8 @@ fn test_plant_seed_interaction() {
mut inventory: ResMut<Inventory>, mut inventory: ResMut<Inventory>,
mut item_stack_query: Query<&mut ItemStack>, mut item_stack_query: Query<&mut ItemStack>,
mut commands: Commands, mut commands: Commands,
game_config: Res<GameConfig>| { game_config: Res<GameConfig>,
mut session_tracker: ResMut<SessionTracker>| {
let action = InteractionAction::Plant(seed_type.clone()); let action = InteractionAction::Plant(seed_type.clone());
action.execute( action.execute(
(1, 1), (1, 1),
@@ -37,6 +39,7 @@ fn test_plant_seed_interaction() {
&mut item_stack_query, &mut item_stack_query,
&mut commands, &mut commands,
&game_config, &game_config,
&mut session_tracker,
); );
}, },
); );
@@ -98,7 +101,8 @@ fn test_plant_seed_no_inventory() {
mut inventory: ResMut<Inventory>, mut inventory: ResMut<Inventory>,
mut item_stack_query: Query<&mut ItemStack>, mut item_stack_query: Query<&mut ItemStack>,
mut commands: Commands, mut commands: Commands,
game_config: Res<GameConfig>| { game_config: Res<GameConfig>,
mut session_tracker: ResMut<SessionTracker>| {
let action = InteractionAction::Plant(seed_type.clone()); let action = InteractionAction::Plant(seed_type.clone());
action.execute( action.execute(
(1, 1), (1, 1),
@@ -108,6 +112,7 @@ fn test_plant_seed_no_inventory() {
&mut item_stack_query, &mut item_stack_query,
&mut commands, &mut commands,
&game_config, &game_config,
&mut session_tracker,
); );
}, },
); );

View File

@@ -11,9 +11,14 @@ fn test_session_tracker_focus_to_short_break() {
}), }),
}); });
let timer_settings = TimerSettings::default(); let timer_settings = TimerSettings::default();
let mut session_tracker = SessionTracker::default(); // Simulate that grant_focus_rewards has already incremented the counter
let session_tracker = SessionTracker {
completed_focus_phases: 1,
total_berries_earned: 0,
total_plants_withered: 0,
};
next_phase(&mut current_phase, &mut session_tracker, &timer_settings); next_phase(&mut current_phase, &session_tracker, &timer_settings);
assert_eq!( assert_eq!(
session_tracker.completed_focus_phases, 1, session_tracker.completed_focus_phases, 1,
@@ -37,15 +42,18 @@ fn test_session_tracker_focus_to_long_break() {
}), }),
}); });
let timer_settings = TimerSettings::default(); let timer_settings = TimerSettings::default();
let mut session_tracker = SessionTracker { // Simulate that grant_focus_rewards has already incremented the counter to the interval
completed_focus_phases: timer_settings.long_break_interval - 1, let session_tracker = SessionTracker {
}; // To trigger long break on next phase completed_focus_phases: timer_settings.long_break_interval,
total_berries_earned: 0,
total_plants_withered: 0,
};
next_phase(&mut current_phase, &mut session_tracker, &timer_settings); next_phase(&mut current_phase, &session_tracker, &timer_settings);
assert_eq!( assert_eq!(
session_tracker.completed_focus_phases, timer_settings.long_break_interval, session_tracker.completed_focus_phases, timer_settings.long_break_interval,
"Completed focus phases should reach long break interval" "Completed focus phases should remain at long break interval"
); );
if let Phase::Break { duration } = current_phase.0 { if let Phase::Break { duration } = current_phase.0 {
assert_eq!( assert_eq!(
@@ -64,12 +72,14 @@ fn test_session_tracker_break_to_focus() {
duration: 5.0 * 60.0, duration: 5.0 * 60.0,
}), }),
}); });
let mut session_tracker = SessionTracker { let session_tracker = SessionTracker {
completed_focus_phases: 1, completed_focus_phases: 1,
total_berries_earned: 0,
total_plants_withered: 0,
}; // Arbitrary value, should not change }; // Arbitrary value, should not change
let timer_settings = TimerSettings::default(); let timer_settings = TimerSettings::default();
next_phase(&mut current_phase, &mut session_tracker, &timer_settings); next_phase(&mut current_phase, &session_tracker, &timer_settings);
assert_eq!( assert_eq!(
session_tracker.completed_focus_phases, 1, session_tracker.completed_focus_phases, 1,
@@ -89,15 +99,17 @@ fn test_session_tracker_break_to_focus() {
fn test_session_tracker_not_finished_phase_no_change() { fn test_session_tracker_not_finished_phase_no_change() {
// Test that nothing changes if the phase is not `Finished` // Test that nothing changes if the phase is not `Finished`
let mut current_phase = CurrentPhase(Phase::Focus { duration: 100.0 }); let mut current_phase = CurrentPhase(Phase::Focus { duration: 100.0 });
let mut session_tracker = SessionTracker { let session_tracker = SessionTracker {
completed_focus_phases: 0, completed_focus_phases: 0,
total_berries_earned: 0,
total_plants_withered: 0,
}; };
let timer_settings = TimerSettings::default(); let timer_settings = TimerSettings::default();
let initial_phase = current_phase.0.clone(); let initial_phase = current_phase.0.clone();
let initial_completed_focus = session_tracker.completed_focus_phases; let initial_completed_focus = session_tracker.completed_focus_phases;
next_phase(&mut current_phase, &mut session_tracker, &timer_settings); next_phase(&mut current_phase, &session_tracker, &timer_settings);
assert_eq!( assert_eq!(
current_phase.0, initial_phase, current_phase.0, initial_phase,

109
tests/trapped.rs Normal file
View File

@@ -0,0 +1,109 @@
use bevy::ecs::system::RunSystemOnce;
use pomomon_garden::features::grid::components::{Grid, TileState};
use pomomon_garden::features::inventory::components::ItemType;
use pomomon_garden::features::notification::components::Notifications;
use pomomon_garden::features::pom::actions::InteractionAction;
use pomomon_garden::features::pom::components::{GridPosition, InteractionTarget, PathQueue, Pom};
use pomomon_garden::features::pom::perform_interaction;
use pomomon_garden::prelude::*;
mod common;
use common::setup_app;
#[test]
fn test_prevent_trapping_plant() {
let occupied = TileState::Occupied {
seed: ItemType::BerrySeed {
name: "Wall".into(),
},
watered: false,
growth_stage: 0,
withered: false,
dry_counter: 0,
};
let mut app = setup_app(
2,
2,
&[
(0, 0, TileState::Empty), // Pom
(1, 0, TileState::Empty), // Target
(0, 1, occupied.clone()),
(1, 1, occupied.clone()),
],
vec![(
ItemType::BerrySeed {
name: "Test".into(),
},
10,
)],
None,
);
app.init_resource::<Notifications>();
app.world_mut().spawn((
Pom,
GridPosition { x: 0, y: 0 },
InteractionTarget {
target: Some((1, 0)),
action: Some(InteractionAction::Plant(ItemType::BerrySeed {
name: "Test".into(),
})),
},
PathQueue::default(),
));
let _ = app.world_mut().run_system_once(perform_interaction);
let grid = app.world().resource::<Grid>();
let tile_entity = grid.get_tile((1, 0)).unwrap();
let tile_state = app.world().entity(tile_entity).get::<TileState>().unwrap();
// Should remain Empty because planting was blocked
match tile_state {
TileState::Empty => (),
_ => panic!("Should have prevented planting! State is {:?}", tile_state),
}
}
#[test]
fn test_allow_safe_plant() {
let mut app = setup_app(
10,
10,
&[(0, 0, TileState::Empty), (1, 0, TileState::Empty)],
vec![(
ItemType::BerrySeed {
name: "Test".into(),
},
10,
)],
None,
);
app.init_resource::<Notifications>();
app.world_mut().spawn((
Pom,
GridPosition { x: 0, y: 0 },
InteractionTarget {
target: Some((1, 0)),
action: Some(InteractionAction::Plant(ItemType::BerrySeed {
name: "Test".into(),
})),
},
PathQueue::default(),
));
let _ = app.world_mut().run_system_once(perform_interaction);
let grid = app.world().resource::<Grid>();
let tile_entity = grid.get_tile((1, 0)).unwrap();
let tile_state = app.world().entity(tile_entity).get::<TileState>().unwrap();
// Should be Occupied
match tile_state {
TileState::Occupied { .. } => (),
_ => panic!("Should have allowed planting! State is {:?}", tile_state),
}
}

View File

@@ -2,6 +2,7 @@ use bevy::ecs::system::RunSystemOnce;
use pomomon_garden::features::config::components::GameConfig; use pomomon_garden::features::config::components::GameConfig;
use pomomon_garden::features::grid::components::{Grid, TileState}; use pomomon_garden::features::grid::components::{Grid, TileState};
use pomomon_garden::features::inventory::components::{Inventory, ItemStack, ItemType}; use pomomon_garden::features::inventory::components::{Inventory, ItemStack, ItemType};
use pomomon_garden::features::phase::components::SessionTracker;
use pomomon_garden::features::pom::actions::InteractionAction; use pomomon_garden::features::pom::actions::InteractionAction;
use pomomon_garden::prelude::*; use pomomon_garden::prelude::*;
@@ -57,7 +58,8 @@ fn test_water_crop() {
mut inventory: ResMut<Inventory>, mut inventory: ResMut<Inventory>,
mut item_stack_query: Query<&mut ItemStack>, mut item_stack_query: Query<&mut ItemStack>,
mut commands: Commands, mut commands: Commands,
game_config: Res<GameConfig>| { game_config: Res<GameConfig>,
mut session_tracker: ResMut<SessionTracker>| {
let action = InteractionAction::Water; let action = InteractionAction::Water;
action.execute( action.execute(
(1, 1), (1, 1),
@@ -67,6 +69,7 @@ fn test_water_crop() {
&mut item_stack_query, &mut item_stack_query,
&mut commands, &mut commands,
&game_config, &game_config,
&mut session_tracker,
); );
}, },
); );

176
tests/wonderevent.rs Normal file
View File

@@ -0,0 +1,176 @@
use bevy::prelude::*;
use pomomon_garden::features::config::components::{BerrySeedConfig, GameConfig};
use pomomon_garden::features::core::states::AppState;
use pomomon_garden::features::grid::components::{Grid, Tile, TileState};
use pomomon_garden::features::inventory::components::ItemType;
use pomomon_garden::features::notification::components::Notifications;
use pomomon_garden::features::phase::components::{CurrentPhase, Phase, TimerSettings};
use pomomon_garden::features::wonderevent::components::{Position, WonderEventMessage};
use pomomon_garden::features::wonderevent::{
RequestWonderEvent, WonderEventPlugin, WonderEventSender,
};
#[test]
fn test_wonder_event_triggers() {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.add_plugins(bevy::state::app::StatesPlugin);
app.add_plugins(WonderEventPlugin);
app.insert_resource(Notifications::default());
app.insert_resource(GameConfig::default());
app.insert_resource(TimerSettings {
focus_duration: 60, // 60 seconds
short_break_duration: 5,
long_break_duration: 15,
long_break_interval: 4,
});
app.insert_resource(Grid {
width: 10,
height: 10,
tiles: vec![],
});
app.init_state::<AppState>();
let mut next_state = app.world_mut().resource_mut::<NextState<AppState>>();
next_state.set(AppState::GameScreen);
app.insert_resource(CurrentPhase(Phase::Focus { duration: 60.0 }));
// 1. Full Duration (60.0). No trigger.
app.update();
verify_event_count(&mut app, 0);
// 2. Half Duration (30.0). Trigger!
*app.world_mut().resource_mut::<CurrentPhase>() = CurrentPhase(Phase::Focus { duration: 30.0 });
app.update();
verify_event_count(&mut app, 1);
// 3. Less than half (29.0). Already triggered. No new trigger.
*app.world_mut().resource_mut::<CurrentPhase>() = CurrentPhase(Phase::Focus { duration: 29.0 });
app.update();
verify_event_count(&mut app, 0); // New events should be 0
// 4. Pause (Focus). Logic should NOT reset trigger.
let prev = Box::new(Phase::Focus { duration: 29.0 });
*app.world_mut().resource_mut::<CurrentPhase>() = CurrentPhase(Phase::Paused {
previous_phase: prev,
});
app.update();
verify_event_count(&mut app, 0);
// 5. Resume Focus. Logic should remember it was triggered.
*app.world_mut().resource_mut::<CurrentPhase>() = CurrentPhase(Phase::Focus { duration: 29.0 });
app.update();
verify_event_count(&mut app, 0);
// 6. Break. Logic should reset trigger.
*app.world_mut().resource_mut::<CurrentPhase>() = CurrentPhase(Phase::Break { duration: 5.0 });
app.update();
verify_event_count(&mut app, 0);
// 7. New Focus (Half Duration). Logic should trigger again.
*app.world_mut().resource_mut::<CurrentPhase>() = CurrentPhase(Phase::Focus { duration: 30.0 });
app.update();
verify_event_count(&mut app, 1);
}
fn verify_event_count(app: &mut App, expected: usize) {
let count = app
.world_mut()
.resource_mut::<Messages<RequestWonderEvent>>()
.drain()
.count();
assert_eq!(
count, expected,
"Expected {} events, found {}",
expected, count
);
}
#[test]
fn test_wonder_response_handling() {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.add_plugins(bevy::state::app::StatesPlugin);
app.add_plugins(WonderEventPlugin);
app.insert_resource(Notifications::default());
app.insert_resource(GameConfig {
berry_seeds: vec![BerrySeedConfig {
name: "TestSeed".to_string(),
cost: 1,
grants: 1,
slice: "Seed1".to_string(),
growth_stages: 5,
}],
..Default::default()
});
app.insert_resource(CurrentPhase(Phase::Focus { duration: 60.0 }));
// Setup Grid with an occupied tile
let mut tiles = Vec::new();
let column = vec![
app.world_mut()
.spawn((
Tile { x: 0, y: 0 },
TileState::Occupied {
seed: ItemType::BerrySeed {
name: "TestSeed".to_string(),
},
watered: true,
growth_stage: 1,
withered: false,
dry_counter: 0,
},
))
.id(),
];
tiles.push(column);
app.insert_resource(Grid {
width: 1,
height: 1,
tiles,
});
app.insert_resource(TimerSettings::default());
// Init State
app.init_state::<AppState>();
let mut next_state = app.world_mut().resource_mut::<NextState<AppState>>();
next_state.set(AppState::GameScreen);
// Run update to enter state
app.update();
// Send WonderEventMessage directly to the channel
let sender = app.world().resource::<WonderEventSender>().0.clone();
sender
.send(WonderEventMessage::WonderGranted {
position: Position { x: 0, y: 0 },
})
.expect("Failed to send WonderEventMessage");
// Run update to process message
app.update();
// Verify Tile State (growth should increase from 1 to 2)
let grid = app.world().resource::<Grid>();
let tile_entity = grid.get_tile((0, 0)).unwrap();
let tile_state = app.world().get::<TileState>(tile_entity).unwrap();
if let TileState::Occupied { growth_stage, .. } = tile_state {
assert_eq!(*growth_stage, 2, "Growth stage should increase to 2");
} else {
panic!("Tile state should be Occupied");
}
// Verify Floating Text Spawned
let mut text_query = app.world_mut().query::<&Text2d>();
assert_eq!(
text_query.iter(app.world()).count(),
1,
"Should spawn one floating text"
);
}