14 Commits
Author SHA1 Message Date
ddidderr 7ee2e71bc7 fix: relative api/highscores path 2026-08-29 18:46:10 +02:00
ddidderr 86434aaa2b fix: remote unused github workflow 2026-08-29 18:07:54 +02:00
ddidderr 51502ef92f chore(release): prepare v1.1.0
Build TDK Pinball / build (macos-latest) (push) Canceled after 0s
Build TDK Pinball / build (ubuntu-latest) (push) Canceled after 0s
Build TDK Pinball / build (windows-latest) (push) Canceled after 0s
Bump the package version to 1.1.0 for the web port, optional shared high-score service, and post-1.0 parity and edge fixes. Update CHANGELOG.md and refresh the tracked web WASM artifact.

Test Plan:
- `just test` -- passed (138 game tests, 3 highscore-server tests)
- `just clippy` -- passed
- `just build-production` -- passed
- `just web-build` -- passed
- `cargo metadata --locked --format-version 1 --no-deps` -- passed
- `git diff --cached --check` -- passed
2026-08-29 17:52:53 +02:00
ddidderr f9063bd2ff chore(web): regenerate browser artifact
Build TDK Pinball / build (macos-latest) (push) Canceled after 0s
Build TDK Pinball / build (ubuntu-latest) (push) Canceled after 0s
Build TDK Pinball / build (windows-latest) (push) Canceled after 0s
2026-08-29 17:50:49 +02:00
ddidderr a36856e526 fix(game): allow launcher input while tilted
Build TDK Pinball / build (macos-latest) (push) Canceled after 0s
Build TDK Pinball / build (ubuntu-latest) (push) Canceled after 0s
Build TDK Pinball / build (windows-latest) (push) Canceled after 0s
Preserve the original launcher path when a nudge tilts a game before launch.
The Win16 press and release handlers process scan code 0x50 without consulting
`game_tilted`, so Tilt disables flippers and scoring but does not strand a ball
in the shooter lane. Remove the extra Rust gate, retain the original launch
sound suppression during Tilt, and add regression coverage for the waiting-ball
case.

Test Plan:
- `just test` -- passed (138 Rust tests and 3 highscore-server tests)
- `just clippy` -- passed
- `cargo +nightly fmt --all -- --check` -- passed
- `git diff --check` and `git diff --cached --check` -- passed
2026-08-29 17:46:24 +02:00
ddidderr acb0c20b59 fix(web): vendor the Macroquad browser loader
The page depended on the externally hosted miniquad bundle, which violates a
same-origin `script-src 'self'` policy and makes the game depend on a third
party at runtime. Vendor the current official loader alongside the web assets
and move the WASM `load` call into a local bootstrap script, preserving plugin
registration order without inline JavaScript.

Document the CSP requirement for WebAssembly compilation and same-origin
connections. The vendored bundle is the current response from the official
Macroquad loader URL and was syntax-checked before committing.

Test Plan:
- `just web-build` -- passed
- `node --check web/mq_js_bundle.js web/storage.js web/bootstrap.js` -- passed
- `prettier --check web/storage.js web/bootstrap.js` -- passed
- Browser smoke test with `script-src 'self' 'wasm-unsafe-eval'` and `connect-src 'self'` -- passed; 640x460 canvas and no CSP/script errors
- `git diff --cached --check` -- passed
2026-08-29 17:12:43 +02:00
ddidderr a81741b470 chore(highscores): align dependency and lint standards
Build TDK Pinball / build (macos-latest) (push) Canceled after 0s
Build TDK Pinball / build (ubuntu-latest) (push) Canceled after 0s
Build TDK Pinball / build (windows-latest) (push) Canceled after 0s
Update the high-score service to the latest compatible direct releases while
keeping the requested Cargo.toml ranges: 0.x crates use their latest minor
line and 1.x crates use their major-only range. Refresh the standalone lockfile
so it resolves axum 0.8.9, rusqlite 0.40.2, serde 1.0.229, serde_json 1.0.151,
Tokio 1.53.1, and the latest dev-tool releases.

Copy the main package's Clippy policy and make the repository's formatting
recipes cover the service. The nested crate inherits the parent rustfmt.toml,
so one configuration remains authoritative; newly required documentation and
borrow style also keep the stricter pedantic policy clean.

Test Plan:
- `cargo update --manifest-path highscore-server/Cargo.toml` -- passed
- `just test` -- passed (3 service tests and 137 game tests)
- `just clippy` -- passed with the shared lint policy
- `just fmt-highscore-server` and `cargo +nightly fmt --manifest-path highscore-server/Cargo.toml -- --check` -- passed
- `cargo tree --manifest-path highscore-server/Cargo.toml --depth 1` -- confirmed latest direct resolutions
- `rustfmt +nightly --print-config current` -- confirmed the parent rustfmt settings
- `rumdl check --flavor commonmark highscore-server/README.md` -- passed
- `git diff --cached --check` -- passed
2026-08-29 16:03:11 +02:00
ddidderr 3b7b84affe chore(highscores): ignore the local SQLite database
The service defaults to a database file in the Rust project directory when no
production path is configured. Ignore that file and SQLite sidecars so local
runs do not create accidental repository changes.

Test Plan:
- `git diff --cached --check` -- passed
- Ignore patterns verified against the default database filename
2026-08-29 15:55:41 +02:00
ddidderr 5dd7f38450 feat(web): use the shared high-score service
Keep browser settings and the existing local save fallback, but route the
browser high-score table through the same-origin service when it is available.
The WASM storage bridge now accepts fetched score JSON and queues one validated
submission at a time. The JavaScript plugin fetches the canonical table,
submits accepted name-entry scores, ignores stale responses, retries failures,
and feeds successful responses back into the running game.

Update the web and project documentation to describe the optional shared
leaderboard and regenerate the tracked browser artifact. A missing service
continues to leave the local table usable; the service documentation records
that anonymous client scores are intentionally not tamper-resistant.

Test Plan:
- `just test` -- passed (3 service tests and 137 game tests)
- `just clippy` -- passed
- `just web-build` -- passed
- `cargo +nightly fmt -- --check` -- passed
- `node --check web/storage.js` and `prettier --check web/storage.js` -- passed
- `rumdl check --flavor commonmark CHANGELOG.md README.md web/README.md highscore-server/README.md` -- passed
- Browser WASM load through same-origin API proxy -- passed
- `git diff --cached --check` -- passed
2026-08-29 15:55:15 +02:00
ddidderr ec0fdfd6b4 feat(highscores): add SQLite Axum service
Add a small standalone Axum service for the shared anonymous top-ten table.
SQLite keeps the deployment self-contained, while one transaction inserts a
validated name and score and removes entries below the canonical top ten.
Expose a health endpoint and same-origin API, with an nginx proxy block and
run instructions beside the service. Keep the service outside the game crate
so native gameplay persistence remains unchanged.

Test Plan:
- `cargo test --manifest-path highscore-server/Cargo.toml` -- passed (3 tests)
- `cargo clippy --manifest-path highscore-server/Cargo.toml --all-targets --all-features -- -D warnings` -- passed
- `cargo +nightly fmt --manifest-path highscore-server/Cargo.toml -- --check` -- passed
- Live executable health, POST, and GET smoke test -- passed
- `git diff --cached --check` -- passed
2026-08-29 15:50:04 +02:00
ddidderr 8fe9431989 fix(game): restore startup and multiball edge behavior
Build TDK Pinball / build (macos-latest) (push) Canceled after 0s
Build TDK Pinball / build (ubuntu-latest) (push) Canceled after 0s
Build TDK Pinball / build (windows-latest) (push) Canceled after 0s
The interactive build inherited Macroquad's fixed zero random state, making
maximum-power launcher shots repeat across fresh runs. Seed the platform
generator from the startup clock before taking the program-global gameplay
seed. Require record 148's still-live contact before the effect-seven special
respawn and clear active multiball state on an ordinary ball drain, so a
released reserve ball cannot be recreated after both slots are lost.

Type-4 target and effect handlers now honor each record's predicted broadphase
before changing its entry latch. This preserves the latch while another
multiball slot is elsewhere, preventing repeated upper-left corridor scoring.
The regressions and regenerated browser artifact stay with the implementation.

Test Plan:
- `just test` -- passed (137 tests)
- `just clippy` -- passed
- `cargo build --profile production` -- passed
- `just web-build` -- passed
- `cargo +nightly fmt --check` -- passed
- `rumdl check --flavor commonmark CHANGELOG.md` -- passed
- `git diff --cached --check` -- passed
2026-08-29 15:27:28 +02:00
ddidderr a21f01c02d fix(web): keep game at original canvas size
Build TDK Pinball / build (macos-latest) (push) Canceled after 0s
Build TDK Pinball / build (ubuntu-latest) (push) Canceled after 0s
Build TDK Pinball / build (windows-latest) (push) Canceled after 0s
The browser shell previously sized the canvas to the full viewport, causing
Macroquad to scale the 640x460 game presentation as the website changed size.
Keep the canvas at the original 640x460 CSS-pixel dimensions and center it on
a black page instead. A minimum page size and scrolling preserve access to the
fixed canvas on viewports smaller than the original presentation.

Test Plan:
- `just web-build` -- passed
- `git diff --check` -- passed
- Browser smoke test -- canvas measured 640x460 and was centered at (320,130)
  in a 1280x720 viewport; the rendered game remained at the intended fixed
  presentation size with no runtime errors
2026-08-29 14:49:37 +02:00
ddidderr b079cfa196 feat(web): add browser build for TDK Pinball
Expose the existing Macroquad game as a static WASM website while preserving
native desktop behavior. Native-only simulation/file-export code and the
per-user filesystem save path are now separated from the browser build.

The browser version uses a small WASM-only storage support crate and a
Macroquad-compatible JavaScript plugin to persist the same JSON settings and
high scores in localStorage. Browser audio decoding starts in an owned
background coroutine so the game can render its original loading/attract
screens while the embedded sounds finish loading. The checked-in web bundle
contains the optimized WASM, centered black HTML shell, and build/serve
instructions.

Test Plan:
- `just test` -- passed, 135 tests
- `just clippy` -- passed
- `cargo clippy --target wasm32-unknown-unknown -- -D warnings` -- passed
- `cargo +nightly fmt --check` and web-storage format check -- passed
- `just web-build` -- passed; packaged WASM matches the production artifact
- Browser smoke test at `http://127.0.0.1:8000/` -- rendered the centered
  game, started gameplay, opened settings, and restored a changed language
  from browser storage in a fresh page with no runtime errors
2026-08-29 14:32:41 +02:00
ddidderr c2f1443436 fmt: just fmt (rust only)
Build TDK Pinball / build (macos-latest) (push) Canceled after 0s
Build TDK Pinball / build (ubuntu-latest) (push) Canceled after 0s
Build TDK Pinball / build (windows-latest) (push) Canceled after 0s
2026-08-29 09:57:39 +02:00
33 changed files with 2525 additions and 523 deletions
-27
View File
@@ -1,27 +0,0 @@
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
+2
View File
@@ -1,2 +1,4 @@
target/
__pycache__/
/tdkpin-rs/highscores.sqlite3
/tdkpin-rs/highscores.sqlite3-*
+21
View File
@@ -0,0 +1,21 @@
[target.wasm32-unknown-unknown]
rustflags = [
"-C",
"link-arg=--import-undefined",
"-C",
"link-arg=--export=tdkpin_storage_crate_version",
"-C",
"link-arg=--export=tdkpin_browser_storage_clear",
"-C",
"link-arg=--export=tdkpin_browser_storage_push",
"-C",
"link-arg=--export=tdkpin_browser_storage_finish",
"-C",
"link-arg=--export=tdkpin_browser_storage_save_revision",
"-C",
"link-arg=--export=tdkpin_browser_storage_save_length",
"-C",
"link-arg=--export=tdkpin_browser_storage_save_byte",
"-C",
"link-arg=--export=tdkpin_browser_storage_save_ack",
]
+88 -58
View File
@@ -8,6 +8,35 @@ and this project adheres to
## [Unreleased]
## [1.1.0] - 2026-08-29
### Added
- Add a static WebAssembly (WASM) browser build and web shell for TDK Pinball,
persisting settings and scores in browser localStorage.
- Add a standalone Axum and SQLite high-score service (`highscore-server`) for
an optional shared top-ten leaderboard, including an nginx reverse-proxy
configuration and API endpoints.
- Route the browser high-score table through the shared service when available,
retaining the local-storage fallback when offline or unconfigured.
### Fixed
- Allow launcher input while tilted so a waiting ball in the shooter lane is not
stranded after a tilt, while retaining launch sound suppression.
- Bundle the official Macroquad browser loader and WASM bootstrap locally so a
CSP does not require inline JavaScript or code from `not-fl3.github.io`.
- Keep the web canvas centered at the original fixed 640x460 presentation size
instead of scaling with the browser viewport.
- Seed the initial gameplay random stream from the startup clock so the first
maximum-power launcher shot is not identical on every run.
- Require an unconsumed record-148 contact before the effect-seven special
respawn, preventing a released reserve ball from being duplicated after both
multiball balls drain.
- Apply type-4 entry latches only inside each target's registered broadphase,
preventing an unrelated multiball slot from clearing an upper-left target's
latch and causing repeated scoring while the ball remains there.
## [1.0.0] - 2026-08-29
### Fixed
@@ -23,13 +52,13 @@ and this project adheres to
a held ball remains velocity-stationary and the moving response retains its
full normal velocity instead of always subtracting 1,000.
- Restore `1000:ae6e`'s required-ball special respawn: armed or completed
effect-seven play grants one final `(17,23)/(0,3040)` ball without consuming
a marker, clears required state including record 148's contact word, and
suppresses record 148 until the next broadphase-active type-4 record
reenables it.
effect-seven play grants one final `(17,23)/(0,3040)` ball without consuming a
marker, clears required state including record 148's contact word, and
suppresses record 148 until the next broadphase-active type-4 record reenables
it.
- Replace the invented post-move `y>470` fallback with the original pre-scan
prediction bounds `x<=0 || x>340000 || y<=0 || y>460000`, including
one-slot removal during multiball and next-substep handling after a response.
prediction bounds `x<=0 || x>340000 || y<=0 || y>460000`, including one-slot
removal during multiball and next-substep handling after a response.
- Remove the invented 180 ms whole-frame nudge shake; original nudges affect
ball-slot velocities, sound, and Tilt state without moving the viewport.
- Match `SndPlaySound(SND_ASYNC|SND_NODEFAULT)` playback: stop the previous WAV
@@ -40,8 +69,8 @@ and this project adheres to
collect-bonus, and release-ball states are now visible.
- Restore display blits from `1008:0996`/`1008:0b9e`: 130x40 score fields at
X=478, eight places ending at X=606, exact 139x31 configured-player rows,
11x11 active/status markers, 5x5 remaining-ball markers for every player,
and strict signed score-status thresholds independent of media awards.
11x11 active/status markers, 5x5 remaining-ball markers for every player, and
strict signed score-status thresholds independent of media awards.
- Import exact `[Settings]` `Language`, `Speed`, and singular `Sound` values
from an adjacent/original TDKPIN.INI before creating portable JSON. Fresh
installs now inherit the distributed German help setting; F12 is again a
@@ -54,17 +83,17 @@ and this project adheres to
DAT995 at `(202,328)`, height 13 and clipped width `(stage-1)*7`; correct the
previously impossible Pascal-order C interpretation of a 328x202 blit.
- Restore the original idle/attract timer animation: target and word-quad
chases, magnetic flashes, five-record strip, four-point chase, DAT801-809
item progression, and the circular `WELCOME TO THE MACHINE` BITMAP500
marquee. Each nested phase uses the binary's remainder first: `%32/2`,
`%32/4`, `%40/4`, `%16/4`, and `%144/8`. Move gameplay item frames to their
exact `(123,300)` destination.
chases, magnetic flashes, five-record strip, four-point chase, DAT801-809 item
progression, and the circular `WELCOME TO THE MACHINE` BITMAP500 marquee. Each
nested phase uses the binary's remainder first: `%32/2`, `%32/4`, `%40/4`,
`%16/4`, and `%144/8`. Move gameplay item frames to their exact `(123,300)`
destination.
- Match central score/event side effects: every stored collision candidate
clears its ball slot's capture age, Tilt suppresses all score and rule-record
mutations, and active multiball doubles points before permanent Double can
double them again.
- Scan static, capture, trigger, magnetic, and dynamic record ranges in exact
ID order with a separately tracked prediction. Preserve the raw scanner's
- Scan static, capture, trigger, magnetic, and dynamic record ranges in exact ID
order with a separately tracked prediction. Preserve the raw scanner's
first-detected-candidate quirk even when a later record is geometrically
nearer, plus type-3 and dynamic-record broadphase behavior.
- Run type-3/type-4 processing before response and position publication:
@@ -72,16 +101,16 @@ and this project adheres to
candidates, triggers mutate the live motion first, and sensor IDs execute in
binary order without Hold/Complete prematurely ending the record scan.
- Apply the collision table's predicted-position broadphase bounds before
type-1/type-2 detection, including the registered five-pixel ball margin,
and retain unresolved candidates until the selected response phase.
- Correct the two relative slingshot initializer chains after records 55 and
70: circles 56/58/71/73 and lines 57/59/72/74 now accumulate from each
preceding path endpoint exactly as the 175-record ledger specifies. The
flagless TDK companion lines also retain their zero contact/link field rather
than carrying unused synthetic head IDs. Record 57 additionally matches the
live original's `(75530,354765)/(3000,15)` to
`(77369,356597)/(1839,1832)` transition; symmetric record 72 matches
`(235348,356825)/(-3000,15)` to `(233253,358494)/(-2095,1669)`.
type-1/type-2 detection, including the registered five-pixel ball margin, and
retain unresolved candidates until the selected response phase.
- Correct the two relative slingshot initializer chains after records 55 and 70:
circles 56/58/71/73 and lines 57/59/72/74 now accumulate from each preceding
path endpoint exactly as the 175-record ledger specifies. The flagless TDK
companion lines also retain their zero contact/link field rather than carrying
unused synthetic head IDs. Record 57 additionally matches the live original's
`(75530,354765)/(3000,15)` to `(77369,356597)/(1839,1832)` transition;
symmetric record 72 matches `(235348,356825)/(-3000,15)` to
`(233253,358494)/(-2095,1669)`.
- Match rectangular type-4 magnetic gates to raw `1000:9b69`: enter only when
the previous position is inside, then deactivate when the newly predicted
position exits any edge, replacing a swept-intersection approximation.
@@ -90,11 +119,11 @@ and this project adheres to
- Match the multiball nudge path's saved-slot behavior: consume the active
formula's random draws, then add one shared fixed/random impulse to both slot
velocity records instead of applying different formulas to each ball.
- Restore auxiliary response thresholds/kicks for bumpers 51-53 and line
records 55, 74, and 107, including weak-hit bumper scoring, WAVE 2019 on
kicked unscored rails, and the original tilt suppression.
- Preserve every collision record's `+0x49` layer mask and select upper layer
1 or lower layer 2 from the ball's previous 250,000-millipixel Y boundary.
- Restore auxiliary response thresholds/kicks for bumpers 51-53 and line records
55, 74, and 107, including weak-hit bumper scoring, WAVE 2019 on kicked
unscored rails, and the original tilt suppression.
- Preserve every collision record's `+0x49` layer mask and select upper layer 1
or lower layer 2 from the ball's previous 250,000-millipixel Y boundary.
- Match the original detail callback's slot-major integration order, stop a
slot's remaining 10 ms substeps after its first collision/action, delay a
newly requested second ball until the next callback, and retain ball-two
@@ -102,9 +131,9 @@ and this project adheres to
- Run type-3 captures and type-4 triggers for both live ball slots, including
per-slot capture age/contact ownership, exact captured-slot removal, survivor
promotion, and the effect-seven ball-number guard against a third ball.
- Spawn effect-seven ball 2 from record 148 at `(17,23)` with exact
`(0,3040)` millipixel velocity, instead of launching a guessed second ball
upward from the shooter lane.
- Spawn effect-seven ball 2 from record 148 at `(17,23)` with exact `(0,3040)`
millipixel velocity, instead of launching a guessed second ball upward from
the shooter lane.
- Treat record 148 as the special/multiball hole represented by player item 20;
capturing it preserves all five lock-hole items and contacts instead of
inventing an immediate wheel reset. Its exact 17x17 overlay-A Word-Quad at
@@ -115,12 +144,12 @@ and this project adheres to
- Render the five cumulative bumper-value lamps from original player items 1-5
at their recovered Word-Quad bounds as the value rises from 1,000 to 6,000.
- Render the nine in-bank hit states for record heads 90/93/96/99/102 and
109/112/115/118 from their exact overlay-A bounds until bank completion
rearms each three-line group.
- Replace the free-running wheel animation with the original six-callback
91x90 target rotation, WAVE 2011 start, exact DAT600 frames/target positions,
and state-6 rotation of the five per-player contact/item values. It advances
only for an active ball, pauses in the launcher, and survives a normal drain.
109/112/115/118 from their exact overlay-A bounds until bank completion rearms
each three-line group.
- Replace the free-running wheel animation with the original six-callback 91x90
target rotation, WAVE 2011 start, exact DAT600 frames/target positions, and
state-6 rotation of the five per-player contact/item values. It advances only
for an active ball, pauses in the launcher, and survives a normal drain.
- Restore the complete 281-callback DAT600 panel sequence after the fifth lock
hole, including physics suspension, contact/item cleanup, all nine image
phases, and the original WAVE 2013/2012/stop boundaries. A fifth lock captured
@@ -131,9 +160,9 @@ and this project adheres to
- Remove the invented attract-screen instruction panel and stop assigning the
original F2/F3 keys to modern overlays. The idle view is the original table;
optional settings/high-score viewers move to F10/F9.
- Batch physics and flipper publication on the original 50/40/30/20/10-ms
detail callbacks with exactly 5/4/3/2/1 internal 10-ms substeps, instead of
exposing every substep on host render frames.
- Batch physics and flipper publication on the original 50/40/30/20/10-ms detail
callbacks with exactly 5/4/3/2/1 internal 10-ms substeps, instead of exposing
every substep on host render frames.
- Separate flipper key flags from rendered/collision position so press/release
edges and WAVE 2021 occur only when the detail timer processes them.
- Add a bit-exact Borland Real48 core and route speed clamps, type-1 circles,
@@ -141,8 +170,8 @@ and this project adheres to
magnetic impulses through its original rounding behavior.
- Preserve per-ball Real48 spin and use it in collision tangent response, so
zero-spin C fixtures and retained-spin Wine traces are both represented.
- Implement dynamic records 174/175 with bidirectional ball impulse transfer
and the original post-collision speed clamp instead of a one-sided secondary
- Implement dynamic records 174/175 with bidirectional ball impulse transfer and
the original post-collision speed clamp instead of a one-sided secondary
bounce.
- Split collision contact state into per-player 16-bit type-3 contact words and
transient type-4 entry flags. Type-3 records now use the original deep-inside
@@ -182,11 +211,12 @@ and this project adheres to
- Match the original low-byte keyboard scans: both Ctrl keys operate the left
flipper, both Enter keys operate the right, and the physical `0x1b`
main-keyboard plus/right-bracket position starts or adds players.
- Dispatch add-player, nudge, and F12 sound-toggle actions on key release through
the original `1000:638e` timing rather than on their initial key press.
- Dispatch add-player, nudge, and F12 sound-toggle actions on key release
through the original `1000:638e` timing rather than on their initial key
press.
- Enforce the original auxiliary-window input gate: F1 and F12 are ignored while
Help, HighScore, or name-entry UI is open instead of controlling the main
game through a child window.
Help, HighScore, or name-entry UI is open instead of controlling the main game
through a child window.
- Route a single-ball type-3 completion during Tilt through `1000:ae6e` instead
of granting a free launcher reset: lock holes consume the ball, while record
148 can still take its required-state special-respawn branch.
@@ -202,18 +232,18 @@ and this project adheres to
playback and restores bank, trigger, capture, nudge, tilt, and drain cues.
Type-4 WAVE 2004 and bumper WAVE 2006 now precede their score mutations so a
simultaneously crossed media marker leaves WAVE 2007 audible, as in the
monophonic original. A tilted drain suppresses WAVE 2008 before reset, while
a following flipper-release edge may still play WAVE 2021 after reset.
monophonic original. A tilted drain suppresses WAVE 2008 before reset, while a
following flipper-release edge may still play WAVE 2021 after reset.
Bumper/TDK bank completion WAVE 2017 likewise precedes its 50,000 or
10,000..24,464 award, preserving the marker cue when that award crosses one.
- Advance media/extra-ball thresholds only from score additions, preserve
32-bit score wrapping, and restore the ninth-diamond ordering: its 24,464
award remains single, then ball-scoped double scoring and all three magnetic
fields activate before the collision's static score. Further completed banks
during that ball add 100,000 to the secondary score; a normal ball end clears
the multiplier, while effect seven's special respawn preserves it. Marker
comparisons retain the binary's signed-high/unsigned-low 32-bit ordering
after score wrap.
- Advance media/extra-ball thresholds only from score additions, preserve 32-bit
score wrapping, and restore the ninth-diamond ordering: its 24,464 award
remains single, then ball-scoped double scoring and all three magnetic fields
activate before the collision's static score. Further completed banks during
that ball add 100,000 to the secondary score; a normal ball end clears the
multiplier, while effect seven's special respawn preserves it. Marker
comparisons retain the binary's signed-high/unsigned-low 32-bit ordering after
score wrap.
- Match the original 640x460 window, native 16x28 score glyphs, and
`TDK Pinball Machine 1.00` title so the recovered artwork is presented
one-for-one instead of enlarged or resampled.
+55 -1
View File
@@ -164,6 +164,42 @@ dependencies = [
"ttf-parser",
]
[[package]]
name = "futures-core"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-macro"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "futures-task"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]]
name = "futures-util"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
"futures-core",
"futures-macro",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "getrandom"
version = "0.2.17"
@@ -362,6 +398,12 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "png"
version = "0.17.16"
@@ -481,6 +523,12 @@ version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
version = "0.6.14"
@@ -503,14 +551,20 @@ dependencies = [
[[package]]
name = "tdkpin-rs"
version = "1.0.0"
version = "1.1.0"
dependencies = [
"directories",
"futures-util",
"macroquad",
"serde",
"serde_json",
"tdkpin-web-storage",
]
[[package]]
name = "tdkpin-web-storage"
version = "1.0.0"
[[package]]
name = "thiserror"
version = "2.0.20"
+8 -2
View File
@@ -1,14 +1,20 @@
[package]
name = "tdkpin-rs"
version = "1.0.0"
version = "1.1.0"
edition = "2024"
[dependencies]
directories = "6"
macroquad = { version = "0.4", features = ["audio"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
directories = "6"
[target.'cfg(target_arch = "wasm32")'.dependencies]
futures-util = "0.3"
tdkpin-web-storage = { path = "web_storage" }
[lints.clippy]
pedantic = { level = "warn", priority = -1 }
todo = "warn"
+21 -4
View File
@@ -31,6 +31,20 @@ 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.
## Browser version
Build and serve the WASM website locally with:
```sh
just web-serve
```
Then open <http://127.0.0.1:8000/>. The browser build keeps the original
640x460 presentation centered on a black page and stores settings and a local
fallback table in browser storage unless the optional same-origin
`/api/highscores` service is deployed. See [web/README.md](web/README.md) and
[highscore-server/README.md](highscore-server/README.md) for deployment details.
## Deterministic mechanics validation
Named scenarios can be advanced without waiting in real time. The simulator
@@ -75,10 +89,13 @@ 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, settings
are imported from an adjacent original INI (or the embedded distributed
TDKPIN.INI), and the table is imported from the original `HISCORES.DAT`.
Native settings and the ten-entry high-score table are stored as `save.json` in
the platform's normal per-user application-data directory. Browser settings
and the fallback table use `localStorage`; a deployed browser build uses the
same-origin high-score service as its shared table when available. On first
run, native settings are imported from an adjacent original INI (or the
embedded distributed TDKPIN.INI), and the table is imported from the original
`HISCORES.DAT`.
## Reconstruction status
+805
View File
@@ -0,0 +1,805 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "atomic-waker"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "axum"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
dependencies = [
"axum-core",
"bytes",
"form_urlencoded",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"itoa",
"matchit",
"memchr",
"mime",
"percent-encoding",
"pin-project-lite",
"serde_core",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-core"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "bitflags"
version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "bytes"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cc"
version = "1.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fastrand"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "find-msvc-tools"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
dependencies = [
"percent-encoding",
]
[[package]]
name = "futures-channel"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
dependencies = [
"futures-core",
]
[[package]]
name = "futures-core"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-task"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]]
name = "futures-util"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
"futures-core",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"foldhash",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
]
[[package]]
name = "hashlink"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32069d97bb81e38fa67eab65e3393bf804bb85969f2bc06bf13f64aef5aba248"
dependencies = [
"hashbrown 0.17.1",
]
[[package]]
name = "http"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
dependencies = [
"bytes",
"itoa",
]
[[package]]
name = "http-body"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
dependencies = [
"bytes",
"http",
]
[[package]]
name = "http-body-util"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"pin-project-lite",
]
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43"
dependencies = [
"atomic-waker",
"bytes",
"futures-channel",
"futures-core",
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"smallvec",
"tokio",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
"bytes",
"http",
"http-body",
"hyper",
"pin-project-lite",
"tokio",
"tower-service",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "js-sys"
version = "0.3.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
dependencies = [
"cfg-if",
"wasm-bindgen",
]
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "libsqlite3-sys"
version = "0.38.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "log"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "matchit"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mio"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
dependencies = [
"libc",
"wasi",
"windows-sys",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pkg-config"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
[[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 = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rsqlite-vfs"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
dependencies = [
"hashbrown 0.16.1",
"thiserror",
]
[[package]]
name = "rusqlite"
version = "0.40.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3"
dependencies = [
"bitflags",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
"sqlite-wasm-rs",
]
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys",
]
[[package]]
name = "rustversion"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[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 3.0.4",
]
[[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 = "serde_path_to_error"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
dependencies = [
"itoa",
"serde",
"serde_core",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
[[package]]
name = "socket2"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "sqlite-wasm-rs"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75"
dependencies = [
"cc",
"js-sys",
"rsqlite-vfs",
"wasm-bindgen",
]
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "sync_wrapper"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
[[package]]
name = "tdkpin-highscore-server"
version = "0.1.0"
dependencies = [
"axum",
"http-body-util",
"rusqlite",
"serde",
"serde_json",
"tempfile",
"tokio",
"tower",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom",
"once_cell",
"rustix",
"windows-sys",
]
[[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 3.0.4",
]
[[package]]
name = "tokio"
version = "1.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
dependencies = [
"libc",
"mio",
"pin-project-lite",
"socket2",
"tokio-macros",
"windows-sys",
]
[[package]]
name = "tokio-macros"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.4",
]
[[package]]
name = "tower"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
dependencies = [
"futures-core",
"futures-util",
"pin-project-lite",
"sync_wrapper",
"tokio",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tower-layer"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
[[package]]
name = "tower-service"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "tracing"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
"tracing-core",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasm-bindgen"
version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 2.0.119",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
dependencies = [
"unicode-ident",
]
[[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"
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "tdkpin-highscore-server"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
axum = "0.8"
rusqlite = { version = "0.40", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread"] }
[dev-dependencies]
http-body-util = "0.1"
tempfile = "3"
tower = { version = "0.5", features = ["util"] }
[lints.clippy]
pedantic = { level = "warn", priority = -1 }
todo = "warn"
unwrap_used = "warn"
[lints.rust]
unsafe_code = "forbid"
+31
View File
@@ -0,0 +1,31 @@
# TDK high-score service
This small Axum service stores the shared top-ten table in SQLite. It exposes:
```text
GET /healthz
GET /api/highscores
POST /api/highscores
```
The POST body is JSON with a 21-character maximum name and a `u32` score. The
response is the canonical top-ten JSON array.
Submissions are anonymous and intentionally trust the browser's score. Add
rate limiting, moderation, or server-side run verification if the table needs
to resist forged scores.
Run it from `tdkpin-rs` with:
```sh
TDKPIN_HIGHSCORE_BIND=127.0.0.1:3000 \
TDKPIN_HIGHSCORE_DB=/var/lib/tdkpin/highscores.sqlite3 \
cargo run --manifest-path highscore-server/Cargo.toml
```
Place [nginx.conf.example](nginx.conf.example) inside the public site's
existing `server` block. The browser client expects the API at
`/api/highscores` on the same origin as the game.
The crate inherits the parent [`rustfmt.toml`](../rustfmt.toml); run
`just fmt-highscore-server` when formatting it directly.
@@ -0,0 +1,15 @@
# Add this block inside the nginx server block that serves the game.
location /api/highscores {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Optional health check for local monitoring.
location = /healthz {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
+300
View File
@@ -0,0 +1,300 @@
use std::{
path::Path,
sync::{Arc, Mutex},
};
use axum::{
Json,
Router,
extract::State,
http::StatusCode,
response::{IntoResponse, Response},
routing::get,
};
use rusqlite::{Connection, params, types::Type};
use serde::{Deserialize, Serialize};
const MAX_HIGH_SCORES: usize = 10;
const MAX_NAME_CHARS: usize = 21;
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub struct HighScore {
pub name: String,
pub score: u32,
}
#[derive(Debug)]
pub enum StoreError {
Database(rusqlite::Error),
Poisoned,
}
impl From<rusqlite::Error> for StoreError {
fn from(error: rusqlite::Error) -> Self {
Self::Database(error)
}
}
#[derive(Clone)]
pub struct HighScoreStore(Arc<Mutex<Connection>>);
impl HighScoreStore {
/// Open or create a SQLite-backed high-score store.
///
/// # Errors
///
/// Returns the SQLite error raised while opening or initializing the
/// database.
pub fn open(path: impl AsRef<Path>) -> Result<Self, rusqlite::Error> {
Self::from_connection(Connection::open(path)?)
}
/// Create an in-memory high-score store for tests or short-lived runs.
///
/// # Errors
///
/// Returns the SQLite error raised while initializing the database.
pub fn open_in_memory() -> Result<Self, rusqlite::Error> {
Self::from_connection(Connection::open_in_memory()?)
}
fn from_connection(connection: Connection) -> Result<Self, rusqlite::Error> {
connection.execute_batch(
"CREATE TABLE IF NOT EXISTS high_scores (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
score INTEGER NOT NULL CHECK (score >= 0 AND score <= 4294967295)
);
CREATE INDEX IF NOT EXISTS high_scores_order
ON high_scores (score DESC, id ASC);",
)?;
Ok(Self(Arc::new(Mutex::new(connection))))
}
/// Return the current table in descending score order.
///
/// # Errors
///
/// Returns an error if the database lock or query fails.
pub fn list(&self) -> Result<Vec<HighScore>, StoreError> {
let connection = self.0.lock().map_err(|_| StoreError::Poisoned)?;
let mut statement = connection.prepare(
"SELECT name, score
FROM high_scores
ORDER BY score DESC, id ASC
LIMIT ?1",
)?;
let scores = statement
.query_map([i64::try_from(MAX_HIGH_SCORES).unwrap_or(10)], |row| {
let score: i64 = row.get(1)?;
let score = u32::try_from(score).map_err(|error| {
rusqlite::Error::FromSqlConversionFailure(1, Type::Integer, Box::new(error))
})?;
Ok(HighScore {
name: row.get(0)?,
score,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(scores)
}
/// Add one score and return the resulting canonical top ten.
///
/// # Errors
///
/// Returns an error if the database lock or transaction fails.
pub fn submit(&self, entry: &HighScore) -> Result<Vec<HighScore>, StoreError> {
{
let mut connection = self.0.lock().map_err(|_| StoreError::Poisoned)?;
let transaction = connection.transaction()?;
transaction.execute(
"INSERT INTO high_scores (name, score) VALUES (?1, ?2)",
params![entry.name, i64::from(entry.score)],
)?;
transaction.execute(
"DELETE FROM high_scores
WHERE id NOT IN (
SELECT id FROM high_scores
ORDER BY score DESC, id ASC
LIMIT ?1
)",
[i64::try_from(MAX_HIGH_SCORES).unwrap_or(10)],
)?;
transaction.commit()?;
}
self.list()
}
}
#[derive(Debug, Deserialize)]
struct SubmitRequest {
name: String,
score: u32,
}
#[derive(Debug, Serialize)]
struct ErrorResponse {
error: &'static str,
}
fn invalid_request(message: &'static str) -> Response {
(
StatusCode::BAD_REQUEST,
Json(ErrorResponse { error: message }),
)
.into_response()
}
fn validate_request(request: &SubmitRequest) -> Result<HighScore, &'static str> {
let name = request.name.trim();
if name.is_empty() {
return Err("name must not be empty");
}
if name.chars().count() > MAX_NAME_CHARS {
return Err("name is too long");
}
if name.chars().any(char::is_control) {
return Err("name contains a control character");
}
Ok(HighScore {
name: name.to_owned(),
score: request.score,
})
}
async fn health() -> &'static str {
"ok"
}
async fn list_high_scores(State(store): State<HighScoreStore>) -> Response {
match store.list() {
Ok(scores) => Json(scores).into_response(),
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
}
async fn submit_high_score(
State(store): State<HighScoreStore>,
Json(request): Json<SubmitRequest>,
) -> Response {
let entry = match validate_request(&request) {
Ok(entry) => entry,
Err(message) => return invalid_request(message),
};
match store.submit(&entry) {
Ok(scores) => (StatusCode::CREATED, Json(scores)).into_response(),
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
}
pub fn router(store: HighScoreStore) -> Router {
Router::new()
.route("/healthz", get(health))
.route(
"/api/highscores",
get(list_high_scores).post(submit_high_score),
)
.with_state(store)
}
#[cfg(test)]
mod tests {
use axum::{
body::Body,
http::{Request, StatusCode},
};
use http_body_util::BodyExt;
use tempfile::tempdir;
use tower::ServiceExt;
use super::*;
async fn submit(app: &Router, name: &str, score: u32) -> StatusCode {
app.clone()
.oneshot(
Request::post("/api/highscores")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_vec(&serde_json::json!({
"name": name,
"score": score,
}))
.expect("request JSON should encode"),
))
.expect("request should build"),
)
.await
.expect("router should respond")
.status()
}
async fn list(app: &Router) -> Vec<HighScore> {
let response = app
.clone()
.oneshot(
Request::get("/api/highscores")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("router should respond");
assert_eq!(response.status(), StatusCode::OK);
let body = response
.into_body()
.collect()
.await
.expect("response body should read")
.to_bytes();
serde_json::from_slice(&body).expect("response should contain scores")
}
#[tokio::test]
async fn api_persists_and_keeps_the_top_ten() {
let directory = tempdir().expect("temporary directory should exist");
let database = directory.path().join("highscores.sqlite3");
let store = HighScoreStore::open(&database).expect("database should open");
let app = router(store);
for score in 0..12 {
assert_eq!(
submit(&app, &format!("Player {score}"), score).await,
StatusCode::CREATED
);
}
let scores = list(&app).await;
assert_eq!(scores.len(), MAX_HIGH_SCORES);
assert_eq!(scores[0].score, 11);
assert_eq!(scores[9].score, 2);
drop(app);
let reopened = router(HighScoreStore::open(&database).expect("database should reopen"));
assert_eq!(list(&reopened).await, scores);
}
#[tokio::test]
async fn api_rejects_invalid_names() {
let app = router(HighScoreStore::open_in_memory().expect("database should open"));
assert_eq!(submit(&app, "", 10).await, StatusCode::BAD_REQUEST);
assert_eq!(
submit(&app, "abcdefghijklmnopqrstuv", 10).await,
StatusCode::BAD_REQUEST
);
assert_eq!(submit(&app, "ok\nno", 10).await, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn health_endpoint_is_available_for_nginx() {
let app = router(HighScoreStore::open_in_memory().expect("database should open"));
let response = app
.oneshot(
Request::get("/healthz")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("router should respond");
assert_eq!(response.status(), StatusCode::OK);
}
}
+17
View File
@@ -0,0 +1,17 @@
use std::{env, error::Error, path::PathBuf};
use axum::Router;
use tdkpin_highscore_server::{HighScoreStore, router};
use tokio::net::TcpListener;
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let bind = env::var("TDKPIN_HIGHSCORE_BIND").unwrap_or_else(|_| "127.0.0.1:3000".to_owned());
let database = env::var_os("TDKPIN_HIGHSCORE_DB")
.map_or_else(|| PathBuf::from("highscores.sqlite3"), PathBuf::from);
let app: Router = router(HighScoreStore::open(database)?);
let listener = TcpListener::bind(&bind).await?;
println!("TDK high-score service listening on http://{bind}");
axum::serve(listener, app).await?;
Ok(())
}
+22 -3
View File
@@ -12,7 +12,26 @@ build-release:
build-production:
cargo build --profile production
fmt:
web-build:
cargo build --profile production --target wasm32-unknown-unknown
cp target/wasm32-unknown-unknown/production/tdkpin-rs.wasm web/tdkpin-rs.wasm
web-serve: web-build
python3 -m http.server 8000 --directory web
highscore-server:
cargo run --manifest-path highscore-server/Cargo.toml
test-highscore-server:
cargo test --manifest-path highscore-server/Cargo.toml
clippy-highscore-server:
cargo clippy --manifest-path highscore-server/Cargo.toml --all-targets --all-features -- -D warnings
fmt-highscore-server:
cargo +nightly fmt --manifest-path highscore-server/Cargo.toml
fmt: fmt-highscore-server
cargo +nightly fmt
tombi format
fd -tf -e md -x prettier --write --prose-wrap always --print-width 80
@@ -25,10 +44,10 @@ _fix:
fix: _fix fmt
clippy:
clippy: clippy-highscore-server
cargo clippy --workspace --all-targets --all-features -- -D warnings
test:
test: test-highscore-server
cargo test --workspace --all-targets --all-features
clean:
+114 -61
View File
@@ -1,24 +1,68 @@
#[cfg(not(target_arch = "wasm32"))]
use std::path::Path;
use macroquad::prelude::*;
use crate::{
assets::Assets,
game::{Controls, Event, Game, Nudge},
persistence::{
HighScore, Language, Persistence, SavedData, insert_high_score, qualifies_high_score,
HighScore,
Language,
Persistence,
SavedData,
insert_high_score,
qualifies_high_score,
},
};
use macroquad::prelude::*;
use std::path::Path;
const WIDTH: f32 = 640.0;
const HEIGHT: f32 = 460.0;
const DETAIL_TIMER_SECONDS: [f32; 5] = [0.050, 0.040, 0.030, 0.020, 0.010];
const ADD_PLAYER_KEYS: [KeyCode; 3] = [KeyCode::KpAdd, KeyCode::RightBracket, KeyCode::Equal];
const TARGET_POSITIONS: [[(f32, f32); 5]; 6] = [
[(9.0, 41.0), (23.0, 11.0), (56.0, 14.0), (63.0, 47.0), (35.0, 63.0)],
[(11.0, 45.0), (18.0, 13.0), (52.0, 11.0), (63.0, 42.0), (39.0, 64.0)],
[(13.0, 50.0), (15.0, 17.0), (46.0, 9.0), (64.0, 37.0), (44.0, 62.0)],
[(17.0, 55.0), (11.0, 23.0), (39.0, 7.0), (64.0, 30.0), (50.0, 59.0)],
[(21.0, 60.0), (9.0, 28.0), (35.0, 7.0), (62.0, 25.0), (54.0, 56.0)],
[(8.0, 35.0), (26.0, 8.0), (59.0, 18.0), (59.0, 50.0), (27.0, 61.0)],
[
(9.0, 41.0),
(23.0, 11.0),
(56.0, 14.0),
(63.0, 47.0),
(35.0, 63.0),
],
[
(11.0, 45.0),
(18.0, 13.0),
(52.0, 11.0),
(63.0, 42.0),
(39.0, 64.0),
],
[
(13.0, 50.0),
(15.0, 17.0),
(46.0, 9.0),
(64.0, 37.0),
(44.0, 62.0),
],
[
(17.0, 55.0),
(11.0, 23.0),
(39.0, 7.0),
(64.0, 30.0),
(50.0, 59.0),
],
[
(21.0, 60.0),
(9.0, 28.0),
(35.0, 7.0),
(62.0, 25.0),
(54.0, 56.0),
],
[
(8.0, 35.0),
(26.0, 8.0),
(59.0, 18.0),
(59.0, 50.0),
(27.0, 61.0),
],
];
const BUMPER_VALUE_REGIONS: [(i32, i32, i32, i32); 5] = [
(103, 304, 121, 323),
@@ -50,6 +94,13 @@ fn add_player_released() -> bool {
ADD_PLAYER_KEYS.into_iter().any(is_key_released)
}
fn initial_game_random_seed() -> u32 {
// The original seeds RandSeed from the startup clock. Macroquad's random
// generator starts from a fixed zero state unless the application seeds it.
macroquad::rand::srand(macroquad::miniquad::date::now().to_bits());
macroquad::rand::rand()
}
fn visible_score_digits(mut value: u32) -> Vec<(u8, u8)> {
let mut digits = Vec::with_capacity(8);
for position in 0..8 {
@@ -116,11 +167,7 @@ impl AttractAnimation {
fn target_active(self, index: usize) -> bool {
let index = u32::try_from(index).unwrap_or(0);
let first_activation = if index == 0 {
32
} else {
index * 2
};
let first_activation = if index == 0 { 32 } else { index * 2 };
self.tick >= first_activation && self.tick.wrapping_sub(index * 2) % 32 < 16
}
@@ -137,11 +184,7 @@ impl AttractAnimation {
fn record_strip_active(self, index: usize) -> bool {
let index = u32::try_from(index).unwrap_or(0);
let first_activation = if index == 0 {
40
} else {
index * 4
};
let first_activation = if index == 0 { 40 } else { index * 4 };
self.tick >= first_activation && self.tick.wrapping_sub(index * 4) % 40 < 20
}
@@ -151,11 +194,7 @@ impl AttractAnimation {
fn item_index(self) -> Option<usize> {
let phase = (self.tick % 144) / 8;
let item = if phase < 10 {
phase
} else {
18 - phase
};
let item = if phase < 10 { phase } else { 18 - phase };
(item != 0).then(|| usize::try_from(item - 1).unwrap_or(0))
}
@@ -204,7 +243,7 @@ impl App {
screen: Screen::Loading,
return_screen: Screen::Attract,
game: None,
game_random_seed: macroquad::rand::rand(),
game_random_seed: initial_game_random_seed(),
setting_row: 0,
loading_started,
loading_until: loading_started + 1.1,
@@ -216,6 +255,10 @@ impl App {
}
pub fn frame(&mut self) {
#[cfg(target_arch = "wasm32")]
self.assets.update();
#[cfg(target_arch = "wasm32")]
self.update_remote_high_scores();
self.handle_global_input();
match self.screen {
Screen::Loading => {
@@ -235,11 +278,13 @@ impl App {
self.present();
}
#[cfg(not(target_arch = "wasm32"))]
pub fn set_simulation_game(&mut self, game: Game) {
self.game = Some(game);
self.screen = Screen::Playing;
}
#[cfg(not(target_arch = "wasm32"))]
pub fn set_simulation_attract(&mut self, steps: u64) {
self.game = None;
self.screen = Screen::Attract;
@@ -249,6 +294,7 @@ impl App {
}
}
#[cfg(not(target_arch = "wasm32"))]
pub fn set_simulation_loading(&mut self, steps: u64) {
self.game = None;
self.screen = Screen::Loading;
@@ -257,12 +303,14 @@ impl App {
self.loading_until = get_time() + 60.0;
}
#[cfg(not(target_arch = "wasm32"))]
pub fn set_simulation_highscores(&mut self) {
self.game = None;
self.return_screen = Screen::Attract;
self.screen = Screen::HighScores;
}
#[cfg(not(target_arch = "wasm32"))]
pub fn set_simulation_name_entry(&mut self) {
self.game = Some(Game::new(1));
self.return_screen = Screen::Playing;
@@ -271,10 +319,12 @@ impl App {
self.screen = Screen::NameEntry;
}
#[cfg(not(target_arch = "wasm32"))]
pub fn render_simulation(&self) {
self.draw_logical();
}
#[cfg(not(target_arch = "wasm32"))]
pub fn export_simulation_png(&self, path: &Path) -> Result<(), String> {
let path = path
.to_str()
@@ -420,6 +470,7 @@ impl App {
let now = get_time();
if now - self.last_help_click <= 0.40 {
self.save();
#[cfg(not(target_arch = "wasm32"))]
macroquad::miniquad::window::request_quit();
}
self.last_help_click = now;
@@ -495,7 +546,8 @@ impl App {
self.name.pop();
}
let click = is_mouse_button_pressed(MouseButton::Left).then(Self::logical_mouse);
let clicked_ok = click.is_some_and(|point| Rect::new(255.0, 256.0, 60.0, 23.0).contains(point));
let clicked_ok =
click.is_some_and(|point| Rect::new(255.0, 256.0, 60.0, 23.0).contains(point));
let clicked_cancel =
click.is_some_and(|point| Rect::new(324.0, 256.0, 60.0, 23.0).contains(point));
if is_key_pressed(KeyCode::Escape) || clicked_cancel {
@@ -507,13 +559,13 @@ impl App {
}
fn accept_high_score_name(&mut self) {
insert_high_score(
&mut self.saved.high_scores,
HighScore {
name: self.name.clone(),
score: self.pending_score,
},
);
let entry = HighScore {
name: self.name.clone(),
score: self.pending_score,
};
insert_high_score(&mut self.saved.high_scores, entry.clone());
#[cfg(target_arch = "wasm32")]
self.persistence.submit_high_score(&entry);
self.save();
if self.game.as_ref().is_some_and(|game| game.finished) {
self.take_game_and_preserve_random_seed();
@@ -574,11 +626,8 @@ impl App {
}
if self.attract.magnetic_records_active() {
for (x, y, width, height) in [
(151, 389, 13, 49),
(11, 344, 13, 49),
(291, 346, 13, 49),
] {
for (x, y, width, height) in [(151, 389, 13, 49), (11, 344, 13, 49), (291, 346, 13, 49)]
{
self.draw_active_table_region(x, y, width, height);
}
}
@@ -605,12 +654,7 @@ impl App {
}
if let Some(item) = self.attract.item_index() {
draw_texture(
&self.assets.diamond[item],
123.0,
300.0,
WHITE,
);
draw_texture(&self.assets.diamond[item], 123.0, 300.0, WHITE);
}
self.draw_intro_marquee();
}
@@ -929,7 +973,15 @@ impl App {
} else if (66..=128).contains(&frame) {
draw_texture_region(&self.assets.wheel, 79.0, 33.0, 91.0, 90.0, 180.0, 90.0);
if frame < 83 {
draw_texture_region(wide_a, 79.0, ((82 - frame) * 2) as f32, 91.0, 90.0, 0.0, 0.0);
draw_texture_region(
wide_a,
79.0,
((82 - frame) * 2) as f32,
91.0,
90.0,
0.0,
0.0,
);
} else {
let height = (128 - frame) * 2;
draw_texture_region(
@@ -1122,14 +1174,7 @@ impl App {
draw_centered("SETTINGS", 122.0, 30, BLACK);
let rows = [
format!("GRAPHICS DETAIL / SPEED: {}", self.saved.settings.speed),
format!(
"SOUND: {}",
if self.sounds_enabled {
"ON"
} else {
"OFF"
}
),
format!("SOUND: {}", if self.sounds_enabled { "ON" } else { "OFF" }),
format!("LANGUAGE: {}", self.saved.settings.language.label()),
];
for (index, row) in rows.iter().enumerate() {
@@ -1181,7 +1226,13 @@ impl App {
draw_rectangle(x, y, 270.0, 124.0, Color::from_rgba(192, 192, 192, 255));
draw_rectangle_lines(x, y, 270.0, 124.0, 2.0, WHITE);
draw_rectangle_lines(x + 2.0, y + 2.0, 266.0, 120.0, 2.0, DARKGRAY);
draw_rectangle(x + 4.0, y + 4.0, 262.0, 20.0, Color::from_rgba(0, 0, 128, 255));
draw_rectangle(
x + 4.0,
y + 4.0,
262.0,
20.0,
Color::from_rgba(0, 0, 128, 255),
);
draw_text(
"Congratulations! This is a Top Ten Score!",
x + 9.0,
@@ -1265,6 +1316,14 @@ impl App {
eprintln!("could not save settings and highscores: {error}");
}
}
#[cfg(target_arch = "wasm32")]
fn update_remote_high_scores(&mut self) {
if let Some(scores) = self.persistence.take_high_scores() {
self.saved.high_scores = scores;
self.save();
}
}
}
fn draw_panel(x: f32, y: f32, width: f32, height: f32) {
@@ -1319,13 +1378,7 @@ mod tests {
#[test]
fn attract_timer_uses_the_selected_detail_callback() {
for (detail, interval) in [
(1, 0.050),
(2, 0.040),
(3, 0.030),
(4, 0.020),
(5, 0.010),
] {
for (detail, interval) in [(1, 0.050), (2, 0.040), (3, 0.030), (4, 0.020), (5, 0.010)] {
let mut animation = AttractAnimation::default();
animation.update(interval - 0.001, detail);
assert_eq!(animation.tick, 0);
+130 -73
View File
@@ -1,3 +1,7 @@
#[cfg(target_arch = "wasm32")]
use futures_util::future::join_all;
#[cfg(target_arch = "wasm32")]
use macroquad::experimental::coroutines::{Coroutine, start_coroutine};
use macroquad::{
audio::{PlaySoundParams, Sound, load_sound_from_bytes, play_sound, stop_sound},
prelude::{FilterMode, Image, Texture2D},
@@ -24,6 +28,8 @@ pub struct Assets {
pub ball: Texture2D,
pub digits: Texture2D,
sounds: Vec<(u16, Sound)>,
#[cfg(target_arch = "wasm32")]
sound_loader: Option<Coroutine<Vec<(u16, Sound)>>>,
}
impl Assets {
@@ -33,7 +39,8 @@ impl Assets {
let inactive_table = texture(include_bytes!("../assets/original/images/dat_00998.png"));
let loading = texture(include_bytes!("../assets/original/images/dat_00995.png"));
let loading_progress = texture(include_bytes!("../assets/original/images/dat_00994.png"));
let highscore_background = texture(include_bytes!("../assets/original/images/dat_00993.png"));
let highscore_background =
texture(include_bytes!("../assets/original/images/dat_00993.png"));
let help = [
texture(include_bytes!("../assets/original/images/dat_01001.png")),
texture(include_bytes!("../assets/original/images/dat_01002.png")),
@@ -84,78 +91,18 @@ impl Assets {
let digits =
monochrome_texture(include_bytes!("../assets/original/images/bitmap_00500.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));
}
}
let sound_bytes = sound_bytes();
#[cfg(target_arch = "wasm32")]
let (sounds, sound_loader) = (
Vec::new(),
Some(start_coroutine(
async move { load_sounds(&sound_bytes).await },
)),
);
#[cfg(not(target_arch = "wasm32"))]
let sounds = load_sounds(&sound_bytes).await;
#[cfg(target_arch = "wasm32")]
macroquad::window::next_frame().await;
Self {
active_table,
@@ -178,9 +125,23 @@ impl Assets {
ball,
digits,
sounds,
#[cfg(target_arch = "wasm32")]
sound_loader,
}
}
#[cfg(target_arch = "wasm32")]
pub fn update(&mut self) {
let Some(loader) = self.sound_loader.as_ref() else {
return;
};
if !loader.is_done() {
return;
}
let loader = self.sound_loader.take().expect("sound loader must exist");
self.sounds = loader.retrieve().unwrap_or_default();
}
pub fn play(&self, id: u16, enabled: bool) {
if !enabled {
return;
@@ -204,6 +165,102 @@ impl Assets {
}
}
fn sound_bytes() -> [(u16, &'static [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"),
),
]
}
async fn load_sounds(sound_bytes: &[(u16, &[u8])]) -> Vec<(u16, Sound)> {
#[cfg(target_arch = "wasm32")]
{
join_all(sound_bytes.iter().map(|(id, bytes)| async move {
load_sound_from_bytes(bytes)
.await
.ok()
.map(|sound| (*id, sound))
}))
.await
.into_iter()
.flatten()
.collect()
}
#[cfg(not(target_arch = "wasm32"))]
{
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));
}
}
sounds
}
}
fn texture(bytes: &[u8]) -> Texture2D {
let texture = Texture2D::from_file_with_format(bytes, None);
texture.set_filter(FilterMode::Nearest);
+5 -4
View File
@@ -77,15 +77,16 @@ mod tests {
fn zero_bounds_still_advance_the_seed() {
let mut random = BorlandRandom::new(7);
assert_eq!(random.below(0), 0);
assert_eq!(random.seed(), 7_u32.wrapping_mul(MULTIPLIER).wrapping_add(1));
assert_eq!(
random.seed(),
7_u32.wrapping_mul(MULTIPLIER).wrapping_add(1)
);
}
#[test]
fn unit_interval_is_the_exact_unsigned_seed_fraction() {
let mut random = BorlandRandom::new(0xfedc_ba98);
let expected_seed = 0xfedc_ba98_u32
.wrapping_mul(MULTIPLIER)
.wrapping_add(1);
let expected_seed = 0xfedc_ba98_u32.wrapping_mul(MULTIPLIER).wrapping_add(1);
assert_eq!(
random.unit_interval().to_bits(),
(f64::from(expected_seed) / TWO_TO_32).to_bits()
+70 -29
View File
@@ -5,8 +5,7 @@ use crate::{original_physics::MilliVec, real48::Real48};
const SEARCH_RADIUS: i32 = 54_000;
const ONE: Real48 = Real48::from_bytes([0x81, 0, 0, 0, 0, 0]);
const TWO: Real48 = Real48::from_bytes([0x82, 0, 0, 0, 0, 0]);
const TWO_FIFTHS: Real48 =
Real48::from_bytes([0x7f, 0xcd, 0xcc, 0xcc, 0xcc, 0x4c]);
const TWO_FIFTHS: Real48 = Real48::from_bytes([0x7f, 0xcd, 0xcc, 0xcc, 0xcc, 0x4c]);
const THOUSAND: Real48 = Real48::from_bytes([0x8a, 0, 0, 0, 0, 0x7a]);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -171,14 +170,11 @@ fn penetration(geometry: Geometry, delta: i32, ball: MilliVec) -> i32 {
.wrapping_add(43_000)
.wrapping_sub(geometry.pivot.y),
);
geometry
.pivot
.y
.wrapping_sub(
Real48::from_i32(numerator)
.divide(Real48::from_i32(edge_dx))
.round_i32(),
)
geometry.pivot.y.wrapping_sub(
Real48::from_i32(numerator)
.divide(Real48::from_i32(edge_dx))
.round_i32(),
)
} else {
geometry.positive_edge.y.wrapping_add(43_000)
}
@@ -299,8 +295,14 @@ mod tests {
let velocities = [
MilliVec { x: 0, y: 2_000 },
MilliVec { x: 1_000, y: 2_000 },
MilliVec { x: -1_000, y: 2_000 },
MilliVec { x: 2_700, y: -2_700 },
MilliVec {
x: -1_000,
y: 2_000,
},
MilliVec {
x: 2_700,
y: -2_700,
},
];
let mut hash = 14_695_981_039_346_656_037_u64;
let mut hits = 0_u32;
@@ -315,10 +317,10 @@ mod tests {
Real48::from_bytes([0x80, 0, 0, 0, 0, 0]),
3_800,
);
let (hit, velocity, movement) = response.map_or(
(0_u32, input_velocity, MilliVec::default()),
|response| (1, response.velocity, response.movement),
);
let (hit, velocity, movement) = response
.map_or((0_u32, input_velocity, MilliVec::default()), |response| {
(1, response.velocity, response.movement)
});
hits += hit;
for value in [
i32_bits(x),
@@ -342,40 +344,73 @@ mod tests {
fn four_direction_vectors_match_the_reconstructed_c_harness() {
let cases = [
(
MilliVec { x: 104_000, y: 384_000 },
MilliVec {
x: 104_000,
y: 384_000,
},
MilliVec { x: 1_000, y: 2_000 },
-1,
FlipperSide::Left,
Real48::from_bytes([0x80, 0, 0, 0, 0, 0]),
MilliVec { x: 1_266, y: 1_511 },
MilliVec { x: -6_703, y: -8_000 },
MilliVec {
x: -6_703,
y: -8_000,
},
),
(
MilliVec { x: 209_000, y: 419_000 },
MilliVec { x: -1_000, y: 2_000 },
MilliVec {
x: 209_000,
y: 419_000,
},
MilliVec {
x: -1_000,
y: 2_000,
},
1,
FlipperSide::Right,
Real48::from_bytes([0x80, 0, 0, 0, 0, 0x40]),
MilliVec { x: -737, y: 2_263 },
MilliVec { x: -5_578, y: 17_127 },
MilliVec {
x: -5_578,
y: 17_127,
},
),
(
MilliVec { x: 104_000, y: 421_000 },
MilliVec {
x: 104_000,
y: 421_000,
},
MilliVec { x: 1_000, y: 2_000 },
1,
FlipperSide::Left,
Real48::from_bytes([0x7f, 0, 0, 0, 0, 0]),
MilliVec { x: 622, y: 2_320 },
MilliVec { x: 5_414, y: 20_193 },
MilliVec {
x: 5_414,
y: 20_193,
},
),
(
MilliVec { x: 209_000, y: 385_000 },
MilliVec { x: -1_000, y: 2_000 },
MilliVec {
x: 209_000,
y: 385_000,
},
MilliVec {
x: -1_000,
y: 2_000,
},
-1,
FlipperSide::Right,
Real48::ZERO,
MilliVec { x: -1_280, y: 1_560 },
MilliVec { x: 7_385, y: -9_000 },
MilliVec {
x: -1_280,
y: 1_560,
},
MilliVec {
x: 7_385,
y: -9_000,
},
),
];
for (ball, velocity, delta, side, response, expected_velocity, expected_movement) in cases {
@@ -398,7 +433,10 @@ mod tests {
fn boundary_vectors_match_the_live_original_binary() {
for (ball, velocity, delta, side, expected_velocity, expected_movement) in [
(
MilliVec { x: 100_000, y: 370_000 },
MilliVec {
x: 100_000,
y: 370_000,
},
MilliVec { x: 1_000, y: 2_000 },
-1,
FlipperSide::Left,
@@ -406,7 +444,10 @@ mod tests {
MilliVec::default(),
),
(
MilliVec { x: 209_000, y: 419_000 },
MilliVec {
x: 209_000,
y: 419_000,
},
MilliVec { x: 1_000, y: 2_000 },
1,
FlipperSide::Right,
+268 -177
View File
@@ -1,20 +1,34 @@
use macroquad::prelude::{Rect, Vec2, vec2};
use crate::{
borland_random::BorlandRandom,
flipper_physics::{FlipperSide, moving_flipper_response},
geometry::Segment,
original_physics::{
CollisionMaterial, CollisionResponse, GRAVITY_MILLI_PER_STEP,
MAXIMUM_SPEED_MILLI_PER_STEP, MilliVec, StaticCollisionCandidate,
ball_collision_response, capture_collision_candidate, circle_collision_candidate_at,
line_collision_candidate_at, milli_distance, path_intersects_circle,
CollisionMaterial,
CollisionResponse,
GRAVITY_MILLI_PER_STEP,
MAXIMUM_SPEED_MILLI_PER_STEP,
MilliVec,
StaticCollisionCandidate,
ball_collision_response,
capture_collision_candidate,
circle_collision_candidate_at,
line_collision_candidate_at,
milli_distance,
path_intersects_circle,
},
real48::Real48,
table::{
BUMPERS, EFFECT_SENSOR, LOCK_HOLES, PASSIVE_CIRCLES, TARGET_SENSORS, WALLS,
BUMPERS,
EFFECT_SENSOR,
LOCK_HOLES,
PASSIVE_CIRCLES,
SPECIAL_HOLE_SENSOR,
TARGET_SENSORS,
WALLS,
},
};
use macroquad::prelude::{Rect, Vec2, vec2};
const LEFT_FLIPPER_RAISED_TIP: Vec2 = Vec2::new(133.0, 377.0);
const RIGHT_FLIPPER_RAISED_TIP: Vec2 = Vec2::new(181.0, 376.0);
@@ -368,6 +382,7 @@ pub struct Game {
}
impl Game {
#[cfg(not(target_arch = "wasm32"))]
pub fn new(player_count: usize) -> Self {
Self::new_with_seed(player_count, macroquad::rand::rand())
}
@@ -446,6 +461,7 @@ impl Game {
true
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn begin_claw_scenario(&mut self, terminal_frame: u8) -> Vec<Event> {
self.ball.in_launcher = false;
self.ball.position = CLAW_TRIGGER_CENTER;
@@ -455,6 +471,7 @@ impl Game {
events
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn begin_effect_scenario(&mut self, effect: u8) {
self.target_effect = effect.min(7);
self.object_active[usize::from(EFFECT_SENSOR.id)] = self.target_effect != 0;
@@ -464,6 +481,7 @@ impl Game {
}
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn begin_target_scenario(&mut self) {
self.ball.in_launcher = false;
self.ball.position = vec2(170.0, 230.0);
@@ -483,7 +501,8 @@ impl Game {
let variation = self.random.below(3_800) / 40;
launch_velocity = -MAXIMUM_SPEED_MILLI_PER_STEP + i32::from(variation);
} else if launch_velocity > -2_280 {
launch_velocity = launch_velocity.wrapping_sub(i32::from(self.random.below(3_800) / 40));
launch_velocity =
launch_velocity.wrapping_sub(i32::from(self.random.below(3_800) / 40));
}
self.ball.in_launcher = false;
self.ball.velocity = MilliVec {
@@ -516,14 +535,15 @@ impl Game {
} else if self.tilted {
self.flipper_release_latch[1] = true;
}
self.flipper_inputs.left_raised = controls.left_flipper
&& !self.tilted
&& !self.flipper_release_latch[0];
self.flipper_inputs.right_raised = controls.right_flipper
&& !self.tilted
&& !self.flipper_release_latch[1];
self.flipper_inputs.left_raised =
controls.left_flipper && !self.tilted && !self.flipper_release_latch[0];
self.flipper_inputs.right_raised =
controls.right_flipper && !self.tilted && !self.flipper_release_latch[1];
if self.ball.in_launcher && !self.tilted {
// The original key handlers keep accepting the launcher key while
// tilted; Tilt disables flippers and scoring, but must not strand a
// ball that is still waiting in the shooter lane.
if self.ball.in_launcher {
if controls.launch_down {
if self.launcher_was_down {
self.launcher_hold_seconds += frame_time.min(0.05);
@@ -546,7 +566,9 @@ impl Game {
} else if self.launcher_was_down {
self.fire_launcher();
events.push(Event::Launch);
events.push(Event::Sound(2002));
if !self.tilted {
events.push(Event::Sound(2002));
}
}
}
if !self.tilted && controls.nudge != Nudge::None {
@@ -576,10 +598,7 @@ impl Game {
self.score_mode = ScoreMode::Multiball;
}
for _ in 0..substeps {
if self.fixed_update(events)
|| self.finished
|| self.claw.ball_suspended
{
if self.fixed_update(events) || self.finished || self.claw.ball_suspended {
break;
}
}
@@ -609,22 +628,18 @@ impl Game {
}
}
self.pending_flipper_edges[0] = match (
self.flipper_inputs.left_raised,
self.flippers.left_raised,
) {
(true, false) => -1,
(false, true) => 1,
_ => 0,
};
self.pending_flipper_edges[1] = match (
self.flipper_inputs.right_raised,
self.flippers.right_raised,
) {
(true, false) => -1,
(false, true) => 1,
_ => 0,
};
self.pending_flipper_edges[0] =
match (self.flipper_inputs.left_raised, self.flippers.left_raised) {
(true, false) => -1,
(false, true) => 1,
_ => 0,
};
self.pending_flipper_edges[1] =
match (self.flipper_inputs.right_raised, self.flippers.right_raised) {
(true, false) => -1,
(false, true) => 1,
_ => 0,
};
self.flippers = self.flipper_inputs;
for edge in self.pending_flipper_edges {
if edge != 0 {
@@ -706,7 +721,11 @@ impl Game {
match nudge {
Nudge::Left | Nudge::Right => {
let amount = (50 - i32::from(self.random.below(20))) * SCALAR;
let signed = if nudge == Nudge::Left { -amount } else { amount };
let signed = if nudge == Nudge::Left {
-amount
} else {
amount
};
velocity.x = velocity.x.wrapping_add(signed);
}
Nudge::Center => {
@@ -726,12 +745,10 @@ impl Game {
}
if let Some(secondary) = &mut self.secondary_ball {
let slot_impulse = match nudge {
Nudge::Left | Nudge::Right => {
MilliVec {
x: if nudge == Nudge::Left { -600 } else { 600 },
y: 0,
}
}
Nudge::Left | Nudge::Right => MilliVec {
x: if nudge == Nudge::Left { -600 } else { 600 },
y: 0,
},
Nudge::Center => {
let horizontal = (i32::from(self.random.below(21)) - 10) * SCALAR;
let vertical = -(i32::from(self.random.below(100)) + 50) * SCALAR;
@@ -1070,13 +1087,7 @@ impl Game {
best = Some((id, false, candidate));
}
ball.velocity = velocity.to_velocity_per_second();
(
best,
SensorScanResult {
action,
},
predicted,
)
(best, SensorScanResult { action }, predicted)
}
fn find_static_collision_candidate_in_range(
@@ -1317,12 +1328,7 @@ impl Game {
collided || scan.action == BallAction::Suspend
}
fn apply_bumper_rule(
&mut self,
object_id: u8,
auxiliary_fired: bool,
events: &mut Vec<Event>,
) {
fn apply_bumper_rule(&mut self, object_id: u8, auxiliary_fired: bool, events: &mut Vec<Event>) {
if self.tilted || !BUMPERS.iter().any(|bumper| bumper.id == object_id) {
return;
}
@@ -1504,11 +1510,7 @@ impl Game {
if !self.tilted
&& (TARGET_SENSORS.into_iter().any(|sensor| {
self.object_active[usize::from(sensor.id)]
&& trigger_broadphase_contains(
predicted_position,
sensor.center,
sensor.radius,
)
&& trigger_broadphase_contains(predicted_position, sensor.center, sensor.radius)
}) || self.object_active[usize::from(EFFECT_SENSOR.id)]
&& trigger_broadphase_contains(
predicted_position,
@@ -1549,56 +1551,32 @@ impl Game {
}
}
if let Some(completed_action) =
self.check_lock_holes(
ball,
ball_number,
ball_count,
old_position,
predicted_position,
&mut capture_candidate,
events,
)
{
action = completed_action;
}
self.check_target_sensors(
ball,
old_position,
movement_velocity,
140..=147,
events,
);
if let Some(completed_action) =
self.check_special_hole(
ball,
ball_number,
ball_count,
old_position,
predicted_position,
&mut capture_candidate,
events,
)
{
action = completed_action;
}
self.check_effect_sensor(
if let Some(completed_action) = self.check_lock_holes(
ball,
ball_number,
ball_count,
old_position,
movement_velocity,
predicted_position,
&mut capture_candidate,
events,
);
self.check_target_sensors(
ball,
old_position,
movement_velocity,
150..=152,
events,
);
SensorScanResult {
action,
) {
action = completed_action;
}
self.check_target_sensors(ball, old_position, movement_velocity, 140..=147, events);
if let Some(completed_action) = self.check_special_hole(
ball,
ball_number,
ball_count,
old_position,
predicted_position,
&mut capture_candidate,
events,
) {
action = completed_action;
}
self.check_effect_sensor(ball, ball_number, old_position, movement_velocity, events);
self.check_target_sensors(ball, old_position, movement_velocity, 150..=152, events);
SensorScanResult { action }
}
#[allow(clippy::cast_possible_truncation, clippy::too_many_arguments)]
@@ -1832,7 +1810,14 @@ impl Game {
return false;
}
let effect_index = usize::from(EFFECT_SENSOR.id);
if self.object_active[effect_index] {
let predicted_position = old_position.add(movement_velocity);
if self.object_active[effect_index]
&& trigger_broadphase_contains(
predicted_position,
EFFECT_SENSOR.center,
EFFECT_SENSOR.radius,
)
{
let touched = path_intersects_circle(
old_position,
movement_velocity,
@@ -1858,7 +1843,8 @@ impl Game {
self.players[self.current_player].secondary_score = 0;
}
7 if self.record_contacts[usize::from(SPECIAL_HOLE_SENSOR.id)] != 0
&& self.record_contacts[usize::from(SPECIAL_HOLE_SENSOR.id)] != ball_number
&& self.record_contacts[usize::from(SPECIAL_HOLE_SENSOR.id)]
!= ball_number
&& self.secondary_ball.is_none()
&& matches!(
self.multiball_state,
@@ -1898,17 +1884,27 @@ impl Game {
return false;
}
let mut triggered = false;
let predicted_position = old_position.add(movement_velocity);
for sensor in TARGET_SENSORS {
if !record_ids.contains(&sensor.id) {
continue;
}
let contact_index = usize::from(sensor.id);
// The original scanner does not call a type-4 handler until the
// predicted point is inside that record's registered bounds. In
// multiball this also prevents an unrelated ball outside the
// corridor from clearing the ball that is currently inside it.
if !self.object_active[contact_index]
|| !trigger_broadphase_contains(predicted_position, sensor.center, sensor.radius)
{
continue;
}
let touched = path_intersects_circle(
old_position,
movement_velocity,
sensor.center,
sensor.radius,
);
let contact_index = usize::from(sensor.id);
let entered = touched && !self.trigger_flags[contact_index];
self.trigger_flags[contact_index] = touched;
if !entered {
@@ -1999,10 +1995,7 @@ impl Game {
.round_i32()
.wrapping_neg();
let predicted = old_position.add(*velocity);
if predicted.x < min_x
|| predicted.x > max_x
|| predicted.y < min_y
|| predicted.y > max_y
if predicted.x < min_x || predicted.x > max_x || predicted.y < min_y || predicted.y > max_y
{
self.object_active[object_id] = false;
}
@@ -2057,6 +2050,7 @@ impl Game {
}
}
#[cfg(not(target_arch = "wasm32"))]
fn begin_claw_capture(&mut self, terminal_frame: u8, events: &mut Vec<Event>) {
debug_assert!(CLAW_TERMINAL_FRAMES.contains(&terminal_frame));
if self.claw.active {
@@ -2179,6 +2173,7 @@ impl Game {
return;
}
self.ball_double = BallDoubleState::Inactive;
self.multiball_state = MultiballState::Unavailable;
self.special_hole_gate = SpecialHoleGate::Enabled;
let player = &mut self.players[self.current_player];
if player.extra_balls > 0 {
@@ -2225,7 +2220,11 @@ impl Game {
fn special_respawn_pending(&self) -> bool {
self.secondary_ball.is_none()
&& self.score_mode == ScoreMode::Normal
&& matches!(self.multiball_state, MultiballState::Ready | MultiballState::Active)
&& self.record_contacts[usize::from(SPECIAL_HOLE_SENSOR.id)] != 0
&& matches!(
self.multiball_state,
MultiballState::Ready | MultiballState::Active
)
}
fn save_current_rule_state(&mut self) {
@@ -2336,16 +2335,14 @@ fn claw_release(frame: u8) -> (Vec2, Vec2) {
fn apply_flipper_response_to_ball(ball: &mut Ball, delta: i32, side: FlipperSide) {
let position = MilliVec::from_position(ball.position);
let velocity = MilliVec::from_velocity_per_second(ball.velocity);
if let Some(response) =
moving_flipper_response(
position,
velocity,
delta,
side,
Real48::from_bytes([0x80, 0, 0, 0, 0, 0]),
MAXIMUM_SPEED_MILLI_PER_STEP,
)
{
if let Some(response) = moving_flipper_response(
position,
velocity,
delta,
side,
Real48::from_bytes([0x80, 0, 0, 0, 0, 0]),
MAXIMUM_SPEED_MILLI_PER_STEP,
) {
ball.position = position.add(response.movement).to_position();
ball.velocity = response.velocity.to_velocity_per_second();
}
@@ -2379,11 +2376,7 @@ mod tests {
game.ball.in_launcher = false;
game.ball.position = center;
game.ball.velocity = Vec2::ZERO;
game.check_sensor_objects(
MilliVec::from_position(center),
MilliVec::default(),
events,
);
game.check_sensor_objects(MilliVec::from_position(center), MilliVec::default(), events);
}
}
@@ -2397,7 +2390,8 @@ mod tests {
) -> CaptureStep {
let mut ball = game.ball;
let mut capture_candidate = None;
let predicted_position = previous_position.add(MilliVec::from_velocity_per_second(ball.velocity));
let predicted_position =
previous_position.add(MilliVec::from_velocity_per_second(ball.velocity));
let result = game.capture_record_step(
&mut ball,
1,
@@ -2456,8 +2450,8 @@ mod tests {
game.object_active[usize::from(wall.id)] = true;
game.ball.in_launcher = false;
game.ball.position = midpoint - normal;
game.ball.velocity = MilliVec::from_velocity_per_second(normal * 200.0)
.to_velocity_per_second();
game.ball.velocity =
MilliVec::from_velocity_per_second(normal * 200.0).to_velocity_per_second();
game.fixed_update(&mut Vec::new());
if game.last_collision_id != Some(wall.id) {
missed_walls.push(wall.id);
@@ -2478,7 +2472,10 @@ mod tests {
}
}
assert!(missed_walls.is_empty(), "missed production wall records: {missed_walls:?}");
assert!(
missed_walls.is_empty(),
"missed production wall records: {missed_walls:?}"
);
assert!(
missed_circles.is_empty(),
"missed production circle records: {missed_circles:?}"
@@ -2656,6 +2653,17 @@ mod tests {
assert_eq!(game.launcher_frame(), 10);
}
#[test]
fn maximum_launch_uses_the_initial_random_seed_for_trajectory_variation() {
let mut first = Game::new_with_seed(1, 1);
let mut second = Game::new_with_seed(1, 2);
launch_ball(&mut first, 60);
launch_ball(&mut second, 60);
assert_ne!(first.ball.velocity, second.ball.velocity);
}
#[test]
fn charged_ball_clears_the_shooter_lane() {
for detail in 1..=5 {
@@ -2802,6 +2810,7 @@ mod tests {
let mut special = Game::new(1);
special.ball_double = BallDoubleState::Active;
special.multiball_state = MultiballState::Ready;
special.record_contacts[148] = 2;
special.drain(&mut Vec::new());
assert_eq!(special.ball_double, BallDoubleState::Active);
assert_eq!(special.ball.position, vec2(17.0, 23.0));
@@ -2869,9 +2878,11 @@ mod tests {
assert_eq!(special.multiball_state, MultiballState::Unavailable);
assert_eq!(special.record_contacts[148], 0);
assert!(!special_events.contains(&Event::Drain));
assert!(!special_events
.iter()
.any(|event| event.sound_resource().is_some()));
assert!(
!special_events
.iter()
.any(|event| event.sound_resource().is_some())
);
}
#[test]
@@ -3179,10 +3190,7 @@ mod tests {
);
assert_eq!(
MilliVec::from_velocity_per_second(game.ball.velocity),
MilliVec {
x: 0,
y: 3_055,
}
MilliVec { x: 0, y: 3_055 }
);
assert_eq!(game.special_hole_gate, SpecialHoleGate::Suppressed);
@@ -3219,6 +3227,10 @@ mod tests {
assert_eq!(game.record_contacts[148], 0);
game.drain(&mut events);
assert!(game.secondary_ball.is_none());
game.drain(&mut events);
assert!(game.ball.in_launcher);
assert_eq!(game.player().balls, 2);
assert_eq!(game.multiball_state, MultiballState::Unavailable);
events.clear();
game.apply_wall_rule(84, &mut events);
@@ -3240,6 +3252,7 @@ mod tests {
fn record_two_response_continues_after_special_respawn_like_live_original() {
let mut game = Game::new(1);
game.multiball_state = MultiballState::Active;
game.record_contacts[148] = 2;
game.ball.in_launcher = false;
game.ball.position = vec2(157.0, 453.0);
game.ball.velocity = MilliVec { x: 0, y: 3_000 }.to_velocity_per_second();
@@ -3255,10 +3268,7 @@ mod tests {
);
assert_eq!(
MilliVec::from_velocity_per_second(game.ball.velocity),
MilliVec {
x: -302,
y: -1_809,
}
MilliVec { x: -302, y: -1_809 }
);
assert_eq!(game.multiball_state, MultiballState::Unavailable);
assert_eq!(game.special_hole_gate, SpecialHoleGate::Suppressed);
@@ -3297,9 +3307,7 @@ mod tests {
spin: Real48::ZERO,
capture_age: 0,
};
let old_position = MilliVec::from_position(
sensor.center - vec2(sensor.radius + 1.0, 0.0),
);
let old_position = MilliVec::from_position(sensor.center - vec2(sensor.radius + 1.0, 0.0));
let current_position = MilliVec::from_position(sensor.center);
let movement = MilliVec {
x: current_position.x.wrapping_sub(old_position.x),
@@ -3391,7 +3399,10 @@ mod tests {
BallAction::Keep
);
assert_eq!(game.secondary_ball.map(|ball| ball.position), Some(secondary.position));
assert_eq!(
game.secondary_ball.map(|ball| ball.position),
Some(secondary.position)
);
assert_eq!(game.multiball_state, MultiballState::Active);
assert_eq!(game.target_effect, 0);
}
@@ -3511,7 +3522,6 @@ mod tests {
assert!(game.secondary_ball.is_none());
assert_eq!(game.player().balls, 3);
assert_eq!(game.score_mode, ScoreMode::Normal);
}
#[test]
@@ -3605,10 +3615,7 @@ mod tests {
assert!(game.fixed_update(&mut events));
assert_eq!(game.last_collision_id, Some(57));
assert_eq!(
game.ball.spin.bytes(),
[0x83, 0x5d, 0x8f, 0xc2, 0xf5, 0xa8]
);
assert_eq!(game.ball.spin.bytes(), [0x83, 0x5d, 0x8f, 0xc2, 0xf5, 0xa8]);
assert_eq!(
MilliVec::from_position(game.ball.position),
MilliVec {
@@ -3645,10 +3652,7 @@ mod tests {
assert!(game.fixed_update(&mut events));
assert_eq!(game.last_collision_id, Some(72));
assert_eq!(
game.ball.spin.bytes(),
[0x83, 0x86, 0xeb, 0x51, 0xb8, 0x2e]
);
assert_eq!(game.ball.spin.bytes(), [0x83, 0x86, 0xeb, 0x51, 0xb8, 0x2e]);
assert_eq!(
MilliVec::from_position(game.ball.position),
MilliVec {
@@ -3658,7 +3662,10 @@ mod tests {
);
assert_eq!(
MilliVec::from_velocity_per_second(game.ball.velocity),
MilliVec { x: -2_095, y: 1_669 }
MilliVec {
x: -2_095,
y: 1_669
}
);
}
@@ -3928,8 +3935,7 @@ mod tests {
#[test]
fn raised_flipper_tips_use_their_asymmetric_swept_bounds() {
for (object_id, center) in [(67_u8, vec2(133.0, 377.0)), (82, vec2(181.0, 376.0))]
{
for (object_id, center) in [(67_u8, vec2(133.0, 377.0)), (82, vec2(181.0, 376.0))] {
let mut game = Game::new(1);
game.object_active.fill(false);
game.object_active[usize::from(object_id)] = true;
@@ -4012,7 +4018,13 @@ mod tests {
let velocity_after_edge = game.ball.velocity;
assert_eq!(game.ball.position, vec2(138.124, 355.0));
let raw_velocity = MilliVec::from_velocity_per_second(velocity_after_edge);
assert_eq!(raw_velocity, MilliVec { x: 2_209, y: -5_891 });
assert_eq!(
raw_velocity,
MilliVec {
x: 2_209,
y: -5_891
}
);
assert_eq!(game.pending_flipper_edges, [0, 0]);
game.apply_flipper_kicks();
@@ -4102,20 +4114,20 @@ mod tests {
#[test]
fn tilt_counter_decays_once_per_selected_detail_callback() {
for (detail, interval) in [
(1, 0.050),
(2, 0.040),
(3, 0.030),
(4, 0.020),
(5, 0.010),
] {
for (detail, interval) in [(1, 0.050), (2, 0.040), (3, 0.030), (4, 0.020), (5, 0.010)] {
let mut game = Game::new(1);
game.tilt_counter = 2;
game.update(interval - 0.001, detail, Controls::default());
assert_eq!(game.tilt_counter, 2, "detail {detail} decayed before its callback");
assert_eq!(
game.tilt_counter, 2,
"detail {detail} decayed before its callback"
);
game.update(0.001_1, detail, Controls::default());
assert_eq!(game.tilt_counter, 1, "detail {detail} did not decay at its callback");
assert_eq!(
game.tilt_counter, 1,
"detail {detail} did not decay at its callback"
);
}
}
@@ -4340,7 +4352,7 @@ mod tests {
);
assert_eq!(game.player().score, 500, "contact must score only once");
game.ball.position = vec2(205.0, 70.0);
game.ball.position = vec2(205.0, 62.0);
game.check_sensor_objects(
MilliVec::from_position(game.ball.position),
MilliVec::default(),
@@ -4355,6 +4367,47 @@ mod tests {
assert_eq!(game.player().score, 1_000);
}
#[test]
fn type_four_latch_is_not_cleared_by_a_ball_outside_its_broadphase() {
let sensor = TARGET_SENSORS
.into_iter()
.find(|sensor| sensor.id == 140)
.expect("record 140 must be present");
let mut game = Game::new(1);
let mut primary = game.ball;
primary.position = sensor.center;
let mut events = Vec::new();
game.check_target_sensors(
&mut primary,
MilliVec::from_position(sensor.center),
MilliVec::default(),
140..=147,
&mut events,
);
assert_eq!(game.player().score, sensor.score);
let mut unrelated = primary;
unrelated.position = vec2(200.0, 200.0);
let unrelated_position = MilliVec::from_position(unrelated.position);
game.check_target_sensors(
&mut unrelated,
unrelated_position,
MilliVec::default(),
140..=147,
&mut events,
);
game.check_target_sensors(
&mut primary,
MilliVec::from_position(sensor.center),
MilliVec::default(),
140..=147,
&mut events,
);
assert_eq!(game.player().score, sensor.score);
}
#[test]
fn magnetic_rectangles_use_old_position_for_entry_and_prediction_for_exit() {
let mut game = Game::new_with_seed(1, 7);
@@ -4462,11 +4515,7 @@ mod tests {
5
);
complete_stationary_type_three_capture(
&mut game,
SPECIAL_HOLE_SENSOR.center,
&mut events,
);
complete_stationary_type_three_capture(&mut game, SPECIAL_HOLE_SENSOR.center, &mut events);
assert!(game.wheel_holes.iter().all(|filled| *filled));
assert_eq!(game.multiball_state, MultiballState::Ready);
assert_eq!(game.record_contacts[148], 2);
@@ -4696,7 +4745,11 @@ mod tests {
assert_eq!(game.panel_frame, None);
assert_eq!(game.wheel_holes, [false; 5]);
assert!(game.record_contacts[129..=133].iter().all(|contact| *contact == 0));
assert!(
game.record_contacts[129..=133]
.iter()
.all(|contact| *contact == 0)
);
assert_eq!(
events,
[
@@ -4758,7 +4811,11 @@ mod tests {
game.record_countdowns[51] = 5;
game.timer_tick(0.030, 0, &mut Vec::new());
assert_eq!(game.record_countdown(51), 5, "launcher idle pauses countdowns");
assert_eq!(
game.record_countdown(51),
5,
"launcher idle pauses countdowns"
);
game.ball.in_launcher = false;
for expected in (0..5).rev() {
@@ -4876,4 +4933,38 @@ mod tests {
assert!(game.tilted);
assert!(game.ball.in_launcher);
}
#[test]
fn tilted_waiting_ball_can_still_be_launched() {
let mut game = Game::new_with_seed(1, 7);
for _ in 0..2 {
game.update(
0.0,
3,
Controls {
nudge: Nudge::Right,
..Controls::default()
},
);
}
assert!(game.tilted);
assert!(game.ball.in_launcher);
game.update(
0.0,
3,
Controls {
launch_down: true,
..Controls::default()
},
);
assert!(game.launcher_frame() > 0);
let events = game.update(0.0, 3, Controls::default());
assert!(events.contains(&Event::Launch));
assert!(!events.contains(&Event::Sound(2002)));
assert!(!game.ball.in_launcher);
assert!(game.tilted);
}
}
+16 -10
View File
@@ -7,11 +7,13 @@ mod geometry;
mod original_physics;
mod persistence;
mod real48;
#[cfg(not(target_arch = "wasm32"))]
mod simulation;
mod table;
use app::App;
use macroquad::prelude::*;
#[cfg(not(target_arch = "wasm32"))]
use simulation::{Request, Scenario, Simulation, usage};
const WINDOW_WIDTH: i32 = 640;
@@ -30,18 +32,21 @@ fn window_conf() -> Conf {
#[macroquad::main(window_conf)]
async fn main() {
let request = match Request::parse(std::env::args()) {
Ok(request) => request,
Err(error) => {
eprintln!("{error}");
#[cfg(not(target_arch = "wasm32"))]
{
let request = match Request::parse(std::env::args()) {
Ok(request) => request,
Err(error) => {
eprintln!("{error}");
return;
}
};
if let Some(request) = request {
if let Err(error) = run_simulation(request).await {
eprintln!("{error}\n\n{}", usage());
}
return;
}
};
if let Some(request) = request {
if let Err(error) = run_simulation(request).await {
eprintln!("{error}\n\n{}", usage());
}
return;
}
let mut app = App::load().await;
@@ -51,6 +56,7 @@ async fn main() {
}
}
#[cfg(not(target_arch = "wasm32"))]
async fn run_simulation(request: Request) -> Result<(), String> {
let mut simulation = Simulation::new(request.scenario, request.seed);
simulation.advance_to(request.target_step);
+31 -38
View File
@@ -73,11 +73,7 @@ impl CollisionMaterial {
}
}
pub const fn circle(
normal_rebound: f64,
tangent_coupling: f64,
normal_kick: f64,
) -> Self {
pub const fn circle(normal_rebound: f64, tangent_coupling: f64, normal_kick: f64) -> Self {
Self {
normal_rebound,
tangent_coupling,
@@ -173,8 +169,7 @@ impl MilliVec {
// `maximum / speed` is mathematically equivalent, but can differ by
// one millipixel because each Real48 operation is rounded
// independently.
let excess = Real48::from_i32(speed.wrapping_sub(maximum))
.divide(Real48::from_i32(speed));
let excess = Real48::from_i32(speed.wrapping_sub(maximum)).divide(Real48::from_i32(speed));
self.x = self
.x
.wrapping_sub(Real48::from_i32(self.x).multiply(excess).round_i32());
@@ -262,11 +257,7 @@ fn tangent_velocity(velocity: MilliVec, normal_x: Real48, normal_y: Real48, leng
.round_i32()
}
fn cross_at_endpoint(
point: MilliVec,
current: MilliVec,
predicted: MilliVec,
) -> i32 {
fn cross_at_endpoint(point: MilliVec, current: MilliVec, predicted: MilliVec) -> i32 {
predicted
.x
.wrapping_sub(point.x)
@@ -383,9 +374,7 @@ pub fn line_collision_candidate_at(
let vertical_units = normal_y.divide(THOUSAND);
let distance = Real48::from_i32(old_position.x.wrapping_sub(start.x))
.multiply(vertical_units)
.subtract(
Real48::from_i32(old_position.y.wrapping_sub(start.y)).multiply(horizontal_units),
)
.subtract(Real48::from_i32(old_position.y.wrapping_sub(start.y)).multiply(horizontal_units))
.divide(length_units);
let distance = if distance.compare(ZERO).is_lt() {
ZERO.subtract(distance)
@@ -525,10 +514,7 @@ pub fn capture_collision_candidate(
let radius_milli = (radius * 1_000.0).round() as i32;
let delta = MilliVec {
x: center.x.wrapping_sub(old_position.x),
y: center
.y
.wrapping_sub(old_position.y)
.wrapping_sub(2_000),
y: center.y.wrapping_sub(old_position.y).wrapping_sub(2_000),
};
let surface_distance = milli_distance(delta).wrapping_sub(radius_milli);
let normal_x = Real48::from_i32(center.y.wrapping_sub(old_position.y));
@@ -541,9 +527,7 @@ pub fn capture_collision_candidate(
return None;
}
let normal_velocity = normal_velocity(velocity, normal_x, normal_y, length);
if normal_velocity >= 0
|| normal_velocity.wrapping_abs() < surface_distance.wrapping_abs()
{
if normal_velocity >= 0 || normal_velocity.wrapping_abs() < surface_distance.wrapping_abs() {
return None;
}
Some(StaticCollisionCandidate {
@@ -681,8 +665,14 @@ mod tests {
#[test]
fn float_views_roundtrip_every_gameplay_velocity_millipixel() {
for value in -10_000..=10_000 {
let milli = MilliVec { x: value, y: -value };
assert_eq!(MilliVec::from_velocity_per_second(milli.to_velocity_per_second()), milli);
let milli = MilliVec {
x: value,
y: -value,
};
assert_eq!(
MilliVec::from_velocity_per_second(milli.to_velocity_per_second()),
milli
);
}
}
@@ -697,8 +687,7 @@ mod tests {
if speed <= maximum {
continue;
}
let excess = Real48::from_i32(speed - maximum)
.divide(Real48::from_i32(speed));
let excess = Real48::from_i32(speed - maximum).divide(Real48::from_i32(speed));
let expected = MilliVec {
x: x.wrapping_sub(Real48::from_i32(x).multiply(excess).round_i32()),
y: y.wrapping_sub(Real48::from_i32(y).multiply(excess).round_i32()),
@@ -716,7 +705,10 @@ mod tests {
break;
}
}
assert!(mismatches.is_empty(), "speed-clamp mismatches: {mismatches:?}");
assert!(
mismatches.is_empty(),
"speed-clamp mismatches: {mismatches:?}"
);
}
#[test]
@@ -783,7 +775,9 @@ mod tests {
assert_eq!(candidate.surface_distance, -800);
assert_ne!(
candidate.resolve(MilliVec { x: 1_000, y: 0 }, Real48::ZERO).velocity,
candidate
.resolve(MilliVec { x: 1_000, y: 0 }, Real48::ZERO)
.velocity,
MilliVec { x: 1_000, y: 0 }
);
}
@@ -887,16 +881,15 @@ mod tests {
fn surface_distance_orders_candidate_contacts() {
let old = MilliVec::default();
let velocity = MilliVec { x: 10_000, y: 0 };
let near =
line_collision_response(
old,
velocity,
vec2(2.0, 1.0),
vec2(2.0, -1.0),
CollisionMaterial::line(0.6, 0.1),
Real48::ZERO,
)
.expect("near rail should be crossed");
let near = line_collision_response(
old,
velocity,
vec2(2.0, 1.0),
vec2(2.0, -1.0),
CollisionMaterial::line(0.6, 0.1),
Real48::ZERO,
)
.expect("near rail should be crossed");
let far = line_collision_response(
old,
velocity,
+64 -11
View File
@@ -1,6 +1,12 @@
use std::io;
#[cfg(not(target_arch = "wasm32"))]
use std::{fs, path::PathBuf};
#[cfg(not(target_arch = "wasm32"))]
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use std::{fs, io, path::PathBuf};
#[cfg(target_arch = "wasm32")]
use tdkpin_web_storage::{queue_high_score, queue_save, take_high_scores, take_loaded};
const ORIGINAL_HIGHSCORES: &[u8] = include_bytes!("../assets/original/HISCORES.DAT");
const ORIGINAL_INI: &str = include_str!("../../original/TDKPIN.INI");
@@ -74,10 +80,15 @@ impl Default for SavedData {
}
}
#[cfg(not(target_arch = "wasm32"))]
pub struct Persistence {
path: PathBuf,
}
#[cfg(target_arch = "wasm32")]
pub struct Persistence;
#[cfg(not(target_arch = "wasm32"))]
impl Persistence {
pub fn new() -> Self {
let path = ProjectDirs::from("com", "kiwi-hamburg", "TDK Pinball Machine").map_or_else(
@@ -91,11 +102,7 @@ impl Persistence {
fs::read_to_string(&self.path)
.ok()
.and_then(|text| serde_json::from_str(&text).ok())
.unwrap_or_else(|| SavedData {
settings: load_adjacent_original_settings()
.unwrap_or_else(|| parse_original_settings(ORIGINAL_INI)),
high_scores: parse_original_high_scores(ORIGINAL_HIGHSCORES),
})
.unwrap_or_else(native_default_saved_data)
}
pub fn save(&self, data: &SavedData) -> io::Result<()> {
@@ -117,6 +124,47 @@ impl Persistence {
}
}
#[cfg(target_arch = "wasm32")]
impl Persistence {
pub fn new() -> Self {
Self
}
#[allow(clippy::unused_self)]
pub fn load(&self) -> SavedData {
take_loaded()
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
.unwrap_or_default()
}
#[allow(clippy::unused_self)]
pub fn save(&self, data: &SavedData) -> io::Result<()> {
let encoded = serde_json::to_vec(data).map_err(io::Error::other)?;
queue_save(encoded);
Ok(())
}
pub fn take_high_scores(&self) -> Option<Vec<HighScore>> {
take_high_scores().and_then(|bytes| serde_json::from_slice(&bytes).ok())
}
pub fn submit_high_score(&self, entry: &HighScore) {
if let Ok(encoded) = serde_json::to_vec(entry) {
queue_high_score(encoded);
}
}
}
#[cfg(not(target_arch = "wasm32"))]
fn native_default_saved_data() -> SavedData {
SavedData {
settings: load_adjacent_original_settings()
.unwrap_or_else(|| parse_original_settings(ORIGINAL_INI)),
high_scores: parse_original_high_scores(ORIGINAL_HIGHSCORES),
}
}
#[cfg(not(target_arch = "wasm32"))]
fn load_adjacent_original_settings() -> Option<Settings> {
let executable = std::env::current_exe().ok()?;
let exact_name = executable.with_extension("INI");
@@ -154,7 +202,10 @@ pub fn parse_original_settings(text: &str) -> Settings {
_ => Language::English,
};
} else if key.trim().eq_ignore_ascii_case("Speed") {
settings.speed = u8::try_from(value).ok().filter(|speed| (1..=5).contains(speed)).unwrap_or(3);
settings.speed = u8::try_from(value)
.ok()
.filter(|speed| (1..=5).contains(speed))
.unwrap_or(3);
} else if key.trim().eq_ignore_ascii_case("Sound") {
settings.sounds = value != 0;
}
@@ -249,14 +300,16 @@ mod tests {
let settings = parse_original_settings(ORIGINAL_INI);
assert_eq!(settings.speed, 3);
assert_eq!(settings.language, Language::German);
assert!(settings.sounds, "the original reads singular Sound, not Sounds");
assert!(
settings.sounds,
"the original reads singular Sound, not Sounds"
);
}
#[test]
fn original_ini_values_are_case_insensitive_and_clamped() {
let settings = parse_original_settings(
"[settings]\nlanguage=5\nSPEED=9\nSound=0\nSounds=1\n",
);
let settings =
parse_original_settings("[settings]\nlanguage=5\nSPEED=9\nSound=0\nSounds=1\n");
assert_eq!(settings.speed, 3);
assert_eq!(settings.language, Language::Spanish);
assert!(!settings.sounds);
+7 -13
View File
@@ -167,8 +167,7 @@ impl Real48 {
}
let mut numerator = self.significand();
let denominator = right.significand();
let mut exponent =
i16::from(self.exponent()) - i16::from(right.exponent()) + EXPONENT_BIAS;
let mut exponent = i16::from(self.exponent()) - i16::from(right.exponent()) + EXPONENT_BIAS;
if numerator < denominator {
numerator <<= 1;
exponent -= 1;
@@ -254,16 +253,8 @@ impl Real48 {
assert!(highest_bit < 32, "Real48 integer overflow");
let significand = self.significand();
let shift = i16::try_from(FRACTION_BITS).unwrap_or(39) - highest_bit;
let mut magnitude = if shift >= 64 {
0
} else {
significand >> shift
};
if round
&& shift > 0
&& shift <= 40
&& (significand >> (shift - 1)) & 1 != 0
{
let mut magnitude = if shift >= 64 { 0 } else { significand >> shift };
if round && shift > 0 && shift <= 40 && (significand >> (shift - 1)) & 1 != 0 {
magnitude += 1;
}
let limit = if self.negative() {
@@ -344,7 +335,10 @@ mod tests {
assert_eq!(HALF.round_i32(), 1);
assert_eq!(Real48::from_bytes([0x80, 0, 0, 0, 0, 0x80]).round_i32(), -1);
assert_eq!(ONE.compare(TWO), Ordering::Less);
assert_eq!(Real48::from_i32(-2).compare(Real48::from_i32(-1)), Ordering::Less);
assert_eq!(
Real48::from_i32(-2).compare(Real48::from_i32(-1)),
Ordering::Less
);
assert_eq!(TWO.sqrt().bytes(), [0x81, 0xfa, 0x33, 0xf3, 0x04, 0x35]);
assert_eq!(
ONE_AND_HALF.sqrt().bytes(),
+33 -10
View File
@@ -1,11 +1,13 @@
use crate::game::{ClawSpriteBank, Controls, Event, Game};
use serde::Serialize;
use std::{
fs,
path::{Path, PathBuf},
str::FromStr,
};
use serde::Serialize;
use crate::game::{ClawSpriteBank, Controls, Event, Game};
pub const SIMULATION_HZ: u32 = 120;
const SIMULATION_HZ_F64: f64 = 120.0;
const SIMULATION_DT: f32 = 1.0 / 120.0;
@@ -376,9 +378,7 @@ fn controls_for(scenario: Scenario, step: u64) -> Controls {
| Scenario::Claw1
| Scenario::Claw6
| Scenario::Claw7
| Scenario::Claw18 => {
Controls::default()
}
| Scenario::Claw18 => Controls::default(),
}
}
@@ -507,9 +507,24 @@ mod tests {
.iter()
.flat_map(|snapshot| snapshot.events.iter().map(String::as_str))
.collect::<Vec<_>>();
assert_eq!(events.iter().filter(|event| **event == "StopSound").count(), 3);
assert_eq!(events.iter().filter(|event| **event == "Sound(2013)").count(), 4);
assert_eq!(events.iter().filter(|event| **event == "Sound(2012)").count(), 2);
assert_eq!(
events.iter().filter(|event| **event == "StopSound").count(),
3
);
assert_eq!(
events
.iter()
.filter(|event| **event == "Sound(2013)")
.count(),
4
);
assert_eq!(
events
.iter()
.filter(|event| **event == "Sound(2012)")
.count(),
2
);
}
#[test]
@@ -521,7 +536,10 @@ mod tests {
simulation.advance_to(22);
assert_eq!(simulation.game.target_rotation_state, Some(6));
assert_eq!(simulation.game.wheel_holes, [false, true, false, false, true]);
assert_eq!(
simulation.game.wheel_holes,
[false, true, false, false, true]
);
simulation.advance_to(26);
assert_eq!(simulation.game.target_rotation_state, None);
}
@@ -561,7 +579,12 @@ mod tests {
.filter(|event| **event == "ClawRelease")
.count();
assert!(captures >= 1, "{} must enter the claw", scenario.name());
assert_eq!(releases, 1, "{} must release its seeded capture", scenario.name());
assert_eq!(
releases,
1,
"{} must release its seeded capture",
scenario.name()
);
assert!(captures >= releases);
assert!(simulation.game.ball.position.is_finite());
assert!(simulation.game.ball.velocity.is_finite());
+6 -2
View File
@@ -9,9 +9,10 @@
//! each object's bounds by `param_26`. They must not be enlarged again by the
//! radius of the rendered ball.
use crate::geometry::Segment;
use macroquad::prelude::Vec2;
use crate::geometry::Segment;
#[derive(Clone, Copy, Debug)]
pub struct TableSegment {
pub id: u8,
@@ -425,7 +426,10 @@ mod tests {
.iter()
.find(|wall| wall.id == id)
.expect("relative line record must be present");
assert_eq!((wall.segment.start, wall.segment.end), (expected_start, expected_end));
assert_eq!(
(wall.segment.start, wall.segment.end),
(expected_start, expected_end)
);
}
for (id, expected_center) in [
(54, Vec2::new(53.0, 292.0)),
+25
View File
@@ -0,0 +1,25 @@
# TDK Pinball Machine web build
Build and serve the browser version from this directory with:
```sh
just web-serve
```
The game is compiled for `wasm32-unknown-unknown` and loaded into a fixed
640x460 canvas centered on the black page by `index.html`. Browser settings and
a fallback copy of the high-score table are saved in `localStorage`; native
builds continue to use their normal per-user save file.
The page includes the official Macroquad browser loader locally. The WASM
bootstrap is local as well, so the page does not require inline or third-party
JavaScript. The web page must be served over HTTP rather than opened directly
from a `file:` URL.
For a strict Content Security Policy, allow `script-src 'self'
'wasm-unsafe-eval'` for the browser's WebAssembly compilation and
`connect-src 'self'` for the local WASM and optional high-score API requests.
When the same-origin `/api/highscores` endpoint is available, the browser loads
and submits the shared top-ten table there. The small Axum service and an nginx
proxy example are in [highscore-server](../highscore-server/).
+3
View File
@@ -0,0 +1,3 @@
"use strict";
load("tdkpin-rs.wasm");
+53
View File
@@ -0,0 +1,53 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover"
/>
<meta
name="description"
content="Play the reconstructed 1995 TDK Pinball Machine in your browser."
/>
<title>TDK Pinball Machine 1.00</title>
<style>
:root {
color-scheme: dark;
background: #000;
}
html,
body {
width: 100%;
height: 100%;
margin: 0;
min-width: 640px;
min-height: 460px;
overflow: auto;
background: #000;
}
body {
display: grid;
place-items: center;
}
#glcanvas {
display: block;
width: 640px;
height: 460px;
background: #000;
outline: none;
image-rendering: pixelated;
}
</style>
</head>
<body>
<canvas id="glcanvas" tabindex="1" aria-label="TDK Pinball Machine"></canvas>
<noscript>This game needs JavaScript enabled.</noscript>
<script src="./mq_js_bundle.js"></script>
<script src="./storage.js"></script>
<script src="./bootstrap.js"></script>
</body>
</html>
File diff suppressed because one or more lines are too long
+145
View File
@@ -0,0 +1,145 @@
"use strict";
(function registerStoragePlugin() {
const storageKey = "tdkpin.save.v1";
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const highScoreApi = "api/highscores";
let lastRevision = 0;
let lastHighScoreRevision = 0;
let highScoreRequestInFlight = false;
let highScoreGeneration = 0;
let highScoreSubmissionWarningShown = false;
function browserStorage() {
try {
return window.localStorage;
} catch (_error) {
return null;
}
}
function sendSavedDataToRust() {
wasm_exports.tdkpin_browser_storage_clear();
const storage = browserStorage();
let saved = null;
try {
saved = storage?.getItem(storageKey);
} catch (_error) {
saved = null;
}
if (saved !== null && saved !== undefined) {
for (const byte of encoder.encode(saved)) {
wasm_exports.tdkpin_browser_storage_push(byte);
}
}
wasm_exports.tdkpin_browser_storage_finish();
}
function flushRustSave() {
const revision = wasm_exports.tdkpin_browser_storage_save_revision();
if (revision === lastRevision) {
return;
}
const bytes = new Uint8Array(
wasm_exports.tdkpin_browser_storage_save_length(),
);
for (let index = 0; index < bytes.length; index += 1) {
bytes[index] = wasm_exports.tdkpin_browser_storage_save_byte(index);
}
const storage = browserStorage();
try {
storage?.setItem(storageKey, decoder.decode(bytes));
} catch (_error) {
// Private browsing or a full quota should not stop the game loop.
}
wasm_exports.tdkpin_browser_storage_save_ack();
lastRevision = revision;
}
function deliverHighScores(json) {
wasm_exports.tdkpin_browser_high_scores_clear();
for (const byte of encoder.encode(json)) {
wasm_exports.tdkpin_browser_high_scores_push(byte);
}
wasm_exports.tdkpin_browser_high_scores_finish();
}
async function fetchHighScores() {
const generation = ++highScoreGeneration;
try {
const response = await fetch(highScoreApi, {
cache: "no-store",
headers: { Accept: "application/json" },
});
if (!response.ok) {
throw new Error(`high-score request failed (${response.status})`);
}
const json = await response.text();
if (generation === highScoreGeneration) {
deliverHighScores(json);
}
} catch (error) {
console.warn("shared high scores unavailable; using local scores", error);
}
}
async function flushHighScoreSubmission() {
if (highScoreRequestInFlight) {
return;
}
const revision = wasm_exports.tdkpin_browser_high_score_revision();
if (revision === lastHighScoreRevision) {
return;
}
const bytes = new Uint8Array(
wasm_exports.tdkpin_browser_high_score_length(),
);
for (let index = 0; index < bytes.length; index += 1) {
bytes[index] = wasm_exports.tdkpin_browser_high_score_byte(index);
}
highScoreRequestInFlight = true;
try {
const response = await fetch(highScoreApi, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: decoder.decode(bytes),
keepalive: true,
});
if (!response.ok) {
throw new Error(`high-score submission failed (${response.status})`);
}
const json = await response.text();
highScoreGeneration += 1;
deliverHighScores(json);
wasm_exports.tdkpin_browser_high_score_ack(revision);
lastHighScoreRevision = revision;
highScoreSubmissionWarningShown = false;
} catch (error) {
if (!highScoreSubmissionWarningShown) {
console.warn("shared high-score submission failed; will retry", error);
highScoreSubmissionWarningShown = true;
}
} finally {
highScoreRequestInFlight = false;
}
}
function onInit() {
sendSavedDataToRust();
lastRevision = wasm_exports.tdkpin_browser_storage_save_revision();
void fetchHighScores();
window.setInterval(flushRustSave, 50);
window.setInterval(() => void flushHighScoreSubmission(), 50);
window.addEventListener("beforeunload", flushRustSave);
}
miniquad_add_plugin({
name: "tdkpin_storage",
on_init: onInit,
version: 1,
});
})();
BIN
View File
Binary file not shown.
+8
View File
@@ -0,0 +1,8 @@
[package]
name = "tdkpin-web-storage"
version = "1.0.0"
edition = "2024"
publish = false
[lib]
path = "src/lib.rs"
+134
View File
@@ -0,0 +1,134 @@
#![allow(unsafe_code)]
use std::{
cell::{Cell, RefCell},
mem,
};
thread_local! {
static LOADED_BYTES: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) };
static LOADED_HIGH_SCORES: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) };
static PENDING_SAVE: RefCell<Option<Vec<u8>>> = const { RefCell::new(None) };
static PENDING_HIGH_SCORE: RefCell<Option<Vec<u8>>> = const { RefCell::new(None) };
static SAVE_REVISION: Cell<u32> = const { Cell::new(0) };
static HIGH_SCORE_REVISION: Cell<u32> = const { Cell::new(0) };
}
pub fn take_loaded() -> Option<Vec<u8>> {
let bytes = LOADED_BYTES.with(|loaded| mem::take(&mut *loaded.borrow_mut()));
(!bytes.is_empty()).then_some(bytes)
}
pub fn queue_save(bytes: Vec<u8>) {
PENDING_SAVE.with(|pending| *pending.borrow_mut() = Some(bytes));
SAVE_REVISION.with(|revision| revision.set(revision.get().wrapping_add(1)));
}
pub fn take_high_scores() -> Option<Vec<u8>> {
let bytes = LOADED_HIGH_SCORES.with(|loaded| mem::take(&mut *loaded.borrow_mut()));
(!bytes.is_empty()).then_some(bytes)
}
pub fn queue_high_score(bytes: Vec<u8>) {
PENDING_HIGH_SCORE.with(|pending| *pending.borrow_mut() = Some(bytes));
HIGH_SCORE_REVISION.with(|revision| revision.set(revision.get().wrapping_add(1)));
}
#[unsafe(no_mangle)]
pub extern "C" fn tdkpin_storage_crate_version() -> u32 {
1
}
#[unsafe(no_mangle)]
pub extern "C" fn tdkpin_browser_storage_clear() {
LOADED_BYTES.with(|loaded| loaded.borrow_mut().clear());
}
#[unsafe(no_mangle)]
pub extern "C" fn tdkpin_browser_storage_push(byte: u32) {
if let Ok(byte) = u8::try_from(byte) {
LOADED_BYTES.with(|loaded| loaded.borrow_mut().push(byte));
}
}
#[unsafe(no_mangle)]
pub extern "C" fn tdkpin_browser_storage_finish() {}
#[unsafe(no_mangle)]
pub extern "C" fn tdkpin_browser_high_scores_clear() {
LOADED_HIGH_SCORES.with(|loaded| loaded.borrow_mut().clear());
}
#[unsafe(no_mangle)]
pub extern "C" fn tdkpin_browser_high_scores_push(byte: u32) {
if let Ok(byte) = u8::try_from(byte) {
LOADED_HIGH_SCORES.with(|loaded| loaded.borrow_mut().push(byte));
}
}
#[unsafe(no_mangle)]
pub extern "C" fn tdkpin_browser_high_scores_finish() {}
#[unsafe(no_mangle)]
pub extern "C" fn tdkpin_browser_storage_save_revision() -> u32 {
SAVE_REVISION.with(Cell::get)
}
#[unsafe(no_mangle)]
pub extern "C" fn tdkpin_browser_storage_save_length() -> u32 {
PENDING_SAVE.with(|pending| {
pending
.borrow()
.as_ref()
.map_or(0, |bytes| u32::try_from(bytes.len()).unwrap_or(u32::MAX))
})
}
#[unsafe(no_mangle)]
pub extern "C" fn tdkpin_browser_storage_save_byte(index: u32) -> u32 {
PENDING_SAVE.with(|pending| {
pending
.borrow()
.as_ref()
.and_then(|bytes| bytes.get(usize::try_from(index).ok()?))
.map_or(0, |byte| u32::from(*byte))
})
}
#[unsafe(no_mangle)]
pub extern "C" fn tdkpin_browser_storage_save_ack() {
PENDING_SAVE.with(|pending| *pending.borrow_mut() = None);
}
#[unsafe(no_mangle)]
pub extern "C" fn tdkpin_browser_high_score_revision() -> u32 {
HIGH_SCORE_REVISION.with(Cell::get)
}
#[unsafe(no_mangle)]
pub extern "C" fn tdkpin_browser_high_score_length() -> u32 {
PENDING_HIGH_SCORE.with(|pending| {
pending
.borrow()
.as_ref()
.map_or(0, |bytes| u32::try_from(bytes.len()).unwrap_or(u32::MAX))
})
}
#[unsafe(no_mangle)]
pub extern "C" fn tdkpin_browser_high_score_byte(index: u32) -> u32 {
PENDING_HIGH_SCORE.with(|pending| {
pending
.borrow()
.as_ref()
.and_then(|bytes| bytes.get(usize::try_from(index).ok()?))
.map_or(0, |byte| u32::from(*byte))
})
}
#[unsafe(no_mangle)]
pub extern "C" fn tdkpin_browser_high_score_ack(revision: u32) {
if HIGH_SCORE_REVISION.with(Cell::get) == revision {
PENDING_HIGH_SCORE.with(|pending| *pending.borrow_mut() = None);
}
}