diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..e9af743 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,27 @@ +name: Build TDK Pinball + +on: + push: + pull_request: + +jobs: + build: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + defaults: + run: + working-directory: tdkpin-rs + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + - name: Install Linux development libraries + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y libasound2-dev libgl1-mesa-dev libxi-dev + - run: cargo check --all-targets + - run: cargo test --all-targets + - run: cargo clippy --all-targets -- -D warnings diff --git a/tdkpin-rs/.gitattributes b/tdkpin-rs/.gitattributes new file mode 100644 index 0000000..40c30f6 --- /dev/null +++ b/tdkpin-rs/.gitattributes @@ -0,0 +1 @@ +assets/original/TDKPIN.INI binary diff --git a/tdkpin-rs/Cargo.lock b/tdkpin-rs/Cargo.lock index 2606752..ab8815c 100644 --- a/tdkpin-rs/Cargo.lock +++ b/tdkpin-rs/Cargo.lock @@ -2,6 +2,584 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "audir-sles" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea47348666a8edb7ad80cbee3940eb2bccf70df0e6ce09009abe1a836cb779f5" + +[[package]] +name = "audrey" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58b92a84e89497e3cd25d3672cd5d1c288abaac02c18ff21283f17d118b889b8" +dependencies = [ + "dasp_frame", + "dasp_sample", + "hound", + "lewton", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "dasp_frame" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a3937f5fe2135702897535c8d4a5553f8b116f76c1529088797f2eee7c5cd6" +dependencies = [ + "dasp_sample", +] + +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "fontdue" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7894823fa221401399e2598f8b63f81ac77ff5c63248b7656779bff1632d7d3d" +dependencies = [ + "hashbrown", + "ttf-parser", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "glam" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e05e7e6723e3455f4818c7b26e855439f7546cf617ef669d1adedb8669e5cb9" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hound" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" + +[[package]] +name = "image" +version = "0.24.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d" +dependencies = [ + "bytemuck", + "byteorder", + "color_quant", + "num-traits", + "png", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "lewton" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d542c1a317036c45c2aa1cf10cc9d403ca91eb2d333ef1a4917e5cb10628bd0" +dependencies = [ + "byteorder", + "ogg", + "smallvec", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" +dependencies = [ + "libc", +] + +[[package]] +name = "macroquad" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cbd54d99d4d3ac59deed7ee4927bbbf4eb33a55635530af3be9395a55746090" +dependencies = [ + "fontdue", + "glam", + "image", + "macroquad_macro", + "miniquad", + "quad-rand", + "quad-snd", +] + +[[package]] +name = "macroquad_macro" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64b1d96218903768c1ce078b657c0d5965465c95a60d2682fd97443c9d2483dd" + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "maybe-uninit" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60302e4db3a61da70c0cb7991976248362f30319e88850c487b9b95bbf059e00" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniquad" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcbaea978d741f3b60ced666b06a2a1e4a2bc81cb8b9dd29f4a40965e1f59e65" +dependencies = [ + "libc", + "ndk-sys", + "objc-rs", + "winapi", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "ndk-sys" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1bcdd74c20ad5d95aacd60ef9ba40fdf77f767051040541df557b7a9b2a2121" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "objc-rs" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64a1e7069a2525126bf12a9f1f7916835fafade384fb27cabf698e745e2a1eb8" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "ogg" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13e571c3517af9e1729d4c63571a27edd660ade0667973bfc74a67c660c2b651" +dependencies = [ + "byteorder", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quad-alsa-sys" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c66c2f04a6946293477973d85adc251d502da51c57b08cd9c997f0cfd8dcd4b5" +dependencies = [ + "libc", +] + +[[package]] +name = "quad-rand" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a651516ddc9168ebd67b24afd085a718be02f8858fe406591b013d101ce2f40" + +[[package]] +name = "quad-snd" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cba0c4943fc67147fbe9d1eb731fb9e678bfc9d926507eebbbfe0103e154e5b0" +dependencies = [ + "audir-sles", + "audrey", + "libc", + "quad-alsa-sys", + "winapi", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom", + "libredox", + "thiserror", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "smallvec" +version = "0.6.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97fcaeba89edba30f044a10c6a3cc39df9c3f17d7cd829dd1446cab35f890e0" +dependencies = [ + "maybe-uninit", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "tdkpin-rs" version = "0.1.0" +dependencies = [ + "directories", + "macroquad", + "serde", + "serde_json", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/tdkpin-rs/Cargo.toml b/tdkpin-rs/Cargo.toml index 1bb0176..0e4cb12 100644 --- a/tdkpin-rs/Cargo.toml +++ b/tdkpin-rs/Cargo.toml @@ -4,6 +4,10 @@ version = "0.1.0" edition = "2024" [dependencies] +directories = "6.0.0" +macroquad = { version = "0.4.16", features = ["audio"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" [lints.clippy] pedantic = { level = "warn", priority = -1 } diff --git a/tdkpin-rs/README.md b/tdkpin-rs/README.md new file mode 100644 index 0000000..240d249 --- /dev/null +++ b/tdkpin-rs/README.md @@ -0,0 +1,61 @@ +# TDK Pinball Machine for modern PCs + +This is a native Rust reconstruction of the 1995 Windows game **TDK Pinball +Machine**. It uses the extracted original artwork and sound, recreates the +playfield as a fixed-step simulation, and runs from the same source on Linux, +macOS, and Windows. + +The original program is not required at runtime. All required game assets are +embedded in the executable at build time. + +## Run + +Install a current stable Rust toolchain, then run: + +```sh +cargo run +``` + +For a distributable optimized binary: + +```sh +cargo build --profile production +``` + +The binary is written below `target/production/`. On Linux, Macroquad's native +development packages are also required (X11, OpenGL, and ALSA). Windows needs +no extra runtime installation; macOS builds with the normal Apple developer +command-line tools. + +## Original and modern controls + +| Action | Original key | Additional modern key | +| --- | --- | --- | +| Choose 1-4 players | `+` | `=` | +| Start or launch ball | Down arrow | Enter starts a game | +| Left flipper | Left Ctrl | `A` or Left arrow | +| Right flipper | Keypad Enter | Right Ctrl, `D`, or Right arrow | +| Nudge | Space, either Shift, keypad `3` | - | +| Help | F1 | Enter/Escape returns | +| Settings | F2 | - | +| High scores | F3 | - | +| Sound | F12 | - | + +The five original speed choices are retained as simulation-detail settings. +They change fixed-step resolution without changing the speed of play. + +The help screen is the original artwork in English, German, French, Italian, +or Spanish. As instructed on that screen, double-clicking its upper-left exit +box closes the program. + +## Saved data + +Settings and the ten-entry high-score table are stored as `save.json` in the +platform's normal per-user application-data directory. On first run, the table +is imported from the original `HISCORES.DAT` included with this reconstruction. + +## Reconstruction status + +This repository distinguishes exact recovered material from behavioral +reimplementation. See [RECONSTRUCTION.md](RECONSTRUCTION.md) for the evidence +ledger, known inference boundaries, and validation performed. diff --git a/tdkpin-rs/RECONSTRUCTION.md b/tdkpin-rs/RECONSTRUCTION.md new file mode 100644 index 0000000..464a9c9 --- /dev/null +++ b/tdkpin-rs/RECONSTRUCTION.md @@ -0,0 +1,69 @@ +# Reconstruction ledger + +## Source artifacts + +The reconstruction was derived from the preserved files under `../original/`. +The primary program fingerprint is: + +```text +a9022f1894e3e6e21fc42e8f6c932f7c549ca77f63aaa0c488bb9d55d9d0174c TDKPIN.EXE +f1d9ac980c7bfba5dc53eaa9e7cb2c3cd9b82f879ee8962ad40bf863d641bb49 MMTIMER.DLL +``` + +The Ghidra recovery covers all 301 named NE exports plus 62 internal function +entries. Its raw output and coverage tables remain beside the binary, so later +work can be checked against the same evidence rather than against this Rust +implementation. + +## Coverage by subsystem + +| Subsystem | Rust status | Evidence and boundary | +| --- | --- | --- | +| Artwork | Exact | All 34 custom DIB images, three standard bitmaps, icon, and palette derivatives are preserved in `assets/original/`. The game uses the original 640x460 table, loading, help, media, and diamond frames. | +| Audio | Exact samples | All 16 mono PCM WAV resources are embedded unchanged. Their trigger roles were recovered from resource use and gameplay context. | +| Help and languages | Exact | Original resource images 1001-1005 are displayed directly. | +| Playfield collision layout | Recovered | 101 active static line segments are transcribed from the original 175-object registration table. The original sideways coordinates are transformed with `screen = (y, 478 - x)`. Moving flippers and round bumpers use equivalent native Rust bodies. | +| Physics arithmetic | Reimplemented | The Win16 fixed-point/timer engine is replaced by deterministic fixed-step floating-point integration. Restitution and impulses are tuned to the recovered table but are not instruction-for-instruction equivalents. | +| Rules | Behaviorally recovered | Player count, controls, wheel holes, magnetic saves, robot grip, four-position ball lock, target banks, increasing bumper value, nine-part TDK diamond, permanent double scoring for a completed diamond, KByte media progression, and media extra balls follow the original help and code paths. | +| Numeric scoring | Partly inferred | Visible 2000-6000 target values and recovered registration values are preserved. Some bumper, bank-completion, robot, wheel, lock, and media thresholds are best-evidence reconstructions because the decompiler did not recover meaningful names or a clean rule table. | +| High scores | Compatible import | The original 276-byte table is decoded as ten `IWIK`-XOR-obfuscated little-endian scores plus ten 22-byte names, sorted, then migrated to portable JSON. | +| Configuration | Behaviorally compatible | Sound, language, and five detail levels are retained. Storage moves from a local Win16 INI file to the platform user-data directory. | +| Windows UI shell | Deliberately modernized | Win16 menus, modal dialogs, GDI blitting, and multimedia timers are replaced by a resizable, letterboxed native window with keyboard overlays. The visible game and original help remain at 640x460 logical pixels. | + +## Extracted asset inventory + +`assets/original/` contains 69 files used or preserved by the modern project: + +- 40 image files, including all gameplay frames and five localized help pages; +- 16 original WAV resources; +- six decoded dialog descriptions; +- the original icon in ICO and PNG form; +- palette and version metadata; +- the original high-score table and INI configuration sample. + +The parent `original/assets/` directory remains the lossless extraction record, +including raw resource blobs and the extraction manifest. The Rust copy is the +decoded, build-ready subset; it does not replace that evidence archive. + +## Validation levels + +- Static coverage: all intended decoded visual/audio resources are preserved; + the 101 recovered static collision segments are represented in Rust. +- Build coverage: `cargo check`, unit tests, and strict Clippy complete on the + host. Cross-target checks pass for `x86_64-pc-windows-gnu` and + `x86_64-apple-darwin`; CI is configured to build and test natively on Linux, + macOS, and Windows. +- Runtime coverage: the Linux executable was launched through the real window + backend, the attract screen was inspected, a game was started, a ball was + launched, collision scoring was observed, and a rendered frame was captured. +- Semantic boundary: no claim is made that every trajectory or score tick is + bit-identical to the 16-bit executable. Remaining numeric inference is listed + above instead of being presented as proven parity. + +## Architecture + +- `app.rs`: screen flow, controls, rendering, audio dispatch, and overlays; +- `assets.rs`: compile-time original asset embedding; +- `game.rs`: fixed-step game state, recovered walls, rules, and scoring; +- `geometry.rs`: segment/circle collision primitives; +- `persistence.rs`: platform paths, settings, high scores, and legacy import. diff --git a/tdkpin-rs/assets/original/HISCORES.DAT b/tdkpin-rs/assets/original/HISCORES.DAT new file mode 100755 index 0000000..86d6ddd Binary files /dev/null and b/tdkpin-rs/assets/original/HISCORES.DAT differ diff --git a/tdkpin-rs/assets/original/TDKPIN.INI b/tdkpin-rs/assets/original/TDKPIN.INI new file mode 100755 index 0000000..32938ab --- /dev/null +++ b/tdkpin-rs/assets/original/TDKPIN.INI @@ -0,0 +1,27 @@ +; Pinball INI-File by KIWI Hamburg + +[Settings] +; The speed setting defines the "time resolution" of the graphics. +; The higher the setting, the "smoother" the ball will run. +; However, if your hardware is too slow for the current speed setting, +; the pinball machine might "hang" or not react correctly. +; The speed setting DOES NOT influence game speed. +; In any case, try setting your Windows graphics resolution to +; 640x480 pixel in 256 colours before changing this setting. +; Speed=1 for slow graphics hardware +; Speed=5 for very fast graphics hardware +; Default: Speed=3 +Speed=3 + +; Sound=1 for Sounds on +; Sound=0 for Sounds off +; Default: Sounds=1 +Sounds=1 + +;Language=1 for english helpscreen +;Language=2 for german helpscreen +;Language=3 for french helpscreen +;Language=4 for italian helpscreen +;Language=5 for spanish helpscreen +;Default: Language=1 +Language=2 diff --git a/tdkpin-rs/assets/original/audio/wav_02001.wav b/tdkpin-rs/assets/original/audio/wav_02001.wav new file mode 100644 index 0000000..00dd315 Binary files /dev/null and b/tdkpin-rs/assets/original/audio/wav_02001.wav differ diff --git a/tdkpin-rs/assets/original/audio/wav_02002.wav b/tdkpin-rs/assets/original/audio/wav_02002.wav new file mode 100644 index 0000000..e8c63d9 Binary files /dev/null and b/tdkpin-rs/assets/original/audio/wav_02002.wav differ diff --git a/tdkpin-rs/assets/original/audio/wav_02004.wav b/tdkpin-rs/assets/original/audio/wav_02004.wav new file mode 100644 index 0000000..3a551b8 Binary files /dev/null and b/tdkpin-rs/assets/original/audio/wav_02004.wav differ diff --git a/tdkpin-rs/assets/original/audio/wav_02006.wav b/tdkpin-rs/assets/original/audio/wav_02006.wav new file mode 100644 index 0000000..aa6468a Binary files /dev/null and b/tdkpin-rs/assets/original/audio/wav_02006.wav differ diff --git a/tdkpin-rs/assets/original/audio/wav_02007.wav b/tdkpin-rs/assets/original/audio/wav_02007.wav new file mode 100644 index 0000000..b07ba54 Binary files /dev/null and b/tdkpin-rs/assets/original/audio/wav_02007.wav differ diff --git a/tdkpin-rs/assets/original/audio/wav_02008.wav b/tdkpin-rs/assets/original/audio/wav_02008.wav new file mode 100644 index 0000000..4e48d5a Binary files /dev/null and b/tdkpin-rs/assets/original/audio/wav_02008.wav differ diff --git a/tdkpin-rs/assets/original/audio/wav_02011.wav b/tdkpin-rs/assets/original/audio/wav_02011.wav new file mode 100644 index 0000000..ff5b5b9 Binary files /dev/null and b/tdkpin-rs/assets/original/audio/wav_02011.wav differ diff --git a/tdkpin-rs/assets/original/audio/wav_02012.wav b/tdkpin-rs/assets/original/audio/wav_02012.wav new file mode 100644 index 0000000..9dd905f Binary files /dev/null and b/tdkpin-rs/assets/original/audio/wav_02012.wav differ diff --git a/tdkpin-rs/assets/original/audio/wav_02013.wav b/tdkpin-rs/assets/original/audio/wav_02013.wav new file mode 100644 index 0000000..f80c760 Binary files /dev/null and b/tdkpin-rs/assets/original/audio/wav_02013.wav differ diff --git a/tdkpin-rs/assets/original/audio/wav_02015.wav b/tdkpin-rs/assets/original/audio/wav_02015.wav new file mode 100644 index 0000000..b5de7a1 Binary files /dev/null and b/tdkpin-rs/assets/original/audio/wav_02015.wav differ diff --git a/tdkpin-rs/assets/original/audio/wav_02016.wav b/tdkpin-rs/assets/original/audio/wav_02016.wav new file mode 100644 index 0000000..d0c5f7b Binary files /dev/null and b/tdkpin-rs/assets/original/audio/wav_02016.wav differ diff --git a/tdkpin-rs/assets/original/audio/wav_02017.wav b/tdkpin-rs/assets/original/audio/wav_02017.wav new file mode 100644 index 0000000..cf150b7 Binary files /dev/null and b/tdkpin-rs/assets/original/audio/wav_02017.wav differ diff --git a/tdkpin-rs/assets/original/audio/wav_02019.wav b/tdkpin-rs/assets/original/audio/wav_02019.wav new file mode 100644 index 0000000..f3c3c9f Binary files /dev/null and b/tdkpin-rs/assets/original/audio/wav_02019.wav differ diff --git a/tdkpin-rs/assets/original/audio/wav_02020.wav b/tdkpin-rs/assets/original/audio/wav_02020.wav new file mode 100644 index 0000000..5e5d618 Binary files /dev/null and b/tdkpin-rs/assets/original/audio/wav_02020.wav differ diff --git a/tdkpin-rs/assets/original/audio/wav_02021.wav b/tdkpin-rs/assets/original/audio/wav_02021.wav new file mode 100644 index 0000000..e65fd04 Binary files /dev/null and b/tdkpin-rs/assets/original/audio/wav_02021.wav differ diff --git a/tdkpin-rs/assets/original/audio/wav_02022.wav b/tdkpin-rs/assets/original/audio/wav_02022.wav new file mode 100644 index 0000000..6405869 Binary files /dev/null and b/tdkpin-rs/assets/original/audio/wav_02022.wav differ diff --git a/tdkpin-rs/assets/original/dialogs/dialog_32512.json b/tdkpin-rs/assets/original/dialogs/dialog_32512.json new file mode 100644 index 0000000..9751b57 --- /dev/null +++ b/tdkpin-rs/assets/original/dialogs/dialog_32512.json @@ -0,0 +1,169 @@ +{ + "style": "0x00c800c0", + "bounds": { + "x": 20, + "y": 24, + "width": 202, + "height": 130 + }, + "menu": null, + "class": null, + "caption": "File Open", + "declared_control_count": 10, + "controls": [ + { + "id": 65535, + "bounds": { + "x": 6, + "y": 8, + "width": 36, + "height": 10 + }, + "style": "0x50020000", + "class": { + "class_atom": "STATIC" + }, + "title": "File&name:", + "extra_hex": "" + }, + { + "id": 100, + "bounds": { + "x": 42, + "y": 6, + "width": 98, + "height": 12 + }, + "style": "0x50810080", + "class": { + "class_atom": "EDIT" + }, + "title": null, + "extra_hex": "" + }, + { + "id": 65535, + "bounds": { + "x": 6, + "y": 20, + "width": 36, + "height": 10 + }, + "style": "0x50020000", + "class": { + "class_atom": "STATIC" + }, + "title": "Directory:", + "extra_hex": "" + }, + { + "id": 101, + "bounds": { + "x": 42, + "y": 20, + "width": 98, + "height": 10 + }, + "style": "0x50020000", + "class": { + "class_atom": "STATIC" + }, + "title": null, + "extra_hex": "" + }, + { + "id": 65535, + "bounds": { + "x": 6, + "y": 32, + "width": 64, + "height": 10 + }, + "style": "0x50020000", + "class": { + "class_atom": "STATIC" + }, + "title": "&Files:", + "extra_hex": "" + }, + { + "id": 102, + "bounds": { + "x": 6, + "y": 44, + "width": 64, + "height": 82 + }, + "style": "0x50a10003", + "class": { + "class_atom": "LISTBOX" + }, + "title": null, + "extra_hex": "" + }, + { + "id": 65535, + "bounds": { + "x": 76, + "y": 32, + "width": 64, + "height": 10 + }, + "style": "0x50020000", + "class": { + "class_atom": "STATIC" + }, + "title": "&Directories:", + "extra_hex": "" + }, + { + "id": 103, + "bounds": { + "x": 76, + "y": 44, + "width": 64, + "height": 82 + }, + "style": "0x50a10003", + "class": { + "class_atom": "LISTBOX" + }, + "title": null, + "extra_hex": "" + }, + { + "id": 1, + "bounds": { + "x": 146, + "y": 5, + "width": 50, + "height": 14 + }, + "style": "0x50010001", + "class": { + "class_atom": "BUTTON" + }, + "title": "OK", + "extra_hex": "" + }, + { + "id": 2, + "bounds": { + "x": 146, + "y": 23, + "width": 50, + "height": 14 + }, + "style": "0x50010000", + "class": { + "class_atom": "BUTTON" + }, + "title": "Cancel", + "extra_hex": "" + } + ], + "font": { + "point_size": 8, + "name": "Helv" + } +} diff --git a/tdkpin-rs/assets/original/dialogs/dialog_32513.json b/tdkpin-rs/assets/original/dialogs/dialog_32513.json new file mode 100644 index 0000000..f55ea54 --- /dev/null +++ b/tdkpin-rs/assets/original/dialogs/dialog_32513.json @@ -0,0 +1,139 @@ +{ + "style": "0x00c800c0", + "bounds": { + "x": 20, + "y": 24, + "width": 202, + "height": 130 + }, + "menu": null, + "class": null, + "caption": "File Save As", + "declared_control_count": 8, + "controls": [ + { + "id": 65535, + "bounds": { + "x": 6, + "y": 8, + "width": 36, + "height": 10 + }, + "style": "0x50020000", + "class": { + "class_atom": "STATIC" + }, + "title": "File&name:", + "extra_hex": "" + }, + { + "id": 100, + "bounds": { + "x": 42, + "y": 6, + "width": 98, + "height": 12 + }, + "style": "0x50810080", + "class": { + "class_atom": "EDIT" + }, + "title": null, + "extra_hex": "" + }, + { + "id": 65535, + "bounds": { + "x": 6, + "y": 20, + "width": 36, + "height": 10 + }, + "style": "0x50020000", + "class": { + "class_atom": "STATIC" + }, + "title": "Directory:", + "extra_hex": "" + }, + { + "id": 101, + "bounds": { + "x": 42, + "y": 20, + "width": 98, + "height": 10 + }, + "style": "0x50020000", + "class": { + "class_atom": "STATIC" + }, + "title": null, + "extra_hex": "" + }, + { + "id": 103, + "bounds": { + "x": 6, + "y": 44, + "width": 64, + "height": 82 + }, + "style": "0x50a10003", + "class": { + "class_atom": "LISTBOX" + }, + "title": null, + "extra_hex": "" + }, + { + "id": 65535, + "bounds": { + "x": 6, + "y": 32, + "width": 64, + "height": 10 + }, + "style": "0x50020000", + "class": { + "class_atom": "STATIC" + }, + "title": "&Directories:", + "extra_hex": "" + }, + { + "id": 1, + "bounds": { + "x": 146, + "y": 5, + "width": 50, + "height": 14 + }, + "style": "0x50010001", + "class": { + "class_atom": "BUTTON" + }, + "title": "OK", + "extra_hex": "" + }, + { + "id": 2, + "bounds": { + "x": 146, + "y": 23, + "width": 50, + "height": 14 + }, + "style": "0x50010000", + "class": { + "class_atom": "BUTTON" + }, + "title": "Cancel", + "extra_hex": "" + } + ], + "font": { + "point_size": 8, + "name": "Helv" + } +} diff --git a/tdkpin-rs/assets/original/dialogs/dialog_32514.json b/tdkpin-rs/assets/original/dialogs/dialog_32514.json new file mode 100644 index 0000000..1decccd --- /dev/null +++ b/tdkpin-rs/assets/original/dialogs/dialog_32514.json @@ -0,0 +1,79 @@ +{ + "style": "0x80c00040", + "bounds": { + "x": 20, + "y": 24, + "width": 180, + "height": 64 + }, + "menu": null, + "class": null, + "caption": null, + "declared_control_count": 4, + "controls": [ + { + "id": 101, + "bounds": { + "x": 10, + "y": 20, + "width": 160, + "height": 12 + }, + "style": "0x50810080", + "class": { + "class_atom": "EDIT" + }, + "title": null, + "extra_hex": "" + }, + { + "id": 100, + "bounds": { + "x": 10, + "y": 8, + "width": 160, + "height": 10 + }, + "style": "0x50020000", + "class": { + "class_atom": "STATIC" + }, + "title": null, + "extra_hex": "" + }, + { + "id": 1, + "bounds": { + "x": 47, + "y": 42, + "width": 40, + "height": 14 + }, + "style": "0x50010001", + "class": { + "class_atom": "BUTTON" + }, + "title": "OK", + "extra_hex": "" + }, + { + "id": 2, + "bounds": { + "x": 93, + "y": 42, + "width": 40, + "height": 14 + }, + "style": "0x50010000", + "class": { + "class_atom": "BUTTON" + }, + "title": "Cancel", + "extra_hex": "" + } + ], + "font": { + "point_size": 8, + "name": "Helv" + } +} diff --git a/tdkpin-rs/assets/original/dialogs/dialog_32515.json b/tdkpin-rs/assets/original/dialogs/dialog_32515.json new file mode 100644 index 0000000..e2e757c --- /dev/null +++ b/tdkpin-rs/assets/original/dialogs/dialog_32515.json @@ -0,0 +1,217 @@ +{ + "style": "0x80c800c0", + "bounds": { + "x": 24, + "y": 19, + "width": 199, + "height": 135 + }, + "menu": null, + "class": "BorDlg", + "caption": "File Open", + "declared_control_count": 14, + "controls": [ + { + "id": 65535, + "bounds": { + "x": 8, + "y": 8, + "width": 36, + "height": 10 + }, + "style": "0x50020000", + "class": { + "class_atom": "STATIC" + }, + "title": "File&name:", + "extra_hex": "" + }, + { + "id": 100, + "bounds": { + "x": 44, + "y": 6, + "width": 104, + "height": 12 + }, + "style": "0x50810080", + "class": { + "class_atom": "EDIT" + }, + "title": null, + "extra_hex": "" + }, + { + "id": 65535, + "bounds": { + "x": 8, + "y": 20, + "width": 36, + "height": 10 + }, + "style": "0x50020000", + "class": { + "class_atom": "STATIC" + }, + "title": "Directory:", + "extra_hex": "" + }, + { + "id": 101, + "bounds": { + "x": 44, + "y": 20, + "width": 104, + "height": 10 + }, + "style": "0x50020000", + "class": { + "class_atom": "STATIC" + }, + "title": null, + "extra_hex": "" + }, + { + "id": 65535, + "bounds": { + "x": 8, + "y": 39, + "width": 21, + "height": 8 + }, + "style": "0x50020000", + "class": { + "class_atom": "STATIC" + }, + "title": "&Files:", + "extra_hex": "" + }, + { + "id": 102, + "bounds": { + "x": 8, + "y": 48, + "width": 64, + "height": 82 + }, + "style": "0x50a10003", + "class": { + "class_atom": "LISTBOX" + }, + "title": null, + "extra_hex": "" + }, + { + "id": 65535, + "bounds": { + "x": 84, + "y": 39, + "width": 40, + "height": 8 + }, + "style": "0x50020000", + "class": { + "class_atom": "STATIC" + }, + "title": "&Directories:", + "extra_hex": "" + }, + { + "id": 103, + "bounds": { + "x": 84, + "y": 48, + "width": 64, + "height": 82 + }, + "style": "0x50a10003", + "class": { + "class_atom": "LISTBOX" + }, + "title": null, + "extra_hex": "" + }, + { + "id": 104, + "bounds": { + "x": 4, + "y": 4, + "width": 148, + "height": 28 + }, + "style": "0x50000001", + "class": "BorShade", + "title": null, + "extra_hex": "" + }, + { + "id": 105, + "bounds": { + "x": 4, + "y": 36, + "width": 72, + "height": 96 + }, + "style": "0x50000001", + "class": "BorShade", + "title": null, + "extra_hex": "" + }, + { + "id": 1, + "bounds": { + "x": 160, + "y": 7, + "width": 36, + "height": 24 + }, + "style": "0x50010001", + "class": "BorBtn", + "title": "Button", + "extra_hex": "" + }, + { + "id": 2, + "bounds": { + "x": 160, + "y": 37, + "width": 36, + "height": 24 + }, + "style": "0x50010000", + "class": "BorBtn", + "title": "Button", + "extra_hex": "" + }, + { + "id": 106, + "bounds": { + "x": 156, + "y": -1, + "width": 4, + "height": 136 + }, + "style": "0x50000003", + "class": "BorShade", + "title": null, + "extra_hex": "" + }, + { + "id": 107, + "bounds": { + "x": 80, + "y": 36, + "width": 72, + "height": 96 + }, + "style": "0x50000001", + "class": "BorShade", + "title": null, + "extra_hex": "" + } + ], + "font": { + "point_size": 8, + "name": "Helv" + } +} diff --git a/tdkpin-rs/assets/original/dialogs/dialog_32516.json b/tdkpin-rs/assets/original/dialogs/dialog_32516.json new file mode 100644 index 0000000..e562a25 --- /dev/null +++ b/tdkpin-rs/assets/original/dialogs/dialog_32516.json @@ -0,0 +1,174 @@ +{ + "style": "0x80c800c0", + "bounds": { + "x": 20, + "y": 24, + "width": 202, + "height": 135 + }, + "menu": null, + "class": "BorDlg", + "caption": "File Save As", + "declared_control_count": 11, + "controls": [ + { + "id": 65535, + "bounds": { + "x": 8, + "y": 8, + "width": 36, + "height": 12 + }, + "style": "0x50020000", + "class": { + "class_atom": "STATIC" + }, + "title": "File&name:", + "extra_hex": "" + }, + { + "id": 100, + "bounds": { + "x": 44, + "y": 8, + "width": 96, + "height": 12 + }, + "style": "0x50810080", + "class": { + "class_atom": "EDIT" + }, + "title": null, + "extra_hex": "" + }, + { + "id": 65535, + "bounds": { + "x": 8, + "y": 20, + "width": 36, + "height": 8 + }, + "style": "0x50020000", + "class": { + "class_atom": "STATIC" + }, + "title": "Directory:", + "extra_hex": "" + }, + { + "id": 101, + "bounds": { + "x": 44, + "y": 20, + "width": 98, + "height": 10 + }, + "style": "0x50020000", + "class": { + "class_atom": "STATIC" + }, + "title": null, + "extra_hex": "" + }, + { + "id": 103, + "bounds": { + "x": 8, + "y": 48, + "width": 64, + "height": 82 + }, + "style": "0x50a10003", + "class": { + "class_atom": "LISTBOX" + }, + "title": null, + "extra_hex": "" + }, + { + "id": 65535, + "bounds": { + "x": 8, + "y": 37, + "width": 64, + "height": 8 + }, + "style": "0x50020000", + "class": { + "class_atom": "STATIC" + }, + "title": "&Directories:", + "extra_hex": "" + }, + { + "id": 65535, + "bounds": { + "x": 4, + "y": 4, + "width": 140, + "height": 27 + }, + "style": "0x50000001", + "class": "BorShade", + "title": null, + "extra_hex": "" + }, + { + "id": 65535, + "bounds": { + "x": 4, + "y": 36, + "width": 72, + "height": 96 + }, + "style": "0x50000001", + "class": "BorShade", + "title": null, + "extra_hex": "" + }, + { + "id": 1, + "bounds": { + "x": 164, + "y": 4, + "width": 32, + "height": 20 + }, + "style": "0x50010001", + "class": "BorBtn", + "title": "Button", + "extra_hex": "" + }, + { + "id": 2, + "bounds": { + "x": 164, + "y": 32, + "width": 32, + "height": 20 + }, + "style": "0x50010000", + "class": "BorBtn", + "title": "Button", + "extra_hex": "" + }, + { + "id": 105, + "bounds": { + "x": 160, + "y": 0, + "width": 2, + "height": 136 + }, + "style": "0x50000003", + "class": "BorShade", + "title": null, + "extra_hex": "" + } + ], + "font": { + "point_size": 8, + "name": "Helv" + } +} diff --git a/tdkpin-rs/assets/original/dialogs/dialog_32517.json b/tdkpin-rs/assets/original/dialogs/dialog_32517.json new file mode 100644 index 0000000..9b45682 --- /dev/null +++ b/tdkpin-rs/assets/original/dialogs/dialog_32517.json @@ -0,0 +1,88 @@ +{ + "style": "0x80c800c0", + "bounds": { + "x": 20, + "y": 24, + "width": 180, + "height": 75 + }, + "menu": null, + "class": "BorDlg", + "caption": null, + "declared_control_count": 5, + "controls": [ + { + "id": 101, + "bounds": { + "x": 10, + "y": 20, + "width": 160, + "height": 12 + }, + "style": "0x50810080", + "class": { + "class_atom": "EDIT" + }, + "title": null, + "extra_hex": "" + }, + { + "id": 100, + "bounds": { + "x": 10, + "y": 8, + "width": 160, + "height": 10 + }, + "style": "0x50020000", + "class": { + "class_atom": "STATIC" + }, + "title": null, + "extra_hex": "" + }, + { + "id": 102, + "bounds": { + "x": 6, + "y": 5, + "width": 168, + "height": 32 + }, + "style": "0x50000001", + "class": "BorShade", + "title": null, + "extra_hex": "" + }, + { + "id": 1, + "bounds": { + "x": 42, + "y": 44, + "width": 36, + "height": 24 + }, + "style": "0x50010001", + "class": "BorBtn", + "title": "Button", + "extra_hex": "" + }, + { + "id": 2, + "bounds": { + "x": 102, + "y": 44, + "width": 36, + "height": 24 + }, + "style": "0x50010000", + "class": "BorBtn", + "title": "Button", + "extra_hex": "" + } + ], + "font": { + "point_size": 8, + "name": "Helv" + } +} diff --git a/tdkpin-rs/assets/original/icons/icon_00001.ico b/tdkpin-rs/assets/original/icons/icon_00001.ico new file mode 100644 index 0000000..a8b945a Binary files /dev/null and b/tdkpin-rs/assets/original/icons/icon_00001.ico differ diff --git a/tdkpin-rs/assets/original/icons/icon_00001.png b/tdkpin-rs/assets/original/icons/icon_00001.png new file mode 100644 index 0000000..c7602f4 Binary files /dev/null and b/tdkpin-rs/assets/original/icons/icon_00001.png differ diff --git a/tdkpin-rs/assets/original/images/bitmap_00101.bmp b/tdkpin-rs/assets/original/images/bitmap_00101.bmp new file mode 100644 index 0000000..9a7546f Binary files /dev/null and b/tdkpin-rs/assets/original/images/bitmap_00101.bmp differ diff --git a/tdkpin-rs/assets/original/images/bitmap_00101.png b/tdkpin-rs/assets/original/images/bitmap_00101.png new file mode 100644 index 0000000..4c50204 Binary files /dev/null and b/tdkpin-rs/assets/original/images/bitmap_00101.png differ diff --git a/tdkpin-rs/assets/original/images/bitmap_00102.bmp b/tdkpin-rs/assets/original/images/bitmap_00102.bmp new file mode 100644 index 0000000..cfa96d5 Binary files /dev/null and b/tdkpin-rs/assets/original/images/bitmap_00102.bmp differ diff --git a/tdkpin-rs/assets/original/images/bitmap_00102.png b/tdkpin-rs/assets/original/images/bitmap_00102.png new file mode 100644 index 0000000..ea23b8e Binary files /dev/null and b/tdkpin-rs/assets/original/images/bitmap_00102.png differ diff --git a/tdkpin-rs/assets/original/images/bitmap_00500.bmp b/tdkpin-rs/assets/original/images/bitmap_00500.bmp new file mode 100644 index 0000000..e7e8d8c Binary files /dev/null and b/tdkpin-rs/assets/original/images/bitmap_00500.bmp differ diff --git a/tdkpin-rs/assets/original/images/bitmap_00500.png b/tdkpin-rs/assets/original/images/bitmap_00500.png new file mode 100644 index 0000000..c6c2ca0 Binary files /dev/null and b/tdkpin-rs/assets/original/images/bitmap_00500.png differ diff --git a/tdkpin-rs/assets/original/images/dat_00400.png b/tdkpin-rs/assets/original/images/dat_00400.png new file mode 100644 index 0000000..cc787aa Binary files /dev/null and b/tdkpin-rs/assets/original/images/dat_00400.png differ diff --git a/tdkpin-rs/assets/original/images/dat_00401.png b/tdkpin-rs/assets/original/images/dat_00401.png new file mode 100644 index 0000000..043448e Binary files /dev/null and b/tdkpin-rs/assets/original/images/dat_00401.png differ diff --git a/tdkpin-rs/assets/original/images/dat_00402.png b/tdkpin-rs/assets/original/images/dat_00402.png new file mode 100644 index 0000000..65596c0 Binary files /dev/null and b/tdkpin-rs/assets/original/images/dat_00402.png differ diff --git a/tdkpin-rs/assets/original/images/dat_00403.png b/tdkpin-rs/assets/original/images/dat_00403.png new file mode 100644 index 0000000..4856661 Binary files /dev/null and b/tdkpin-rs/assets/original/images/dat_00403.png differ diff --git a/tdkpin-rs/assets/original/images/dat_00404.png b/tdkpin-rs/assets/original/images/dat_00404.png new file mode 100644 index 0000000..278cd02 Binary files /dev/null and b/tdkpin-rs/assets/original/images/dat_00404.png differ diff --git a/tdkpin-rs/assets/original/images/dat_00405.png b/tdkpin-rs/assets/original/images/dat_00405.png new file mode 100644 index 0000000..9205d80 Binary files /dev/null and b/tdkpin-rs/assets/original/images/dat_00405.png differ diff --git a/tdkpin-rs/assets/original/images/dat_00406.png b/tdkpin-rs/assets/original/images/dat_00406.png new file mode 100644 index 0000000..71e3de4 Binary files /dev/null and b/tdkpin-rs/assets/original/images/dat_00406.png differ diff --git a/tdkpin-rs/assets/original/images/dat_00407.png b/tdkpin-rs/assets/original/images/dat_00407.png new file mode 100644 index 0000000..ef885a0 Binary files /dev/null and b/tdkpin-rs/assets/original/images/dat_00407.png differ diff --git a/tdkpin-rs/assets/original/images/dat_00600.png b/tdkpin-rs/assets/original/images/dat_00600.png new file mode 100644 index 0000000..c02b707 Binary files /dev/null and b/tdkpin-rs/assets/original/images/dat_00600.png differ diff --git a/tdkpin-rs/assets/original/images/dat_00701.png b/tdkpin-rs/assets/original/images/dat_00701.png new file mode 100644 index 0000000..982779d Binary files /dev/null and b/tdkpin-rs/assets/original/images/dat_00701.png differ diff --git a/tdkpin-rs/assets/original/images/dat_00702.png b/tdkpin-rs/assets/original/images/dat_00702.png new file mode 100644 index 0000000..8296ac6 Binary files /dev/null and b/tdkpin-rs/assets/original/images/dat_00702.png differ diff --git a/tdkpin-rs/assets/original/images/dat_00703.png b/tdkpin-rs/assets/original/images/dat_00703.png new file mode 100644 index 0000000..fdc82af Binary files /dev/null and b/tdkpin-rs/assets/original/images/dat_00703.png differ diff --git a/tdkpin-rs/assets/original/images/dat_00704.png b/tdkpin-rs/assets/original/images/dat_00704.png new file mode 100644 index 0000000..ce88ab6 Binary files /dev/null and b/tdkpin-rs/assets/original/images/dat_00704.png differ diff --git a/tdkpin-rs/assets/original/images/dat_00801.png b/tdkpin-rs/assets/original/images/dat_00801.png new file mode 100644 index 0000000..d89ef04 Binary files /dev/null and b/tdkpin-rs/assets/original/images/dat_00801.png differ diff --git a/tdkpin-rs/assets/original/images/dat_00802.png b/tdkpin-rs/assets/original/images/dat_00802.png new file mode 100644 index 0000000..8e9df7c Binary files /dev/null and b/tdkpin-rs/assets/original/images/dat_00802.png differ diff --git a/tdkpin-rs/assets/original/images/dat_00803.png b/tdkpin-rs/assets/original/images/dat_00803.png new file mode 100644 index 0000000..454d5cb Binary files /dev/null and b/tdkpin-rs/assets/original/images/dat_00803.png differ diff --git a/tdkpin-rs/assets/original/images/dat_00804.png b/tdkpin-rs/assets/original/images/dat_00804.png new file mode 100644 index 0000000..e635c95 Binary files /dev/null and b/tdkpin-rs/assets/original/images/dat_00804.png differ diff --git a/tdkpin-rs/assets/original/images/dat_00805.png b/tdkpin-rs/assets/original/images/dat_00805.png new file mode 100644 index 0000000..4427216 Binary files /dev/null and b/tdkpin-rs/assets/original/images/dat_00805.png differ diff --git a/tdkpin-rs/assets/original/images/dat_00806.png b/tdkpin-rs/assets/original/images/dat_00806.png new file mode 100644 index 0000000..103e955 Binary files /dev/null and b/tdkpin-rs/assets/original/images/dat_00806.png differ diff --git a/tdkpin-rs/assets/original/images/dat_00807.png b/tdkpin-rs/assets/original/images/dat_00807.png new file mode 100644 index 0000000..a5f2cfa Binary files /dev/null and b/tdkpin-rs/assets/original/images/dat_00807.png differ diff --git a/tdkpin-rs/assets/original/images/dat_00808.png b/tdkpin-rs/assets/original/images/dat_00808.png new file mode 100644 index 0000000..9f70c2c Binary files /dev/null and b/tdkpin-rs/assets/original/images/dat_00808.png differ diff --git a/tdkpin-rs/assets/original/images/dat_00809.png b/tdkpin-rs/assets/original/images/dat_00809.png new file mode 100644 index 0000000..e3318f0 Binary files /dev/null and b/tdkpin-rs/assets/original/images/dat_00809.png differ diff --git a/tdkpin-rs/assets/original/images/dat_00900.png b/tdkpin-rs/assets/original/images/dat_00900.png new file mode 100644 index 0000000..98a8883 Binary files /dev/null and b/tdkpin-rs/assets/original/images/dat_00900.png differ diff --git a/tdkpin-rs/assets/original/images/dat_00901.png b/tdkpin-rs/assets/original/images/dat_00901.png new file mode 100644 index 0000000..cc8e919 Binary files /dev/null and b/tdkpin-rs/assets/original/images/dat_00901.png differ diff --git a/tdkpin-rs/assets/original/images/dat_00993.png b/tdkpin-rs/assets/original/images/dat_00993.png new file mode 100644 index 0000000..377fec4 Binary files /dev/null and b/tdkpin-rs/assets/original/images/dat_00993.png differ diff --git a/tdkpin-rs/assets/original/images/dat_00994.png b/tdkpin-rs/assets/original/images/dat_00994.png new file mode 100644 index 0000000..0139954 Binary files /dev/null and b/tdkpin-rs/assets/original/images/dat_00994.png differ diff --git a/tdkpin-rs/assets/original/images/dat_00995.png b/tdkpin-rs/assets/original/images/dat_00995.png new file mode 100644 index 0000000..494ae75 Binary files /dev/null and b/tdkpin-rs/assets/original/images/dat_00995.png differ diff --git a/tdkpin-rs/assets/original/images/dat_00997.png b/tdkpin-rs/assets/original/images/dat_00997.png new file mode 100644 index 0000000..b99e127 Binary files /dev/null and b/tdkpin-rs/assets/original/images/dat_00997.png differ diff --git a/tdkpin-rs/assets/original/images/dat_00998.png b/tdkpin-rs/assets/original/images/dat_00998.png new file mode 100644 index 0000000..1ae93f5 Binary files /dev/null and b/tdkpin-rs/assets/original/images/dat_00998.png differ diff --git a/tdkpin-rs/assets/original/images/dat_01001.png b/tdkpin-rs/assets/original/images/dat_01001.png new file mode 100644 index 0000000..62dec93 Binary files /dev/null and b/tdkpin-rs/assets/original/images/dat_01001.png differ diff --git a/tdkpin-rs/assets/original/images/dat_01002.png b/tdkpin-rs/assets/original/images/dat_01002.png new file mode 100644 index 0000000..32e2682 Binary files /dev/null and b/tdkpin-rs/assets/original/images/dat_01002.png differ diff --git a/tdkpin-rs/assets/original/images/dat_01003.png b/tdkpin-rs/assets/original/images/dat_01003.png new file mode 100644 index 0000000..790b54d Binary files /dev/null and b/tdkpin-rs/assets/original/images/dat_01003.png differ diff --git a/tdkpin-rs/assets/original/images/dat_01004.png b/tdkpin-rs/assets/original/images/dat_01004.png new file mode 100644 index 0000000..e4bc7f1 Binary files /dev/null and b/tdkpin-rs/assets/original/images/dat_01004.png differ diff --git a/tdkpin-rs/assets/original/images/dat_01005.png b/tdkpin-rs/assets/original/images/dat_01005.png new file mode 100644 index 0000000..fecec15 Binary files /dev/null and b/tdkpin-rs/assets/original/images/dat_01005.png differ diff --git a/tdkpin-rs/assets/original/metadata/version_00001.json b/tdkpin-rs/assets/original/metadata/version_00001.json new file mode 100644 index 0000000..b0f66fc --- /dev/null +++ b/tdkpin-rs/assets/original/metadata/version_00001.json @@ -0,0 +1,38 @@ +{ + "printable_strings": [ + "VS_VERSION_INFO", + "StringFileInfo", + "040904E4", + "CompanyName", + "Lars Hammer / KIWI Hamburg", + "FileDescription", + "TDK Pinball Machine", + "FileVersion", + "1.00", + "InternalName", + "TDKPIN", + "LegalCopyright", + "Developed 1995 by KIWI Von Wendt Konzept GmbH.", + "ProductName", + "TDK Pinball Machine", + "ProductVersion", + "3.10.0.0", + "VarFileInfo", + "Translation" + ], + "fixed_file_info": { + "signature": "0xfeef04bd", + "structure_version": "0x00010000", + "file_version_ms": "0x00000000", + "file_version_ls": "0x00010006", + "product_version_ms": "0x00030001", + "product_version_ls": "0x00000000", + "file_flags_mask": "0x00000000", + "file_flags": "0x00000000", + "file_os": "0x00000001", + "file_type": "0x00000002", + "file_subtype": "0x00000000", + "file_date_ms": "0x00000000", + "file_date_ls": "0x00000000" + } +} diff --git a/tdkpin-rs/assets/original/palettes/palette_0999.gpl b/tdkpin-rs/assets/original/palettes/palette_0999.gpl new file mode 100644 index 0000000..d0fc73c --- /dev/null +++ b/tdkpin-rs/assets/original/palettes/palette_0999.gpl @@ -0,0 +1,260 @@ +GIMP Palette +Name: TDKPIN resource 999 +Columns: 16 +# + 0 0 0 Index 0 +128 0 0 Index 1 + 0 128 0 Index 2 +128 128 0 Index 3 + 0 0 128 Index 4 +128 0 128 Index 5 + 0 128 128 Index 6 +192 192 192 Index 7 +192 220 192 Index 8 +166 202 240 Index 9 + 32 32 184 Index 10 + 72 32 184 Index 11 +112 32 184 Index 12 +152 32 184 Index 13 +192 32 184 Index 14 +232 32 184 Index 15 + 32 32 240 Index 16 + 72 32 240 Index 17 +112 32 240 Index 18 +152 32 240 Index 19 +192 32 240 Index 20 +232 32 240 Index 21 +124 76 48 Index 22 +140 84 56 Index 23 + 32 72 16 Index 24 + 72 72 16 Index 25 +112 72 16 Index 26 +152 72 16 Index 27 +192 72 16 Index 28 +232 72 16 Index 29 + 32 72 72 Index 30 + 72 72 72 Index 31 +112 72 72 Index 32 +152 72 72 Index 33 +192 72 72 Index 34 +232 72 72 Index 35 + 32 72 128 Index 36 + 72 72 128 Index 37 +112 72 128 Index 38 +152 72 128 Index 39 +192 72 128 Index 40 +232 72 128 Index 41 + 32 72 184 Index 42 + 72 72 184 Index 43 +112 72 184 Index 44 +152 72 184 Index 45 +192 72 184 Index 46 +232 72 184 Index 47 + 32 72 240 Index 48 + 72 72 240 Index 49 +112 72 240 Index 50 +152 72 240 Index 51 +192 72 240 Index 52 +232 72 240 Index 53 +152 92 64 Index 54 +168 100 76 Index 55 + 32 112 16 Index 56 + 72 112 16 Index 57 +112 112 16 Index 58 +152 112 16 Index 59 +192 112 16 Index 60 +232 112 16 Index 61 + 32 112 72 Index 62 + 72 112 72 Index 63 +112 112 72 Index 64 +152 112 72 Index 65 +192 112 72 Index 66 +232 112 72 Index 67 + 32 112 128 Index 68 + 72 112 128 Index 69 +112 112 128 Index 70 +152 112 128 Index 71 +192 112 128 Index 72 +232 112 128 Index 73 + 32 112 184 Index 74 + 72 112 184 Index 75 +112 112 184 Index 76 +152 112 184 Index 77 +192 112 184 Index 78 +232 112 184 Index 79 + 32 112 240 Index 80 + 72 112 240 Index 81 +112 112 240 Index 82 +152 112 240 Index 83 +192 112 240 Index 84 +232 112 240 Index 85 +176 116 96 Index 86 +188 136 104 Index 87 + 32 152 16 Index 88 + 72 152 16 Index 89 +112 152 16 Index 90 +152 152 16 Index 91 +192 152 16 Index 92 +232 152 16 Index 93 + 32 152 72 Index 94 + 72 152 72 Index 95 +112 152 72 Index 96 +152 152 72 Index 97 +192 152 72 Index 98 +232 152 72 Index 99 + 32 152 128 Index 100 + 72 152 128 Index 101 +112 152 128 Index 102 +152 152 128 Index 103 +192 152 128 Index 104 +232 152 128 Index 105 + 32 152 184 Index 106 + 72 152 184 Index 107 +112 152 184 Index 108 +152 152 184 Index 109 +192 152 184 Index 110 +232 152 184 Index 111 + 32 152 240 Index 112 + 72 152 240 Index 113 +112 152 240 Index 114 +152 152 240 Index 115 +192 152 240 Index 116 +232 152 240 Index 117 +200 144 108 Index 118 +208 160 116 Index 119 + 32 192 16 Index 120 + 72 192 16 Index 121 +112 192 16 Index 122 +152 192 16 Index 123 +192 192 16 Index 124 +232 192 16 Index 125 + 32 192 72 Index 126 + 72 192 72 Index 127 +112 192 72 Index 128 +152 192 72 Index 129 +192 192 72 Index 130 +232 192 72 Index 131 + 32 192 128 Index 132 + 72 192 128 Index 133 +112 192 128 Index 134 +152 192 128 Index 135 +192 192 128 Index 136 +232 192 128 Index 137 + 32 192 184 Index 138 + 72 192 184 Index 139 +112 192 184 Index 140 +152 192 184 Index 141 +192 192 184 Index 142 +232 192 184 Index 143 + 32 192 240 Index 144 + 72 192 240 Index 145 +112 192 240 Index 146 +152 192 240 Index 147 +192 192 240 Index 148 +232 192 240 Index 149 +216 168 128 Index 150 +224 176 144 Index 151 + 32 232 16 Index 152 + 72 232 16 Index 153 +112 232 16 Index 154 +152 232 16 Index 155 +192 232 16 Index 156 +232 232 16 Index 157 + 32 232 72 Index 158 + 72 232 72 Index 159 +112 232 72 Index 160 +152 232 72 Index 161 +192 232 72 Index 162 +232 232 72 Index 163 + 32 232 128 Index 164 + 72 232 128 Index 165 +112 232 128 Index 166 +152 232 128 Index 167 +192 232 128 Index 168 +232 232 128 Index 169 + 32 232 184 Index 170 + 72 232 184 Index 171 +112 232 184 Index 172 +152 232 184 Index 173 +192 232 184 Index 174 +232 232 184 Index 175 + 32 232 240 Index 176 + 72 232 240 Index 177 +112 232 240 Index 178 +152 232 240 Index 179 +192 232 240 Index 180 +232 232 240 Index 181 +232 192 156 Index 182 +240 212 184 Index 183 + 0 56 0 Index 184 + 0 96 0 Index 185 + 0 160 0 Index 186 + 0 208 0 Index 187 +208 208 0 Index 188 +160 160 0 Index 189 + 96 96 0 Index 190 + 56 56 0 Index 191 + 56 0 0 Index 192 + 96 0 0 Index 193 +160 0 0 Index 194 +208 0 0 Index 195 +208 0 208 Index 196 +160 0 160 Index 197 + 96 0 96 Index 198 + 56 0 56 Index 199 + 0 0 56 Index 200 + 0 0 96 Index 201 + 0 0 160 Index 202 + 0 0 208 Index 203 + 16 16 16 Index 204 + 32 32 32 Index 205 + 48 48 48 Index 206 + 64 64 64 Index 207 + 80 80 80 Index 208 + 96 96 96 Index 209 +112 112 112 Index 210 +144 144 144 Index 211 +160 160 160 Index 212 +176 176 176 Index 213 +208 208 208 Index 214 +224 224 224 Index 215 +240 240 240 Index 216 + 76 40 28 Index 217 + 76 28 40 Index 218 + 40 76 28 Index 219 + 28 76 40 Index 220 + 40 28 76 Index 221 + 28 40 76 Index 222 +108 88 32 Index 223 +160 124 88 Index 224 +232 168 104 Index 225 + 84 48 16 Index 226 + 92 56 40 Index 227 + 32 32 16 Index 228 + 72 32 16 Index 229 +112 32 16 Index 230 +152 32 16 Index 231 +192 32 16 Index 232 +232 32 16 Index 233 + 32 32 72 Index 234 + 72 32 72 Index 235 +112 32 72 Index 236 +152 32 72 Index 237 +192 32 72 Index 238 +232 32 72 Index 239 + 32 32 128 Index 240 + 72 32 128 Index 241 +112 32 128 Index 242 +152 32 128 Index 243 +192 32 128 Index 244 +232 32 128 Index 245 +255 251 240 Index 246 +160 160 164 Index 247 +128 128 128 Index 248 +255 0 0 Index 249 + 0 255 0 Index 250 +255 255 0 Index 251 + 0 0 255 Index 252 +255 0 255 Index 253 + 0 255 255 Index 254 +255 255 255 Index 255 diff --git a/tdkpin-rs/assets/original/palettes/palette_0999.png b/tdkpin-rs/assets/original/palettes/palette_0999.png new file mode 100644 index 0000000..5e0c6ad Binary files /dev/null and b/tdkpin-rs/assets/original/palettes/palette_0999.png differ diff --git a/tdkpin-rs/src/app.rs b/tdkpin-rs/src/app.rs new file mode 100644 index 0000000..7bbf05c --- /dev/null +++ b/tdkpin-rs/src/app.rs @@ -0,0 +1,683 @@ +use crate::{ + assets::Assets, + game::{Controls, Event, Game}, + persistence::{HighScore, Language, Persistence, SavedData, insert_high_score}, +}; +use macroquad::prelude::*; + +const WIDTH: f32 = 640.0; +const HEIGHT: f32 = 460.0; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Screen { + Loading, + Attract, + Playing, + Help, + Settings, + HighScores, + NameEntry, +} + +pub struct App { + assets: Assets, + persistence: Persistence, + saved: SavedData, + render_target: RenderTarget, + screen: Screen, + return_screen: Screen, + game: Option, + player_count: usize, + setting_row: usize, + loading_until: f64, + last_help_click: f64, + message: String, + message_until: f64, + name: String, + pending_score: u32, +} + +impl App { + pub async fn load() -> Self { + let assets = Assets::load().await; + let persistence = Persistence::new(); + let saved = persistence.load(); + let render_target = render_target(640, 460); + render_target.texture.set_filter(FilterMode::Nearest); + Self { + assets, + persistence, + saved, + render_target, + screen: Screen::Loading, + return_screen: Screen::Attract, + game: None, + player_count: 1, + setting_row: 0, + loading_until: get_time() + 1.1, + last_help_click: -1.0, + message: String::new(), + message_until: 0.0, + name: String::new(), + pending_score: 0, + } + } + + pub fn frame(&mut self) { + self.handle_global_input(); + match self.screen { + Screen::Loading => { + if get_time() >= self.loading_until { + self.screen = Screen::Attract; + } + } + Screen::Attract => self.update_attract(), + Screen::Playing => self.update_game(), + Screen::Help => self.update_help(), + Screen::Settings => self.update_settings(), + Screen::HighScores => self.update_high_scores(), + Screen::NameEntry => self.update_name_entry(), + } + + self.draw_logical(); + self.present(); + } + + fn handle_global_input(&mut self) { + if is_key_pressed(KeyCode::F12) { + self.saved.settings.sounds = !self.saved.settings.sounds; + if self.saved.settings.sounds { + "SOUND ON" + } else { + "SOUND OFF" + } + .clone_into(&mut self.message); + self.message_until = get_time() + 1.2; + self.save(); + } + if is_key_pressed(KeyCode::F1) && self.screen != Screen::NameEntry { + if self.screen == Screen::Help { + self.screen = self.return_screen; + } else { + self.return_screen = self.base_screen(); + self.screen = Screen::Help; + } + } + if is_key_pressed(KeyCode::F2) && self.screen != Screen::NameEntry { + if self.screen == Screen::Settings { + self.screen = self.return_screen; + } else { + self.return_screen = self.base_screen(); + self.screen = Screen::Settings; + } + } + if is_key_pressed(KeyCode::F3) && self.screen != Screen::NameEntry { + if self.screen == Screen::HighScores { + self.screen = self.return_screen; + } else { + self.return_screen = self.base_screen(); + self.screen = Screen::HighScores; + } + } + } + + fn base_screen(&self) -> Screen { + if self.game.is_some() { + Screen::Playing + } else { + Screen::Attract + } + } + + fn update_attract(&mut self) { + if is_key_pressed(KeyCode::KpAdd) || is_key_pressed(KeyCode::Equal) { + self.player_count = self.player_count % 4 + 1; + self.assets.play(2012, self.saved.settings.sounds); + } + if is_key_pressed(KeyCode::Down) || is_key_pressed(KeyCode::Enter) { + self.game = Some(Game::new(self.player_count)); + self.screen = Screen::Playing; + self.assets.play(2008, self.saved.settings.sounds); + } + } + + fn update_game(&mut self) { + let left_flipper = is_key_down(KeyCode::LeftControl) + || is_key_down(KeyCode::A) + || is_key_down(KeyCode::Left); + let right_flipper = is_key_down(KeyCode::KpEnter) + || is_key_down(KeyCode::RightControl) + || is_key_down(KeyCode::D) + || is_key_down(KeyCode::Right); + let nudge = if is_key_pressed(KeyCode::LeftShift) { + -1.0 + } else if is_key_pressed(KeyCode::RightShift) || is_key_pressed(KeyCode::Kp3) { + 1.0 + } else if is_key_pressed(KeyCode::Space) { + if self + .game + .as_ref() + .is_some_and(|game| game.ball.position.x < WIDTH / 4.0) + { + 1.0 + } else { + -1.0 + } + } else { + 0.0 + }; + let controls = Controls { + left_flipper, + right_flipper, + launch_pressed: is_key_pressed(KeyCode::Down), + nudge, + }; + let events = self.game.as_mut().map_or_else(Vec::new, |game| { + game.update(get_frame_time(), self.saved.settings.speed, controls) + }); + for event in events { + self.play_event(event); + } + + if self.game.as_ref().is_some_and(|game| game.finished) { + self.finish_game(); + } + } + + fn play_event(&self, event: Event) { + let id = match event { + Event::Flipper => 2001, + Event::Launch => 2016, + Event::Bumper => 2002, + Event::Target => 2006, + Event::Wheel => 2004, + Event::Robot => 2007, + Event::Lock => 2017, + Event::Media => 2022, + Event::ExtraBall => 2019, + Event::Nudge => 2021, + Event::Drain => 2013, + }; + self.assets.play(id, self.saved.settings.sounds); + } + + fn finish_game(&mut self) { + self.pending_score = self + .game + .as_ref() + .and_then(|game| game.players.iter().map(|player| player.score).max()) + .unwrap_or(0); + let qualifies = self.saved.high_scores.len() < 10 + || self + .saved + .high_scores + .last() + .is_some_and(|entry| self.pending_score > entry.score); + if qualifies { + self.name.clear(); + self.screen = Screen::NameEntry; + } else { + self.game = None; + self.return_screen = Screen::Attract; + self.screen = Screen::HighScores; + } + self.assets.play(2020, self.saved.settings.sounds); + } + + fn update_help(&mut self) { + if is_key_pressed(KeyCode::Escape) || is_key_pressed(KeyCode::Enter) { + self.screen = self.return_screen; + } + if is_mouse_button_pressed(MouseButton::Left) { + let point = Self::logical_mouse(); + if point.x < 145.0 && point.y < 115.0 { + let now = get_time(); + if now - self.last_help_click <= 0.40 { + self.save(); + macroquad::miniquad::window::request_quit(); + } + self.last_help_click = now; + } + } + } + + fn update_settings(&mut self) { + if is_key_pressed(KeyCode::Escape) || is_key_pressed(KeyCode::Enter) { + self.save(); + self.screen = self.return_screen; + return; + } + if is_key_pressed(KeyCode::Up) { + self.setting_row = (self.setting_row + 2) % 3; + } + if is_key_pressed(KeyCode::Down) { + self.setting_row = (self.setting_row + 1) % 3; + } + let direction = + i8::from(is_key_pressed(KeyCode::Right)) - i8::from(is_key_pressed(KeyCode::Left)); + if direction == 0 && !is_key_pressed(KeyCode::Space) { + return; + } + match self.setting_row { + 0 => { + let speed = i16::from(self.saved.settings.speed) + i16::from(direction); + self.saved.settings.speed = u8::try_from(speed.clamp(1, 5)).unwrap_or(3); + } + 1 => { + self.saved.settings.sounds = !self.saved.settings.sounds; + } + _ => { + let current = Language::ALL + .iter() + .position(|language| *language == self.saved.settings.language) + .unwrap_or(0); + let next = if direction < 0 { + (current + Language::ALL.len() - 1) % Language::ALL.len() + } else { + (current + 1) % Language::ALL.len() + }; + self.saved.settings.language = Language::ALL[next]; + } + } + self.assets.play(2012, self.saved.settings.sounds); + self.save(); + } + + fn update_high_scores(&mut self) { + if is_key_pressed(KeyCode::Escape) + || is_key_pressed(KeyCode::Enter) + || is_key_pressed(KeyCode::F3) + { + self.screen = self.return_screen; + } + } + + fn update_name_entry(&mut self) { + while let Some(character) = get_char_pressed() { + if !character.is_control() && self.name.chars().count() < 21 { + self.name.push(character); + } + } + if is_key_pressed(KeyCode::Backspace) { + self.name.pop(); + } + if is_key_pressed(KeyCode::Enter) && !self.name.trim().is_empty() { + insert_high_score( + &mut self.saved.high_scores, + HighScore { + name: self.name.trim().to_owned(), + score: self.pending_score, + }, + ); + self.save(); + self.game = None; + self.return_screen = Screen::Attract; + self.screen = Screen::HighScores; + } + } + + fn draw_logical(&self) { + let mut camera = Camera2D::from_display_rect(Rect::new(0.0, 0.0, WIDTH, HEIGHT)); + camera.render_target = Some(self.render_target.clone()); + set_camera(&camera); + clear_background(BLACK); + + match self.screen { + Screen::Loading => draw_texture(&self.assets.loading, 0.0, 0.0, WHITE), + Screen::Help => { + let index = Language::ALL + .iter() + .position(|language| *language == self.saved.settings.language) + .unwrap_or(0); + draw_texture(&self.assets.help[index], 0.0, 0.0, WHITE); + } + Screen::Attract => self.draw_attract(), + Screen::Playing => self.draw_game(), + Screen::Settings => self.draw_settings(), + Screen::HighScores => self.draw_high_scores(), + Screen::NameEntry => self.draw_name_entry(), + } + if get_time() < self.message_until { + draw_panel(232.0, 205.0, 176.0, 48.0); + draw_centered(&self.message, 229.0, 24, BLACK); + } + } + + fn draw_attract(&self) { + draw_texture(&self.assets.inactive_table, 0.0, 0.0, WHITE); + draw_panel(342.0, 72.0, 267.0, 144.0); + draw_centered_at("TDK PINBALL MACHINE", 475.5, 107.0, 22, BLACK); + draw_centered_at( + "PRESS DOWN ARROW", + 475.5, + 139.0, + 22, + Color::from_rgba(0, 72, 102, 255), + ); + draw_centered_at("TO START BALL", 475.5, 165.0, 18, BLACK); + draw_centered_at( + &format!("PLAYERS: {} [+] CHANGE", self.player_count), + 475.5, + 196.0, + 16, + BLACK, + ); + draw_text( + "F1 HELP F2 SETTINGS F3 HIGHSCORES F12 SOUND", + 322.0, + 448.0, + 10.0, + BLACK, + ); + } + + #[allow(clippy::cast_precision_loss, clippy::too_many_lines)] + fn draw_game(&self) { + draw_texture(&self.assets.inactive_table, 0.0, 0.0, WHITE); + let Some(game) = &self.game else { + return; + }; + + for (index, center) in [vec2(205.0, 47.0), vec2(234.0, 50.0), vec2(262.0, 53.0)] + .into_iter() + .enumerate() + { + if game.top_targets[index] { + draw_line( + center.x - 7.0, + center.y + 9.0, + center.x + 5.0, + center.y - 10.0, + 7.0, + YELLOW, + ); + } + } + let side_colors = [RED, ORANGE, YELLOW, GREEN, SKYBLUE]; + for (index, y) in [180.0, 195.0, 210.0, 225.0, 240.0].into_iter().enumerate() { + let color = if game.side_targets[index] { + side_colors[index] + } else { + Color::from_rgba(38, 38, 38, 255) + }; + draw_circle(286.0, y, 5.0, color); + } + for (index, lit) in game.wheel_holes.iter().enumerate() { + if *lit { + let angle = index as f32 / 8.0 * std::f32::consts::TAU; + draw_circle( + 114.0 + angle.cos() * 29.0, + 79.0 + angle.sin() * 29.0, + 6.0, + YELLOW, + ); + } + } + for index in 0..4 { + draw_circle( + 155.0 + index as f32 * 14.0, + 214.0, + 5.0, + if index < usize::from(game.lock_lights) { + WHITE + } else { + DARKGRAY + }, + ); + } + if game.player().diamond_segments > 0 { + let index = usize::from(game.player().diamond_segments.min(9) - 1); + draw_texture(&self.assets.diamond[index], 123.0, 298.0, WHITE); + } + if game.magnets > 0.0 { + for x in [18.0, 296.0] { + draw_triangle( + vec2(x, 345.0), + vec2(x - 7.0, 359.0), + vec2(x + 7.0, 359.0), + YELLOW, + ); + draw_triangle( + vec2(x, 367.0), + vec2(x - 7.0, 381.0), + vec2(x + 7.0, 381.0), + YELLOW, + ); + } + } + + let (left, right) = game.flippers(); + for flipper in [left, right] { + draw_line( + flipper.start.x, + flipper.start.y, + flipper.end.x, + flipper.end.y, + 10.0, + DARKGRAY, + ); + draw_line( + flipper.start.x, + flipper.start.y - 1.0, + flipper.end.x, + flipper.end.y - 1.0, + 6.0, + LIGHTGRAY, + ); + draw_circle(flipper.start.x, flipper.start.y, 6.0, GRAY); + } + draw_circle(game.ball.position.x, game.ball.position.y, 6.0, DARKGRAY); + draw_circle( + game.ball.position.x - 1.0, + game.ball.position.y - 1.0, + 4.5, + LIGHTGRAY, + ); + draw_circle( + game.ball.position.x - 2.0, + game.ball.position.y - 2.0, + 1.5, + WHITE, + ); + + self.draw_displays(game); + if game.tilt > 1.15 { + draw_centered("TILT", 263.0, 34, RED); + } + draw_text("F1 HELP F2 SETUP", 345.0, 448.0, 11.0, BLACK); + } + + #[allow(clippy::cast_precision_loss)] + fn draw_displays(&self, game: &Game) { + draw_rectangle( + 371.0, + 235.0, + 222.0, + 31.0, + Color::from_rgba(0, 191, 209, 255), + ); + draw_text( + format!("BONUS {:07}", game.bonus), + 374.0, + 258.0, + 24.0, + BLACK, + ); + + let media_level = game.player().media_level; + if media_level > 0 { + let index = usize::from(media_level.saturating_sub(1).min(3)); + draw_texture(&self.assets.media[index], 380.0, 72.0, WHITE); + } + draw_text( + format!("{:07} KBytes", game.player().score / 1_000), + 395.0, + 211.0, + 16.0, + WHITE, + ); + + for (index, player) in game.players.iter().enumerate() { + let y = 274.0 + index as f32 * 44.0; + let panel = if index == game.current_player { + Color::from_rgba(0, 191, 209, 255) + } else { + Color::from_rgba(36, 82, 137, 255) + }; + draw_rectangle(472.0, y, 121.0, 30.0, panel); + draw_text(format!("{:09}", player.score), 478.0, y + 21.0, 18.0, BLACK); + for ball in 0..6 { + let lit = ball < usize::from(player.balls + player.extra_balls); + draw_circle( + 397.0 + ball as f32 * 8.0, + y + 27.0, + 2.4, + if lit { GREEN } else { DARKGRAY }, + ); + } + } + } + + #[allow(clippy::cast_precision_loss)] + fn draw_settings(&self) { + draw_texture(&self.assets.inactive_table, 0.0, 0.0, WHITE); + draw_panel(145.0, 82.0, 350.0, 285.0); + draw_centered("SETTINGS", 122.0, 30, BLACK); + let rows = [ + format!("GRAPHICS DETAIL / SPEED: {}", self.saved.settings.speed), + format!( + "SOUND: {}", + if self.saved.settings.sounds { + "ON" + } else { + "OFF" + } + ), + format!("LANGUAGE: {}", self.saved.settings.language.label()), + ]; + for (index, row) in rows.iter().enumerate() { + let y = 181.0 + index as f32 * 52.0; + if index == self.setting_row { + draw_rectangle( + 174.0, + y - 27.0, + 292.0, + 38.0, + Color::from_rgba(0, 191, 209, 255), + ); + } + draw_centered(row, y, 19, BLACK); + } + draw_centered("UP/DOWN SELECT LEFT/RIGHT CHANGE", 328.0, 14, BLACK); + draw_centered("ENTER OR ESC TO RETURN", 349.0, 14, BLACK); + } + + #[allow(clippy::cast_precision_loss)] + fn draw_high_scores(&self) { + draw_texture(&self.assets.inactive_table, 0.0, 0.0, WHITE); + draw_panel(124.0, 32.0, 392.0, 398.0); + draw_centered("TDK HIGHSCORES", 72.0, 28, BLACK); + for (index, entry) in self.saved.high_scores.iter().take(10).enumerate() { + let y = 110.0 + index as f32 * 29.0; + draw_text(format!("{:>2}.", index + 1), 153.0, y, 17.0, BLACK); + draw_text(&entry.name, 190.0, y, 17.0, BLACK); + let score = format!("{:09}", entry.score); + draw_text(&score, 389.0, y, 17.0, Color::from_rgba(0, 72, 102, 255)); + } + draw_centered("ENTER / ESC TO RETURN", 409.0, 14, BLACK); + } + + fn draw_name_entry(&self) { + draw_texture(&self.assets.active_table, 0.0, 0.0, WHITE); + draw_panel(112.0, 145.0, 416.0, 170.0); + draw_centered("NEW TDK HIGHSCORE", 183.0, 28, BLACK); + draw_centered( + &format!("SCORE {:09}", self.pending_score), + 218.0, + 21, + BLACK, + ); + draw_rectangle( + 153.0, + 235.0, + 334.0, + 38.0, + Color::from_rgba(0, 191, 209, 255), + ); + draw_text( + format!( + "{}{}", + self.name, + if get_time().rem_euclid(1.0) < 0.5 { + "_" + } else { + "" + } + ), + 164.0, + 262.0, + 23.0, + BLACK, + ); + draw_centered("TYPE YOUR NAME, THEN PRESS ENTER", 296.0, 14, BLACK); + } + + fn present(&self) { + set_default_camera(); + clear_background(BLACK); + let scale = (screen_width() / WIDTH).min(screen_height() / HEIGHT); + let width = WIDTH * scale; + let height = HEIGHT * scale; + let x = (screen_width() - width) * 0.5; + let y = (screen_height() - height) * 0.5; + draw_texture_ex( + &self.render_target.texture, + x, + y, + WHITE, + DrawTextureParams { + dest_size: Some(vec2(width, height)), + flip_y: true, + ..Default::default() + }, + ); + } + + fn logical_mouse() -> Vec2 { + let scale = (screen_width() / WIDTH).min(screen_height() / HEIGHT); + let width = WIDTH * scale; + let height = HEIGHT * scale; + let offset = vec2( + (screen_width() - width) * 0.5, + (screen_height() - height) * 0.5, + ); + (vec2(mouse_position().0, mouse_position().1) - offset) / scale + } + + fn save(&self) { + if let Err(error) = self.persistence.save(&self.saved) { + eprintln!("could not save settings and highscores: {error}"); + } + } +} + +fn draw_panel(x: f32, y: f32, width: f32, height: f32) { + draw_rectangle(x, y, width, height, Color::from_rgba(214, 214, 214, 248)); + draw_rectangle_lines(x, y, width, height, 3.0, WHITE); + draw_rectangle_lines(x + 4.0, y + 4.0, width - 8.0, height - 8.0, 2.0, DARKGRAY); +} + +fn draw_centered(text: &str, baseline: f32, font_size: u16, color: Color) { + draw_centered_at(text, WIDTH * 0.5, baseline, font_size, color); +} + +fn draw_centered_at(text: &str, center_x: f32, baseline: f32, font_size: u16, color: Color) { + let dimensions = measure_text(text, None, font_size, 1.0); + draw_text( + text, + center_x - dimensions.width * 0.5, + baseline, + f32::from(font_size), + color, + ); +} diff --git a/tdkpin-rs/src/assets.rs b/tdkpin-rs/src/assets.rs new file mode 100644 index 0000000..8c1a11b --- /dev/null +++ b/tdkpin-rs/src/assets.rs @@ -0,0 +1,151 @@ +use macroquad::{ + audio::{PlaySoundParams, Sound, load_sound_from_bytes, play_sound}, + prelude::{FilterMode, Texture2D}, +}; + +pub struct Assets { + pub active_table: Texture2D, + pub inactive_table: Texture2D, + pub loading: Texture2D, + pub help: [Texture2D; 5], + pub media: [Texture2D; 4], + pub diamond: [Texture2D; 9], + sounds: Vec<(u16, Sound)>, +} + +impl Assets { + #[allow(clippy::too_many_lines)] + pub async fn load() -> Self { + let active_table = texture(include_bytes!("../assets/original/images/dat_00997.png")); + let inactive_table = texture(include_bytes!("../assets/original/images/dat_00998.png")); + let loading = texture(include_bytes!("../assets/original/images/dat_00995.png")); + let help = [ + texture(include_bytes!("../assets/original/images/dat_01001.png")), + texture(include_bytes!("../assets/original/images/dat_01002.png")), + texture(include_bytes!("../assets/original/images/dat_01003.png")), + texture(include_bytes!("../assets/original/images/dat_01004.png")), + texture(include_bytes!("../assets/original/images/dat_01005.png")), + ]; + let media = [ + texture(include_bytes!("../assets/original/images/dat_00701.png")), + texture(include_bytes!("../assets/original/images/dat_00702.png")), + texture(include_bytes!("../assets/original/images/dat_00703.png")), + texture(include_bytes!("../assets/original/images/dat_00704.png")), + ]; + let diamond = [ + texture(include_bytes!("../assets/original/images/dat_00801.png")), + texture(include_bytes!("../assets/original/images/dat_00802.png")), + texture(include_bytes!("../assets/original/images/dat_00803.png")), + texture(include_bytes!("../assets/original/images/dat_00804.png")), + texture(include_bytes!("../assets/original/images/dat_00805.png")), + texture(include_bytes!("../assets/original/images/dat_00806.png")), + texture(include_bytes!("../assets/original/images/dat_00807.png")), + texture(include_bytes!("../assets/original/images/dat_00808.png")), + texture(include_bytes!("../assets/original/images/dat_00809.png")), + ]; + + let sound_bytes: [(u16, &[u8]); 16] = [ + ( + 2001, + include_bytes!("../assets/original/audio/wav_02001.wav"), + ), + ( + 2002, + include_bytes!("../assets/original/audio/wav_02002.wav"), + ), + ( + 2004, + include_bytes!("../assets/original/audio/wav_02004.wav"), + ), + ( + 2006, + include_bytes!("../assets/original/audio/wav_02006.wav"), + ), + ( + 2007, + include_bytes!("../assets/original/audio/wav_02007.wav"), + ), + ( + 2008, + include_bytes!("../assets/original/audio/wav_02008.wav"), + ), + ( + 2011, + include_bytes!("../assets/original/audio/wav_02011.wav"), + ), + ( + 2012, + include_bytes!("../assets/original/audio/wav_02012.wav"), + ), + ( + 2013, + include_bytes!("../assets/original/audio/wav_02013.wav"), + ), + ( + 2015, + include_bytes!("../assets/original/audio/wav_02015.wav"), + ), + ( + 2016, + include_bytes!("../assets/original/audio/wav_02016.wav"), + ), + ( + 2017, + include_bytes!("../assets/original/audio/wav_02017.wav"), + ), + ( + 2019, + include_bytes!("../assets/original/audio/wav_02019.wav"), + ), + ( + 2020, + include_bytes!("../assets/original/audio/wav_02020.wav"), + ), + ( + 2021, + include_bytes!("../assets/original/audio/wav_02021.wav"), + ), + ( + 2022, + include_bytes!("../assets/original/audio/wav_02022.wav"), + ), + ]; + let mut sounds = Vec::with_capacity(sound_bytes.len()); + for (id, bytes) in sound_bytes { + if let Ok(sound) = load_sound_from_bytes(bytes).await { + sounds.push((id, sound)); + } + } + + Self { + active_table, + inactive_table, + loading, + help, + media, + diamond, + sounds, + } + } + + pub fn play(&self, id: u16, enabled: bool) { + if !enabled { + return; + } + if let Some((_, sound)) = self.sounds.iter().find(|(sound_id, _)| *sound_id == id) { + play_sound( + sound, + PlaySoundParams { + looped: false, + volume: 0.72, + }, + ); + } + } +} + +fn texture(bytes: &[u8]) -> Texture2D { + let texture = Texture2D::from_file_with_format(bytes, None); + texture.set_filter(FilterMode::Nearest); + texture +} diff --git a/tdkpin-rs/src/game.rs b/tdkpin-rs/src/game.rs new file mode 100644 index 0000000..07b4309 --- /dev/null +++ b/tdkpin-rs/src/game.rs @@ -0,0 +1,556 @@ +use crate::geometry::{Segment, circle_collision, segment_collision}; +use macroquad::prelude::{Vec2, vec2}; +use std::sync::LazyLock; + +const BALL_RADIUS: f32 = 5.0; +const GRAVITY: f32 = 135.0; +const MAX_SPEED: f32 = 430.0; + +#[derive(Clone, Copy, Debug, Default)] +pub struct Controls { + pub left_flipper: bool, + pub right_flipper: bool, + pub launch_pressed: bool, + pub nudge: f32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Event { + Flipper, + Launch, + Bumper, + Target, + Wheel, + Robot, + Lock, + Media, + ExtraBall, + Nudge, + Drain, +} + +#[derive(Clone, Debug)] +pub struct Player { + pub score: u32, + pub balls: u8, + pub extra_balls: u8, + pub bumper_value: u32, + pub diamond_segments: u8, + pub media_level: u8, +} + +impl Default for Player { + fn default() -> Self { + Self { + score: 0, + balls: 3, + extra_balls: 0, + bumper_value: 1_000, + diamond_segments: 0, + media_level: 0, + } + } +} + +#[derive(Clone, Copy, Debug)] +pub struct Ball { + pub position: Vec2, + pub velocity: Vec2, + pub in_launcher: bool, +} + +impl Default for Ball { + fn default() -> Self { + Self { + position: vec2(157.0, 426.0), + velocity: Vec2::ZERO, + in_launcher: true, + } + } +} + +#[derive(Debug)] +pub struct Game { + pub players: Vec, + pub current_player: usize, + pub ball: Ball, + pub bonus: u32, + pub wheel_holes: [bool; 8], + pub side_targets: [bool; 5], + pub top_targets: [bool; 3], + pub lock_lights: u8, + pub magnets: f32, + pub tilt: f32, + pub left_flipper_angle: f32, + pub right_flipper_angle: f32, + pub finished: bool, + accumulator: f32, + target_cooldown: f32, + bumper_cooldown: f32, + nudge_cooldown: f32, +} + +impl Game { + pub fn new(player_count: usize) -> Self { + Self { + players: vec![Player::default(); player_count.clamp(1, 4)], + current_player: 0, + ball: Ball::default(), + bonus: 0, + wheel_holes: [false; 8], + side_targets: [false; 5], + top_targets: [false; 3], + lock_lights: 0, + magnets: 0.0, + tilt: 0.0, + left_flipper_angle: -2.72, + right_flipper_angle: -0.42, + finished: false, + accumulator: 0.0, + target_cooldown: 0.0, + bumper_cooldown: 0.0, + nudge_cooldown: 0.0, + } + } + + pub fn player(&self) -> &Player { + &self.players[self.current_player] + } + + pub fn update(&mut self, frame_time: f32, detail: u8, controls: Controls) -> Vec { + if self.finished { + return Vec::new(); + } + let mut events = Vec::new(); + let desired_left = if controls.left_flipper { -2.18 } else { -2.72 }; + let desired_right = if controls.right_flipper { -0.96 } else { -0.42 }; + let flipper_rate = 14.0 * frame_time; + let old_left = self.left_flipper_angle; + let old_right = self.right_flipper_angle; + self.left_flipper_angle += + (desired_left - self.left_flipper_angle).clamp(-flipper_rate, flipper_rate); + self.right_flipper_angle += + (desired_right - self.right_flipper_angle).clamp(-flipper_rate, flipper_rate); + if (controls.left_flipper && old_left < -2.68) + || (controls.right_flipper && old_right > -0.46) + { + events.push(Event::Flipper); + } + + if controls.launch_pressed && self.ball.in_launcher { + self.ball.in_launcher = false; + self.ball.velocity = vec2(12.0, -330.0); + events.push(Event::Launch); + } + if controls.nudge.abs() > 0.1 && self.nudge_cooldown <= 0.0 { + self.ball.velocity.x += controls.nudge * 55.0; + self.tilt += controls.nudge.abs() * 0.34; + self.nudge_cooldown = 0.22; + events.push(Event::Nudge); + } + + self.accumulator = (self.accumulator + frame_time.min(0.05)).min(0.1); + let steps_per_second = + [60.0, 90.0, 120.0, 180.0, 240.0][usize::from(detail.clamp(1, 5) - 1)]; + let step = 1.0 / steps_per_second; + while self.accumulator >= step { + self.fixed_update(step, &mut events); + self.accumulator -= step; + } + events + } + + fn fixed_update(&mut self, dt: f32, events: &mut Vec) { + self.target_cooldown = (self.target_cooldown - dt).max(0.0); + self.bumper_cooldown = (self.bumper_cooldown - dt).max(0.0); + self.nudge_cooldown = (self.nudge_cooldown - dt).max(0.0); + self.magnets = (self.magnets - dt).max(0.0); + self.tilt = (self.tilt - dt * 0.08).max(0.0); + + if self.ball.in_launcher { + self.ball.position = vec2(157.0, 426.0); + self.ball.velocity = Vec2::ZERO; + return; + } + + self.ball.velocity.y += GRAVITY * dt; + self.ball.velocity *= 1.0 - dt * 0.055; + self.ball.velocity = self.ball.velocity.clamp_length_max(MAX_SPEED); + self.ball.position += self.ball.velocity * dt; + + for wall in table_walls() { + segment_collision( + &mut self.ball.position, + &mut self.ball.velocity, + BALL_RADIUS, + *wall, + ); + } + + let left_flipper = flipper_segment(vec2(103.0, 394.0), self.left_flipper_angle); + let right_flipper = flipper_segment(vec2(211.0, 394.0), self.right_flipper_angle); + if segment_collision( + &mut self.ball.position, + &mut self.ball.velocity, + BALL_RADIUS + 2.0, + left_flipper, + ) && self.left_flipper_angle > -2.55 + { + self.ball.velocity += vec2(-20.0, -115.0); + } + if segment_collision( + &mut self.ball.position, + &mut self.ball.velocity, + BALL_RADIUS + 2.0, + right_flipper, + ) && self.right_flipper_angle < -0.58 + { + self.ball.velocity += vec2(20.0, -115.0); + } + + for center in [vec2(207.0, 109.0), vec2(167.0, 148.0), vec2(219.0, 167.0)] { + if circle_collision( + &mut self.ball.position, + &mut self.ball.velocity, + BALL_RADIUS, + center, + 14.0, + 105.0, + ) && self.bumper_cooldown <= 0.0 + { + self.add_score(self.player().bumper_value); + self.bonus = self.bonus.saturating_add(100); + self.bumper_cooldown = 0.08; + events.push(Event::Bumper); + } + } + + self.check_targets(events); + self.check_media(events); + + if self.ball.position.y > 454.0 { + if self.magnets > 0.0 && (self.ball.position.x < 145.0 || self.ball.position.x > 175.0) + { + self.ball.position.y = 410.0; + self.ball.velocity = vec2((157.0 - self.ball.position.x) * 2.0, -245.0); + self.magnets = 0.0; + } else { + self.drain(events); + } + } + } + + fn check_targets(&mut self, events: &mut Vec) { + if self.target_cooldown > 0.0 { + return; + } + let position = self.ball.position; + + let top = [vec2(205.0, 47.0), vec2(234.0, 50.0), vec2(262.0, 53.0)]; + for (index, center) in top.into_iter().enumerate() { + if position.distance_squared(center) < 11.0_f32.powi(2) && !self.top_targets[index] { + self.top_targets[index] = true; + self.add_score(1_500); + self.ball.velocity.y = self.ball.velocity.y.abs() + 65.0; + self.target_cooldown = 0.12; + events.push(Event::Target); + } + } + if self.top_targets.iter().all(|target| *target) { + self.top_targets.fill(false); + self.magnets = 12.0; + self.add_score(5_000); + } + + let side = [180.0, 195.0, 210.0, 225.0, 240.0]; + for (index, y) in side.into_iter().enumerate() { + if position.distance_squared(vec2(286.0, y)) < 9.0_f32.powi(2) + && !self.side_targets[index] + { + self.side_targets[index] = true; + self.add_score(2_000 + u32::try_from(index).unwrap_or(0) * 1_000); + self.ball.velocity.x = -self.ball.velocity.x.abs() - 55.0; + self.target_cooldown = 0.10; + events.push(Event::Target); + } + } + if self.side_targets.iter().all(|target| *target) { + self.side_targets.fill(false); + let player = &mut self.players[self.current_player]; + player.bumper_value = (player.bumper_value + 1_000).min(10_000); + player.diamond_segments = (player.diamond_segments + 1).min(9); + self.add_score(10_000); + } + + let wheel_center = vec2(114.0, 79.0); + let from_wheel = position - wheel_center; + if (25.0..=43.0).contains(&from_wheel.length()) { + let angle = + (from_wheel.y.atan2(from_wheel.x) + std::f32::consts::TAU) % std::f32::consts::TAU; + let sector_boundaries = [ + std::f32::consts::TAU / 16.0, + std::f32::consts::TAU * 3.0 / 16.0, + std::f32::consts::TAU * 5.0 / 16.0, + std::f32::consts::TAU * 7.0 / 16.0, + std::f32::consts::TAU * 9.0 / 16.0, + std::f32::consts::TAU * 11.0 / 16.0, + std::f32::consts::TAU * 13.0 / 16.0, + std::f32::consts::TAU * 15.0 / 16.0, + ]; + let hole = sector_boundaries.partition_point(|boundary| angle >= *boundary) % 8; + if !self.wheel_holes[hole] { + self.wheel_holes[hole] = true; + self.add_score(2_500); + self.target_cooldown = 0.15; + events.push(Event::Wheel); + } + if self.wheel_holes.iter().all(|hole| *hole) { + self.wheel_holes.fill(false); + self.add_score(25_000); + } + } + + if (270.0..=302.0).contains(&position.x) && (45.0..=105.0).contains(&position.y) { + self.add_score(7_500); + self.ball.velocity = vec2(-125.0, 60.0); + self.target_cooldown = 0.3; + events.push(Event::Robot); + } + + if (151.0..=220.0).contains(&position.x) && (187.0..=205.0).contains(&position.y) { + self.lock_lights = (self.lock_lights + 1).min(4); + self.add_score(u32::from(self.lock_lights) * 5_000); + self.ball.position = vec2(185.0, 181.0); + self.ball.velocity = vec2(-40.0 + f32::from(self.lock_lights) * 18.0, -120.0); + self.target_cooldown = 0.4; + events.push(Event::Lock); + } + + for (center, value) in [ + (vec2(113.0, 285.0), 2_000), + (vec2(132.0, 270.0), 3_000), + (vec2(155.0, 258.0), 4_000), + (vec2(181.0, 270.0), 5_000), + (vec2(201.0, 285.0), 6_000), + ] { + if position.distance_squared(center) < 10.0_f32.powi(2) { + self.add_score(value); + self.ball.velocity.y = -self.ball.velocity.y.abs() - 40.0; + self.target_cooldown = 0.13; + events.push(Event::Target); + break; + } + } + } + + fn check_media(&mut self, events: &mut Vec) { + const THRESHOLDS: [u32; 5] = [360_000, 720_000, 1_440_000, 2_880_000, 5_760_000]; + let score = self.player().score; + let level = + u8::try_from(THRESHOLDS.partition_point(|threshold| score >= *threshold)).unwrap_or(5); + if level > self.player().media_level { + let player = &mut self.players[self.current_player]; + player.media_level = level; + player.extra_balls = player.extra_balls.saturating_add(1); + events.push(Event::Media); + events.push(Event::ExtraBall); + } + } + + fn add_score(&mut self, points: u32) { + let multiplier = if self.player().diamond_segments == 9 { + 2 + } else { + 1 + }; + let player = &mut self.players[self.current_player]; + player.score = player + .score + .saturating_add(points.saturating_mul(multiplier)); + } + + fn drain(&mut self, events: &mut Vec) { + let player = &mut self.players[self.current_player]; + player.score = player.score.saturating_add(self.bonus); + if player.extra_balls > 0 { + player.extra_balls -= 1; + } else { + player.balls = player.balls.saturating_sub(1); + } + events.push(Event::Drain); + self.bonus = 0; + self.side_targets.fill(false); + self.top_targets.fill(false); + self.wheel_holes.fill(false); + self.lock_lights = 0; + self.magnets = 0.0; + self.tilt = 0.0; + + let mut next = (self.current_player + 1) % self.players.len(); + for _ in 0..self.players.len() { + if self.players[next].balls > 0 || self.players[next].extra_balls > 0 { + self.current_player = next; + self.ball = Ball::default(); + return; + } + next = (next + 1) % self.players.len(); + } + self.finished = true; + } + + pub fn flippers(&self) -> (Segment, Segment) { + ( + flipper_segment(vec2(103.0, 394.0), self.left_flipper_angle), + flipper_segment(vec2(211.0, 394.0), self.right_flipper_angle), + ) + } +} + +fn flipper_segment(pivot: Vec2, angle: f32) -> Segment { + Segment::new(pivot, pivot + vec2(angle.cos(), angle.sin()) * 49.0, 0.88) +} + +#[allow(clippy::too_many_lines)] +fn table_walls() -> &'static [Segment] { + static WALLS: LazyLock> = LazyLock::new(|| { + vec![ + Segment::new(Vec2::new(125.0, 21.0), Vec2::new(7.0, 61.0), 0.82), + Segment::new(Vec2::new(300.0, 3.0), Vec2::new(10.0, 3.0), 0.82), + Segment::new(Vec2::new(120.0, 6.0), Vec2::new(120.0, 26.0), 0.82), + Segment::new(Vec2::new(195.0, 26.0), Vec2::new(195.0, 6.0), 0.82), + Segment::new(Vec2::new(134.0, 33.0), Vec2::new(134.0, 16.0), 0.82), + Segment::new(Vec2::new(148.0, 11.0), Vec2::new(148.0, 33.0), 0.82), + Segment::new(Vec2::new(164.0, 12.0), Vec2::new(165.0, 33.0), 0.82), + Segment::new(Vec2::new(180.0, 11.0), Vec2::new(179.0, 33.0), 0.82), + Segment::new(Vec2::new(304.0, 64.0), Vec2::new(183.0, 21.0), 0.82), + Segment::new(Vec2::new(298.0, 417.0), Vec2::new(298.0, 313.0), 0.82), + Segment::new(Vec2::new(298.0, 336.0), Vec2::new(310.0, 336.0), 0.82), + Segment::new(Vec2::new(292.0, 374.0), Vec2::new(291.0, 342.0), 0.82), + Segment::new(Vec2::new(281.0, 378.0), Vec2::new(300.0, 369.0), 0.82), + Segment::new(Vec2::new(275.0, 343.0), Vec2::new(290.0, 382.0), 0.82), + Segment::new(Vec2::new(248.0, 364.0), Vec2::new(264.0, 339.0), 0.82), + Segment::new(Vec2::new(257.0, 387.0), Vec2::new(249.0, 364.0), 0.82), + Segment::new(Vec2::new(277.0, 429.0), Vec2::new(257.0, 387.0), 0.82), + Segment::new(Vec2::new(287.0, 446.0), Vec2::new(277.0, 427.0), 0.82), + Segment::new(Vec2::new(303.0, 430.0), Vec2::new(283.0, 436.0), 0.82), + Segment::new(Vec2::new(320.0, 410.0), Vec2::new(302.0, 430.0), 0.82), + Segment::new(Vec2::new(320.0, 21.0), Vec2::new(320.0, 410.0), 0.82), + Segment::new(Vec2::new(307.0, 437.0), Vec2::new(316.0, 430.0), 0.82), + Segment::new(Vec2::new(296.0, 442.0), Vec2::new(307.0, 437.0), 0.82), + Segment::new(Vec2::new(285.0, 443.0), Vec2::new(296.0, 442.0), 0.82), + Segment::new(Vec2::new(112.0, 443.0), Vec2::new(285.0, 443.0), 0.82), + Segment::new(Vec2::new(99.0, 438.0), Vec2::new(121.0, 444.0), 0.82), + Segment::new(Vec2::new(78.0, 427.0), Vec2::new(100.0, 438.0), 0.82), + Segment::new(Vec2::new(66.0, 409.0), Vec2::new(79.0, 428.0), 0.82), + Segment::new(Vec2::new(57.0, 385.0), Vec2::new(68.0, 413.0), 0.82), + Segment::new(Vec2::new(59.0, 304.0), Vec2::new(59.0, 396.0), 0.82), + Segment::new(Vec2::new(67.0, 288.0), Vec2::new(58.0, 311.0), 0.82), + Segment::new(Vec2::new(41.0, 268.0), Vec2::new(64.0, 279.0), 0.82), + Segment::new(Vec2::new(22.0, 293.0), Vec2::new(31.0, 272.0), 0.82), + Segment::new(Vec2::new(22.0, 447.0), Vec2::new(22.0, 293.0), 0.82), + Segment::new(Vec2::new(11.0, 445.0), Vec2::new(23.0, 445.0), 0.82), + Segment::new(Vec2::new(13.0, 288.0), Vec2::new(13.0, 448.0), 0.82), + Segment::new(Vec2::new(32.0, 228.0), Vec2::new(12.0, 293.0), 0.82), + Segment::new(Vec2::new(10.0, 158.0), Vec2::new(33.0, 227.0), 0.82), + Segment::new(Vec2::new(12.0, 51.0), Vec2::new(12.0, 197.0), 0.82), + Segment::new(Vec2::new(37.0, 111.0), Vec2::new(37.0, 166.0), 0.82), + Segment::new(Vec2::new(110.0, 74.0), Vec2::new(37.0, 111.0), 0.82), + Segment::new(Vec2::new(19.0, 73.0), Vec2::new(92.0, 50.0), 0.82), + Segment::new(Vec2::new(19.0, 167.0), Vec2::new(19.0, 73.0), 0.82), + Segment::new(Vec2::new(141.0, 45.0), Vec2::new(112.0, 72.0), 0.82), + Segment::new(Vec2::new(97.0, 47.0), Vec2::new(130.0, 31.0), 0.82), + Segment::new(Vec2::new(0.0, 433.0), Vec2::new(7.0, 478.0), 0.82), + Segment::new(Vec2::new(292.0, 73.0), Vec2::new(292.0, 167.0), 0.82), + Segment::new(Vec2::new(221.0, 50.0), Vec2::new(292.0, 73.0), 0.82), + Segment::new(Vec2::new(275.0, 111.0), Vec2::new(205.0, 74.0), 0.82), + Segment::new(Vec2::new(275.0, 166.0), Vec2::new(275.0, 111.0), 0.82), + Segment::new(Vec2::new(177.0, 31.0), Vec2::new(216.0, 47.0), 0.82), + Segment::new(Vec2::new(201.0, 72.0), Vec2::new(172.0, 45.0), 0.82), + Segment::new(Vec2::new(40.0, 257.0), Vec2::new(73.0, 273.0), 0.82), + Segment::new(Vec2::new(73.0, 273.0), Vec2::new(67.0, 285.0), 0.82), + Segment::new(Vec2::new(34.0, 269.0), Vec2::new(40.0, 257.0), 0.82), + Segment::new(Vec2::new(292.0, 231.0), Vec2::new(292.0, 205.0), 0.82), + Segment::new(Vec2::new(292.0, 205.0), Vec2::new(302.0, 205.0), 0.82), + Segment::new(Vec2::new(302.0, 231.0), Vec2::new(292.0, 231.0), 0.82), + Segment::new(Vec2::new(292.0, 247.0), Vec2::new(292.0, 222.0), 0.82), + Segment::new(Vec2::new(292.0, 222.0), Vec2::new(302.0, 222.0), 0.82), + Segment::new(Vec2::new(302.0, 247.0), Vec2::new(292.0, 247.0), 0.82), + Segment::new(Vec2::new(292.0, 261.0), Vec2::new(292.0, 237.0), 0.82), + Segment::new(Vec2::new(292.0, 237.0), Vec2::new(302.0, 237.0), 0.82), + Segment::new(Vec2::new(302.0, 261.0), Vec2::new(292.0, 261.0), 0.82), + Segment::new(Vec2::new(292.0, 277.0), Vec2::new(292.0, 252.0), 0.82), + Segment::new(Vec2::new(292.0, 252.0), Vec2::new(302.0, 252.0), 0.82), + Segment::new(Vec2::new(302.0, 276.0), Vec2::new(292.0, 276.0), 0.82), + Segment::new(Vec2::new(292.0, 291.0), Vec2::new(292.0, 268.0), 0.82), + Segment::new(Vec2::new(292.0, 269.0), Vec2::new(302.0, 268.0), 0.82), + Segment::new(Vec2::new(302.0, 295.0), Vec2::new(292.0, 290.0), 0.82), + Segment::new(Vec2::new(148.0, 254.0), Vec2::new(203.0, 236.0), 0.82), + Segment::new(Vec2::new(215.0, 261.0), Vec2::new(158.0, 280.0), 0.82), + Segment::new(Vec2::new(187.0, 232.0), Vec2::new(209.0, 225.0), 0.82), + Segment::new(Vec2::new(209.0, 225.0), Vec2::new(214.0, 240.0), 0.82), + Segment::new(Vec2::new(191.0, 244.0), Vec2::new(187.0, 232.0), 0.82), + Segment::new(Vec2::new(169.0, 239.0), Vec2::new(198.0, 229.0), 0.82), + Segment::new(Vec2::new(198.0, 229.0), Vec2::new(202.0, 241.0), 0.82), + Segment::new(Vec2::new(173.0, 251.0), Vec2::new(169.0, 239.0), 0.82), + Segment::new(Vec2::new(153.0, 245.0), Vec2::new(182.0, 235.0), 0.82), + Segment::new(Vec2::new(182.0, 235.0), Vec2::new(186.0, 247.0), 0.82), + Segment::new(Vec2::new(157.0, 257.0), Vec2::new(153.0, 245.0), 0.82), + Segment::new(Vec2::new(139.0, 252.0), Vec2::new(163.0, 241.0), 0.82), + Segment::new(Vec2::new(163.0, 241.0), Vec2::new(167.0, 253.0), 0.82), + Segment::new(Vec2::new(144.0, 267.0), Vec2::new(139.0, 252.0), 0.82), + Segment::new(Vec2::new(84.0, 318.0), Vec2::new(105.0, 329.0), 0.82), + Segment::new(Vec2::new(105.0, 329.0), Vec2::new(101.0, 339.0), 0.82), + Segment::new(Vec2::new(80.0, 328.0), Vec2::new(84.0, 318.0), 0.82), + Segment::new(Vec2::new(87.0, 327.0), Vec2::new(111.0, 339.0), 0.82), + Segment::new(Vec2::new(77.0, 370.0), Vec2::new(77.0, 331.0), 0.82), + Segment::new(Vec2::new(87.0, 372.0), Vec2::new(77.0, 370.0), 0.82), + Segment::new(Vec2::new(110.0, 337.0), Vec2::new(87.0, 372.0), 0.82), + Segment::new(Vec2::new(184.0, 426.0), Vec2::new(172.0, 403.0), 0.82), + Segment::new(Vec2::new(193.0, 396.0), Vec2::new(205.0, 417.0), 0.82), + Segment::new(Vec2::new(213.0, 426.0), Vec2::new(201.0, 403.0), 0.82), + Segment::new(Vec2::new(222.0, 396.0), Vec2::new(234.0, 417.0), 0.82), + Segment::new(Vec2::new(242.0, 426.0), Vec2::new(230.0, 403.0), 0.82), + Segment::new(Vec2::new(251.0, 396.0), Vec2::new(263.0, 417.0), 0.82), + Segment::new(Vec2::new(291.0, 205.0), Vec2::new(298.0, 187.0), 0.82), + Segment::new(Vec2::new(298.0, 378.0), Vec2::new(298.0, 58.0), 0.82), + Segment::new(Vec2::new(298.0, 288.0), Vec2::new(298.0, 211.0), 0.82), + Segment::new(Vec2::new(298.0, 313.0), Vec2::new(291.0, 290.0), 0.82), + ] + }); + &WALLS +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn player_count_is_bounded() { + assert_eq!(Game::new(0).players.len(), 1); + assert_eq!(Game::new(99).players.len(), 4); + } + + #[test] + fn launch_is_deterministic() { + let mut game = Game::new(1); + let events = game.update( + 1.0 / 60.0, + 3, + Controls { + launch_pressed: true, + ..Controls::default() + }, + ); + assert!(events.contains(&Event::Launch)); + assert!(!game.ball.in_launcher); + assert!(game.ball.velocity.y < 0.0); + } + + #[test] + fn completed_diamond_doubles_score() { + let mut game = Game::new(1); + game.players[0].diamond_segments = 9; + game.add_score(1_000); + assert_eq!(game.players[0].score, 2_000); + } +} diff --git a/tdkpin-rs/src/geometry.rs b/tdkpin-rs/src/geometry.rs new file mode 100644 index 0000000..bd6c734 --- /dev/null +++ b/tdkpin-rs/src/geometry.rs @@ -0,0 +1,106 @@ +use macroquad::prelude::Vec2; + +#[derive(Clone, Copy, Debug)] +pub struct Segment { + pub start: Vec2, + pub end: Vec2, + pub bounce: f32, +} + +impl Segment { + pub const fn new(start: Vec2, end: Vec2, bounce: f32) -> Self { + Self { start, end, bounce } + } +} + +pub fn closest_point(point: Vec2, segment: Segment) -> Vec2 { + let line = segment.end - segment.start; + let length_squared = line.length_squared(); + if length_squared <= f32::EPSILON { + return segment.start; + } + let t = ((point - segment.start).dot(line) / length_squared).clamp(0.0, 1.0); + segment.start + line * t +} + +pub fn segment_collision( + position: &mut Vec2, + velocity: &mut Vec2, + radius: f32, + segment: Segment, +) -> bool { + let point = closest_point(*position, segment); + let offset = *position - point; + let distance_squared = offset.length_squared(); + if distance_squared >= radius * radius { + return false; + } + + let normal = if distance_squared > 0.000_001 { + offset / distance_squared.sqrt() + } else { + let line = segment.end - segment.start; + Vec2::new(-line.y, line.x).normalize_or_zero() + }; + *position = point + normal * radius; + let inward_speed = velocity.dot(normal); + if inward_speed < 0.0 { + *velocity -= normal * inward_speed * (1.0 + segment.bounce); + } + true +} + +pub fn circle_collision( + position: &mut Vec2, + velocity: &mut Vec2, + radius: f32, + center: Vec2, + obstacle_radius: f32, + kick: f32, +) -> bool { + let offset = *position - center; + let minimum = radius + obstacle_radius; + if offset.length_squared() >= minimum * minimum { + return false; + } + let normal = if offset.length_squared() > 0.000_001 { + offset.normalize() + } else { + Vec2::Y + }; + *position = center + normal * minimum; + let inward_speed = velocity.dot(normal); + if inward_speed < 0.0 { + *velocity -= normal * inward_speed * 1.8; + } + *velocity += normal * kick; + true +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn closest_point_is_clamped_to_segment() { + let segment = Segment::new(Vec2::ZERO, Vec2::new(10.0, 0.0), 0.8); + assert_eq!( + closest_point(Vec2::new(12.0, 4.0), segment), + Vec2::new(10.0, 0.0) + ); + } + + #[test] + fn collision_reflects_velocity() { + let mut position = Vec2::new(5.0, 1.0); + let mut velocity = Vec2::new(0.0, -10.0); + let hit = segment_collision( + &mut position, + &mut velocity, + 2.0, + Segment::new(Vec2::ZERO, Vec2::new(10.0, 0.0), 1.0), + ); + assert!(hit); + assert!(velocity.y > 9.9); + } +} diff --git a/tdkpin-rs/src/main.rs b/tdkpin-rs/src/main.rs index e7a11a9..3ede069 100644 --- a/tdkpin-rs/src/main.rs +++ b/tdkpin-rs/src/main.rs @@ -1,3 +1,31 @@ -fn main() { - println!("Hello, world!"); +mod app; +mod assets; +mod game; +mod geometry; +mod persistence; + +use app::App; +use macroquad::prelude::*; + +const LOGICAL_WIDTH: i32 = 640; +const LOGICAL_HEIGHT: i32 = 460; + +fn window_conf() -> Conf { + Conf { + window_title: "TDK Pinball Machine".to_owned(), + window_width: LOGICAL_WIDTH, + window_height: LOGICAL_HEIGHT, + window_resizable: true, + high_dpi: true, + ..Default::default() + } +} + +#[macroquad::main(window_conf)] +async fn main() { + let mut app = App::load().await; + loop { + app.frame(); + next_frame().await; + } } diff --git a/tdkpin-rs/src/persistence.rs b/tdkpin-rs/src/persistence.rs new file mode 100644 index 0000000..caec789 --- /dev/null +++ b/tdkpin-rs/src/persistence.rs @@ -0,0 +1,196 @@ +use directories::ProjectDirs; +use serde::{Deserialize, Serialize}; +use std::{cmp::Reverse, fs, io, path::PathBuf}; + +const ORIGINAL_HIGHSCORES: &[u8] = include_bytes!("../assets/original/HISCORES.DAT"); + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +pub enum Language { + English, + German, + French, + Italian, + Spanish, +} + +impl Language { + pub const ALL: [Self; 5] = [ + Self::English, + Self::German, + Self::French, + Self::Italian, + Self::Spanish, + ]; + + pub const fn label(self) -> &'static str { + match self { + Self::English => "English", + Self::German => "Deutsch", + Self::French => "Francais", + Self::Italian => "Italiano", + Self::Spanish => "Espanol", + } + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(default)] +pub struct Settings { + pub speed: u8, + pub sounds: bool, + pub language: Language, +} + +impl Default for Settings { + fn default() -> Self { + Self { + speed: 3, + sounds: true, + language: Language::English, + } + } +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +pub struct HighScore { + pub name: String, + pub score: u32, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(default)] +pub struct SavedData { + pub settings: Settings, + pub high_scores: Vec, +} + +impl Default for SavedData { + fn default() -> Self { + Self { + settings: Settings::default(), + high_scores: parse_original_high_scores(ORIGINAL_HIGHSCORES), + } + } +} + +pub struct Persistence { + path: PathBuf, +} + +impl Persistence { + pub fn new() -> Self { + let path = ProjectDirs::from("com", "kiwi-hamburg", "TDK Pinball Machine").map_or_else( + || PathBuf::from("tdkpin-save.json"), + |dirs| dirs.data_local_dir().join("save.json"), + ); + Self { path } + } + + pub fn load(&self) -> SavedData { + fs::read_to_string(&self.path) + .ok() + .and_then(|text| serde_json::from_str(&text).ok()) + .unwrap_or_default() + } + + pub fn save(&self, data: &SavedData) -> io::Result<()> { + if let Some(parent) = self + .path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + fs::create_dir_all(parent)?; + } + let encoded = serde_json::to_vec_pretty(data).map_err(io::Error::other)?; + let temporary = self.path.with_extension("json.tmp"); + fs::write(&temporary, encoded)?; + #[cfg(target_os = "windows")] + if self.path.exists() { + fs::remove_file(&self.path)?; + } + fs::rename(temporary, &self.path) + } +} + +pub fn parse_original_high_scores(bytes: &[u8]) -> Vec { + const HEADER: usize = 16; + const COUNT: usize = 10; + const NAME_LENGTH: usize = 22; + const SCORE_KEY: u32 = u32::from_le_bytes(*b"IWIK"); + if bytes.len() < HEADER + COUNT * 4 + COUNT * NAME_LENGTH + || !bytes.starts_with(b"TDK Highscores\0\0") + { + return Vec::new(); + } + + let scores = &bytes[HEADER..HEADER + COUNT * 4]; + let names = &bytes[HEADER + COUNT * 4..]; + let mut result = (0..COUNT) + .map(|index| { + let score_start = index * 4; + let score = u32::from_le_bytes([ + scores[score_start], + scores[score_start + 1], + scores[score_start + 2], + scores[score_start + 3], + ]) ^ SCORE_KEY; + let name_start = index * NAME_LENGTH; + let name_bytes = &names[name_start..name_start + NAME_LENGTH]; + let length = name_bytes + .iter() + .position(|byte| *byte == 0) + .unwrap_or(NAME_LENGTH); + HighScore { + name: String::from_utf8_lossy(&name_bytes[..length]).into_owned(), + score, + } + }) + .collect::>(); + result.sort_by_key(|entry| Reverse(entry.score)); + result +} + +pub fn insert_high_score(scores: &mut Vec, entry: HighScore) { + scores.push(entry); + scores.sort_by_key(|entry| Reverse(entry.score)); + scores.truncate(10); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn imports_all_original_entries_in_score_order() { + let scores = parse_original_high_scores(ORIGINAL_HIGHSCORES); + assert_eq!(scores.len(), 10); + assert_eq!(scores[0].score, 6_537_392); + assert!(scores.windows(2).all(|pair| pair[0].score >= pair[1].score)); + assert!( + scores + .iter() + .any(|entry| entry.name == "TDK Pinball Player") + ); + } + + #[test] + fn rejects_a_table_without_the_original_signature() { + let mut corrupt = ORIGINAL_HIGHSCORES.to_vec(); + corrupt[0] = b'X'; + assert!(parse_original_high_scores(&corrupt).is_empty()); + } + + #[test] + fn new_score_is_inserted_and_table_stays_bounded() { + let mut scores = parse_original_high_scores(ORIGINAL_HIGHSCORES); + insert_high_score( + &mut scores, + HighScore { + name: "TEST".to_owned(), + score: u32::MAX, + }, + ); + assert_eq!(scores.len(), 10); + assert_eq!(scores[0].name, "TEST"); + } +}