feat: rebuild TDK Pinball Machine in Rust

Replace the empty Rust scaffold with a playable native reconstruction of the
1995 Win16 game. Embed the complete decoded asset set, retain the original
640x460 presentation and localized help, and implement a resizable letterboxed
Macroquad frontend for Linux, macOS, and Windows.

Recreate the multiplayer ball flow, flippers, nudging, target banks, wheel,
robot, lock, magnets, bumper progression, TDK diamond multiplier, media extra
balls, sound dispatch, settings, and high-score entry. The physics engine uses
a fixed time step and 101 static collision segments transcribed from the
original 175-object table. Portable JSON persistence imports and decodes the
original XOR-obfuscated high-score file on first run.

Document the evidence boundary explicitly: artwork and PCM samples are exact,
static geometry is recovered, and several numeric scoring/impulse values remain
best-evidence behavioral tuning rather than bit-identical Win16 arithmetic.
Add a native three-OS CI matrix and retain every decoded resource for future
fidelity work.

Test Plan:
- `diff -qr original/assets/decoded tdkpin-rs/assets/original` with the two
  intentionally added legacy root files excluded -- passed
- `cargo fmt --all -- --check` -- passed
- `cargo check --all-targets` -- passed
- `cargo test --all-targets` -- passed, 8 tests
- `cargo clippy --all-targets -- -D warnings` -- passed
- `cargo build --profile production` -- passed
- `cargo check --all-targets --target x86_64-pc-windows-gnu` -- passed
- `cargo check --all-targets --target x86_64-apple-darwin` -- passed
- `cargo run` graphical start, launch, collision, and score smoke test -- passed;
  audible output was unavailable because the host has no ALSA device
This commit is contained in:
2026-08-22 16:12:11 +02:00
parent 54061f1eb7
commit f817b63683
81 changed files with 3653 additions and 2 deletions
+27
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
assets/original/TDKPIN.INI binary
+578
View File
@@ -2,6 +2,584 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 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]] [[package]]
name = "tdkpin-rs" name = "tdkpin-rs"
version = "0.1.0" 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"
+4
View File
@@ -4,6 +4,10 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
directories = "6.0.0"
macroquad = { version = "0.4.16", features = ["audio"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
[lints.clippy] [lints.clippy]
pedantic = { level = "warn", priority = -1 } pedantic = { level = "warn", priority = -1 }
+61
View File
@@ -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.
+69
View File
@@ -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.
Binary file not shown.
+27
View File
@@ -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
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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"
}
}
@@ -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"
}
}
@@ -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"
}
}
@@ -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"
}
}
@@ -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"
}
}
@@ -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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 766 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 373 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 772 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 880 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

@@ -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"
}
}
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

+683
View File
@@ -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<Game>,
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,
);
}
+151
View File
@@ -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
}
+556
View File
@@ -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<Player>,
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<Event> {
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<Event>) {
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<Event>) {
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<Event>) {
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<Event>) {
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<Vec<Segment>> = 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);
}
}
+106
View File
@@ -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);
}
}
+30 -2
View File
@@ -1,3 +1,31 @@
fn main() { mod app;
println!("Hello, world!"); 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;
}
} }
+196
View File
@@ -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<HighScore>,
}
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<HighScore> {
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::<Vec<_>>();
result.sort_by_key(|entry| Reverse(entry.score));
result
}
pub fn insert_high_score(scores: &mut Vec<HighScore>, 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");
}
}