Compare commits
18
Commits
v1.0.0
...
12d1ec0aab
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12d1ec0aab
|
||
|
|
9c5b033c0a | ||
|
|
bc1ebcaaaa | ||
|
|
3dff722535 | ||
|
|
7ee2e71bc7
|
||
|
|
86434aaa2b
|
||
|
|
51502ef92f | ||
|
|
f9063bd2ff | ||
|
|
a36856e526 | ||
|
|
acb0c20b59 | ||
|
|
a81741b470 | ||
|
|
3b7b84affe | ||
|
|
5dd7f38450 | ||
|
|
ec0fdfd6b4 | ||
|
|
8fe9431989 | ||
|
|
a21f01c02d | ||
|
|
b079cfa196 | ||
|
|
c2f1443436
|
@@ -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
|
|
||||||
@@ -1,2 +1,4 @@
|
|||||||
target/
|
target/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
/tdkpin-rs/highscores.sqlite3
|
||||||
|
/tdkpin-rs/highscores.sqlite3-*
|
||||||
|
|||||||
@@ -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
@@ -8,6 +8,35 @@ and this project adheres to
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [1.0.0] - 2026-08-29
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
@@ -23,13 +52,13 @@ and this project adheres to
|
|||||||
a held ball remains velocity-stationary and the moving response retains its
|
a held ball remains velocity-stationary and the moving response retains its
|
||||||
full normal velocity instead of always subtracting 1,000.
|
full normal velocity instead of always subtracting 1,000.
|
||||||
- Restore `1000:ae6e`'s required-ball special respawn: armed or completed
|
- Restore `1000:ae6e`'s required-ball special respawn: armed or completed
|
||||||
effect-seven play grants one final `(17,23)/(0,3040)` ball without consuming
|
effect-seven play grants one final `(17,23)/(0,3040)` ball without consuming a
|
||||||
a marker, clears required state including record 148's contact word, and
|
marker, clears required state including record 148's contact word, and
|
||||||
suppresses record 148 until the next broadphase-active type-4 record
|
suppresses record 148 until the next broadphase-active type-4 record reenables
|
||||||
reenables it.
|
it.
|
||||||
- Replace the invented post-move `y>470` fallback with the original pre-scan
|
- Replace the invented post-move `y>470` fallback with the original pre-scan
|
||||||
prediction bounds `x<=0 || x>340000 || y<=0 || y>460000`, including
|
prediction bounds `x<=0 || x>340000 || y<=0 || y>460000`, including one-slot
|
||||||
one-slot removal during multiball and next-substep handling after a response.
|
removal during multiball and next-substep handling after a response.
|
||||||
- Remove the invented 180 ms whole-frame nudge shake; original nudges affect
|
- Remove the invented 180 ms whole-frame nudge shake; original nudges affect
|
||||||
ball-slot velocities, sound, and Tilt state without moving the viewport.
|
ball-slot velocities, sound, and Tilt state without moving the viewport.
|
||||||
- Match `SndPlaySound(SND_ASYNC|SND_NODEFAULT)` playback: stop the previous WAV
|
- 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.
|
collect-bonus, and release-ball states are now visible.
|
||||||
- Restore display blits from `1008:0996`/`1008:0b9e`: 130x40 score fields at
|
- 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,
|
X=478, eight places ending at X=606, exact 139x31 configured-player rows,
|
||||||
11x11 active/status markers, 5x5 remaining-ball markers for every player,
|
11x11 active/status markers, 5x5 remaining-ball markers for every player, and
|
||||||
and strict signed score-status thresholds independent of media awards.
|
strict signed score-status thresholds independent of media awards.
|
||||||
- Import exact `[Settings]` `Language`, `Speed`, and singular `Sound` values
|
- Import exact `[Settings]` `Language`, `Speed`, and singular `Sound` values
|
||||||
from an adjacent/original TDKPIN.INI before creating portable JSON. Fresh
|
from an adjacent/original TDKPIN.INI before creating portable JSON. Fresh
|
||||||
installs now inherit the distributed German help setting; F12 is again a
|
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
|
DAT995 at `(202,328)`, height 13 and clipped width `(stage-1)*7`; correct the
|
||||||
previously impossible Pascal-order C interpretation of a 328x202 blit.
|
previously impossible Pascal-order C interpretation of a 328x202 blit.
|
||||||
- Restore the original idle/attract timer animation: target and word-quad
|
- Restore the original idle/attract timer animation: target and word-quad
|
||||||
chases, magnetic flashes, five-record strip, four-point chase, DAT801-809
|
chases, magnetic flashes, five-record strip, four-point chase, DAT801-809 item
|
||||||
item progression, and the circular `WELCOME TO THE MACHINE` BITMAP500
|
progression, and the circular `WELCOME TO THE MACHINE` BITMAP500 marquee. Each
|
||||||
marquee. Each nested phase uses the binary's remainder first: `%32/2`,
|
nested phase uses the binary's remainder first: `%32/2`, `%32/4`, `%40/4`,
|
||||||
`%32/4`, `%40/4`, `%16/4`, and `%144/8`. Move gameplay item frames to their
|
`%16/4`, and `%144/8`. Move gameplay item frames to their exact `(123,300)`
|
||||||
exact `(123,300)` destination.
|
destination.
|
||||||
- Match central score/event side effects: every stored collision candidate
|
- Match central score/event side effects: every stored collision candidate
|
||||||
clears its ball slot's capture age, Tilt suppresses all score and rule-record
|
clears its ball slot's capture age, Tilt suppresses all score and rule-record
|
||||||
mutations, and active multiball doubles points before permanent Double can
|
mutations, and active multiball doubles points before permanent Double can
|
||||||
double them again.
|
double them again.
|
||||||
- Scan static, capture, trigger, magnetic, and dynamic record ranges in exact
|
- Scan static, capture, trigger, magnetic, and dynamic record ranges in exact ID
|
||||||
ID order with a separately tracked prediction. Preserve the raw scanner's
|
order with a separately tracked prediction. Preserve the raw scanner's
|
||||||
first-detected-candidate quirk even when a later record is geometrically
|
first-detected-candidate quirk even when a later record is geometrically
|
||||||
nearer, plus type-3 and dynamic-record broadphase behavior.
|
nearer, plus type-3 and dynamic-record broadphase behavior.
|
||||||
- Run type-3/type-4 processing before response and position publication:
|
- 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
|
candidates, triggers mutate the live motion first, and sensor IDs execute in
|
||||||
binary order without Hold/Complete prematurely ending the record scan.
|
binary order without Hold/Complete prematurely ending the record scan.
|
||||||
- Apply the collision table's predicted-position broadphase bounds before
|
- Apply the collision table's predicted-position broadphase bounds before
|
||||||
type-1/type-2 detection, including the registered five-pixel ball margin,
|
type-1/type-2 detection, including the registered five-pixel ball margin, and
|
||||||
and retain unresolved candidates until the selected response phase.
|
retain unresolved candidates until the selected response phase.
|
||||||
- Correct the two relative slingshot initializer chains after records 55 and
|
- Correct the two relative slingshot initializer chains after records 55 and 70:
|
||||||
70: circles 56/58/71/73 and lines 57/59/72/74 now accumulate from each
|
circles 56/58/71/73 and lines 57/59/72/74 now accumulate from each preceding
|
||||||
preceding path endpoint exactly as the 175-record ledger specifies. The
|
path endpoint exactly as the 175-record ledger specifies. The flagless TDK
|
||||||
flagless TDK companion lines also retain their zero contact/link field rather
|
companion lines also retain their zero contact/link field rather than carrying
|
||||||
than carrying unused synthetic head IDs. Record 57 additionally matches the
|
unused synthetic head IDs. Record 57 additionally matches the live original's
|
||||||
live original's `(75530,354765)/(3000,15)` to
|
`(75530,354765)/(3000,15)` to `(77369,356597)/(1839,1832)` transition;
|
||||||
`(77369,356597)/(1839,1832)` transition; symmetric record 72 matches
|
symmetric record 72 matches `(235348,356825)/(-3000,15)` to
|
||||||
`(235348,356825)/(-3000,15)` to `(233253,358494)/(-2095,1669)`.
|
`(233253,358494)/(-2095,1669)`.
|
||||||
- Match rectangular type-4 magnetic gates to raw `1000:9b69`: enter only when
|
- Match rectangular type-4 magnetic gates to raw `1000:9b69`: enter only when
|
||||||
the previous position is inside, then deactivate when the newly predicted
|
the previous position is inside, then deactivate when the newly predicted
|
||||||
position exits any edge, replacing a swept-intersection approximation.
|
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
|
- 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
|
formula's random draws, then add one shared fixed/random impulse to both slot
|
||||||
velocity records instead of applying different formulas to each ball.
|
velocity records instead of applying different formulas to each ball.
|
||||||
- Restore auxiliary response thresholds/kicks for bumpers 51-53 and line
|
- Restore auxiliary response thresholds/kicks for bumpers 51-53 and line records
|
||||||
records 55, 74, and 107, including weak-hit bumper scoring, WAVE 2019 on
|
55, 74, and 107, including weak-hit bumper scoring, WAVE 2019 on kicked
|
||||||
kicked unscored rails, and the original tilt suppression.
|
unscored rails, and the original tilt suppression.
|
||||||
- Preserve every collision record's `+0x49` layer mask and select upper layer
|
- Preserve every collision record's `+0x49` layer mask and select upper layer 1
|
||||||
1 or lower layer 2 from the ball's previous 250,000-millipixel Y boundary.
|
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
|
- 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
|
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
|
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
|
- 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
|
per-slot capture age/contact ownership, exact captured-slot removal, survivor
|
||||||
promotion, and the effect-seven ball-number guard against a third ball.
|
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
|
- Spawn effect-seven ball 2 from record 148 at `(17,23)` with exact `(0,3040)`
|
||||||
`(0,3040)` millipixel velocity, instead of launching a guessed second ball
|
millipixel velocity, instead of launching a guessed second ball upward from
|
||||||
upward from the shooter lane.
|
the shooter lane.
|
||||||
- Treat record 148 as the special/multiball hole represented by player item 20;
|
- 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
|
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
|
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
|
- 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.
|
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
|
- 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
|
109/112/115/118 from their exact overlay-A bounds until bank completion rearms
|
||||||
rearms each three-line group.
|
each three-line group.
|
||||||
- Replace the free-running wheel animation with the original six-callback
|
- Replace the free-running wheel animation with the original six-callback 91x90
|
||||||
91x90 target rotation, WAVE 2011 start, exact DAT600 frames/target positions,
|
target rotation, WAVE 2011 start, exact DAT600 frames/target positions, and
|
||||||
and state-6 rotation of the five per-player contact/item values. It advances
|
state-6 rotation of the five per-player contact/item values. It advances only
|
||||||
only for an active ball, pauses in the launcher, and survives a normal drain.
|
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
|
- Restore the complete 281-callback DAT600 panel sequence after the fifth lock
|
||||||
hole, including physics suspension, contact/item cleanup, all nine image
|
hole, including physics suspension, contact/item cleanup, all nine image
|
||||||
phases, and the original WAVE 2013/2012/stop boundaries. A fifth lock captured
|
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
|
- 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;
|
original F2/F3 keys to modern overlays. The idle view is the original table;
|
||||||
optional settings/high-score viewers move to F10/F9.
|
optional settings/high-score viewers move to F10/F9.
|
||||||
- Batch physics and flipper publication on the original 50/40/30/20/10-ms
|
- Batch physics and flipper publication on the original 50/40/30/20/10-ms detail
|
||||||
detail callbacks with exactly 5/4/3/2/1 internal 10-ms substeps, instead of
|
callbacks with exactly 5/4/3/2/1 internal 10-ms substeps, instead of exposing
|
||||||
exposing every substep on host render frames.
|
every substep on host render frames.
|
||||||
- Separate flipper key flags from rendered/collision position so press/release
|
- Separate flipper key flags from rendered/collision position so press/release
|
||||||
edges and WAVE 2021 occur only when the detail timer processes them.
|
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,
|
- 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.
|
magnetic impulses through its original rounding behavior.
|
||||||
- Preserve per-ball Real48 spin and use it in collision tangent response, so
|
- 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.
|
zero-spin C fixtures and retained-spin Wine traces are both represented.
|
||||||
- Implement dynamic records 174/175 with bidirectional ball impulse transfer
|
- Implement dynamic records 174/175 with bidirectional ball impulse transfer and
|
||||||
and the original post-collision speed clamp instead of a one-sided secondary
|
the original post-collision speed clamp instead of a one-sided secondary
|
||||||
bounce.
|
bounce.
|
||||||
- Split collision contact state into per-player 16-bit type-3 contact words and
|
- 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
|
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
|
- Match the original low-byte keyboard scans: both Ctrl keys operate the left
|
||||||
flipper, both Enter keys operate the right, and the physical `0x1b`
|
flipper, both Enter keys operate the right, and the physical `0x1b`
|
||||||
main-keyboard plus/right-bracket position starts or adds players.
|
main-keyboard plus/right-bracket position starts or adds players.
|
||||||
- Dispatch add-player, nudge, and F12 sound-toggle actions on key release through
|
- Dispatch add-player, nudge, and F12 sound-toggle actions on key release
|
||||||
the original `1000:638e` timing rather than on their initial key press.
|
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
|
- 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
|
Help, HighScore, or name-entry UI is open instead of controlling the main game
|
||||||
game through a child window.
|
through a child window.
|
||||||
- Route a single-ball type-3 completion during Tilt through `1000:ae6e` instead
|
- 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
|
of granting a free launcher reset: lock holes consume the ball, while record
|
||||||
148 can still take its required-state special-respawn branch.
|
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.
|
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
|
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
|
simultaneously crossed media marker leaves WAVE 2007 audible, as in the
|
||||||
monophonic original. A tilted drain suppresses WAVE 2008 before reset, while
|
monophonic original. A tilted drain suppresses WAVE 2008 before reset, while a
|
||||||
a following flipper-release edge may still play WAVE 2021 after reset.
|
following flipper-release edge may still play WAVE 2021 after reset.
|
||||||
Bumper/TDK bank completion WAVE 2017 likewise precedes its 50,000 or
|
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.
|
10,000..24,464 award, preserving the marker cue when that award crosses one.
|
||||||
- Advance media/extra-ball thresholds only from score additions, preserve
|
- Advance media/extra-ball thresholds only from score additions, preserve 32-bit
|
||||||
32-bit score wrapping, and restore the ninth-diamond ordering: its 24,464
|
score wrapping, and restore the ninth-diamond ordering: its 24,464 award
|
||||||
award remains single, then ball-scoped double scoring and all three magnetic
|
remains single, then ball-scoped double scoring and all three magnetic fields
|
||||||
fields activate before the collision's static score. Further completed banks
|
activate before the collision's static score. Further completed banks during
|
||||||
during that ball add 100,000 to the secondary score; a normal ball end clears
|
that ball add 100,000 to the secondary score; a normal ball end clears the
|
||||||
the multiplier, while effect seven's special respawn preserves it. Marker
|
multiplier, while effect seven's special respawn preserves it. Marker
|
||||||
comparisons retain the binary's signed-high/unsigned-low 32-bit ordering
|
comparisons retain the binary's signed-high/unsigned-low 32-bit ordering after
|
||||||
after score wrap.
|
score wrap.
|
||||||
- Match the original 640x460 window, native 16x28 score glyphs, and
|
- Match the original 640x460 window, native 16x28 score glyphs, and
|
||||||
`TDK Pinball Machine 1.00` title so the recovered artwork is presented
|
`TDK Pinball Machine 1.00` title so the recovered artwork is presented
|
||||||
one-for-one instead of enlarged or resampled.
|
one-for-one instead of enlarged or resampled.
|
||||||
|
|||||||
Generated
+55
-1
@@ -164,6 +164,42 @@ dependencies = [
|
|||||||
"ttf-parser",
|
"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]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -362,6 +398,12 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pin-project-lite"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "png"
|
name = "png"
|
||||||
version = "0.17.16"
|
version = "0.17.16"
|
||||||
@@ -481,6 +523,12 @@ version = "0.3.10"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "slab"
|
||||||
|
version = "0.4.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "smallvec"
|
name = "smallvec"
|
||||||
version = "0.6.14"
|
version = "0.6.14"
|
||||||
@@ -503,14 +551,20 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tdkpin-rs"
|
name = "tdkpin-rs"
|
||||||
version = "1.0.0"
|
version = "1.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"directories",
|
"directories",
|
||||||
|
"futures-util",
|
||||||
"macroquad",
|
"macroquad",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"tdkpin-web-storage",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tdkpin-web-storage"
|
||||||
|
version = "1.0.0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "2.0.20"
|
version = "2.0.20"
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "tdkpin-rs"
|
name = "tdkpin-rs"
|
||||||
version = "1.0.0"
|
version = "1.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
directories = "6"
|
|
||||||
macroquad = { version = "0.4", features = ["audio"] }
|
macroquad = { version = "0.4", features = ["audio"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
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]
|
[lints.clippy]
|
||||||
pedantic = { level = "warn", priority = -1 }
|
pedantic = { level = "warn", priority = -1 }
|
||||||
todo = "warn"
|
todo = "warn"
|
||||||
|
|||||||
+21
-4
@@ -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
|
no extra runtime installation; macOS builds with the normal Apple developer
|
||||||
command-line tools.
|
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
|
## Deterministic mechanics validation
|
||||||
|
|
||||||
Named scenarios can be advanced without waiting in real time. The simulator
|
Named scenarios can be advanced without waiting in real time. The simulator
|
||||||
@@ -75,10 +89,13 @@ box closes the program.
|
|||||||
|
|
||||||
## Saved data
|
## Saved data
|
||||||
|
|
||||||
Settings and the ten-entry high-score table are stored as `save.json` in the
|
Native settings and the ten-entry high-score table are stored as `save.json` in
|
||||||
platform's normal per-user application-data directory. On first run, settings
|
the platform's normal per-user application-data directory. Browser settings
|
||||||
are imported from an adjacent original INI (or the embedded distributed
|
and the fallback table use `localStorage`; a deployed browser build uses the
|
||||||
TDKPIN.INI), and the table is imported from the original `HISCORES.DAT`.
|
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
|
## Reconstruction status
|
||||||
|
|
||||||
|
|||||||
Generated
+805
@@ -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"
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
[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", "sync"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
http-body-util = "0.1"
|
||||||
|
tempfile = "3"
|
||||||
|
tokio = { version = "1", features = ["time"] }
|
||||||
|
tower = { version = "0.5", features = ["util"] }
|
||||||
|
|
||||||
|
[lints.clippy]
|
||||||
|
pedantic = { level = "warn", priority = -1 }
|
||||||
|
todo = "warn"
|
||||||
|
unwrap_used = "warn"
|
||||||
|
|
||||||
|
[lints.rust]
|
||||||
|
unsafe_code = "forbid"
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# 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
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy the rate and connection zone declarations from
|
||||||
|
[nginx.conf.example](nginx.conf.example) into the existing `http` block, then
|
||||||
|
place its two `location` blocks inside the public site's `server` block. The
|
||||||
|
example bounds per-client and aggregate API traffic, request bodies, and proxy
|
||||||
|
waits. 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,36 @@
|
|||||||
|
# Add these directives inside the existing nginx http block. The server-wide
|
||||||
|
# zones bound aggregate traffic, while the address-keyed zones prevent one
|
||||||
|
# client from consuming the whole allowance.
|
||||||
|
limit_req_zone $binary_remote_addr zone=tdkpin_highscore_client_rate:10m rate=5r/s;
|
||||||
|
limit_req_zone $server_name zone=tdkpin_highscore_global_rate:1m rate=50r/s;
|
||||||
|
limit_conn_zone $binary_remote_addr zone=tdkpin_highscore_client_connections:10m;
|
||||||
|
limit_conn_zone $server_name zone=tdkpin_highscore_global_connections:1m;
|
||||||
|
|
||||||
|
# Add these blocks inside the server block that serves the game.
|
||||||
|
location /api/highscores {
|
||||||
|
limit_req zone=tdkpin_highscore_client_rate burst=10 nodelay;
|
||||||
|
limit_req zone=tdkpin_highscore_global_rate burst=25 nodelay;
|
||||||
|
limit_req_status 429;
|
||||||
|
limit_conn tdkpin_highscore_client_connections 10;
|
||||||
|
limit_conn tdkpin_highscore_global_connections 100;
|
||||||
|
limit_conn_status 429;
|
||||||
|
|
||||||
|
client_max_body_size 1k;
|
||||||
|
client_body_timeout 5s;
|
||||||
|
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;
|
||||||
|
proxy_connect_timeout 2s;
|
||||||
|
proxy_send_timeout 5s;
|
||||||
|
proxy_read_timeout 5s;
|
||||||
|
proxy_next_upstream off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,534 @@
|
|||||||
|
use std::{
|
||||||
|
path::Path,
|
||||||
|
sync::{Arc, Mutex},
|
||||||
|
};
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
Router,
|
||||||
|
extract::{DefaultBodyLimit, State},
|
||||||
|
http::StatusCode,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
routing::get,
|
||||||
|
};
|
||||||
|
use rusqlite::{Connection, params, types::Type};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tokio::{sync::Semaphore, task};
|
||||||
|
|
||||||
|
const MAX_HIGH_SCORES: usize = 10;
|
||||||
|
const MAX_NAME_CHARS: usize = 21;
|
||||||
|
const MAX_SUBMISSION_BODY_BYTES: usize = 1024;
|
||||||
|
const MAX_CONCURRENT_DATABASE_OPERATIONS: usize = 1;
|
||||||
|
|
||||||
|
#[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(Clone)]
|
||||||
|
struct AppState {
|
||||||
|
store: HighScoreStore,
|
||||||
|
database_slots: Arc<Semaphore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
fn new(store: HighScoreStore) -> Self {
|
||||||
|
Self {
|
||||||
|
store,
|
||||||
|
database_slots: Arc::new(Semaphore::new(MAX_CONCURRENT_DATABASE_OPERATIONS)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum DatabaseRequestError {
|
||||||
|
Busy,
|
||||||
|
Failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_database_operation<T, F>(
|
||||||
|
state: &AppState,
|
||||||
|
operation: F,
|
||||||
|
) -> Result<T, DatabaseRequestError>
|
||||||
|
where
|
||||||
|
T: Send + 'static,
|
||||||
|
F: FnOnce(&HighScoreStore) -> Result<T, StoreError> + Send + 'static,
|
||||||
|
{
|
||||||
|
let permit = state
|
||||||
|
.database_slots
|
||||||
|
.clone()
|
||||||
|
.try_acquire_owned()
|
||||||
|
.map_err(|_| DatabaseRequestError::Busy)?;
|
||||||
|
let store = state.store.clone();
|
||||||
|
task::spawn_blocking(move || {
|
||||||
|
let _permit = permit;
|
||||||
|
operation(&store)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|_| DatabaseRequestError::Failed)?
|
||||||
|
.map_err(|_| DatabaseRequestError::Failed)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 database_error_response(error: &DatabaseRequestError) -> Response {
|
||||||
|
match error {
|
||||||
|
DatabaseRequestError::Busy => (
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: "service is busy",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
DatabaseRequestError::Failed => StatusCode::INTERNAL_SERVER_ERROR.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(state): State<AppState>) -> Response {
|
||||||
|
match run_database_operation(&state, HighScoreStore::list).await {
|
||||||
|
Ok(scores) => Json(scores).into_response(),
|
||||||
|
Err(error) => database_error_response(&error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn submit_high_score(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(request): Json<SubmitRequest>,
|
||||||
|
) -> Response {
|
||||||
|
let entry = match validate_request(&request) {
|
||||||
|
Ok(entry) => entry,
|
||||||
|
Err(message) => return invalid_request(message),
|
||||||
|
};
|
||||||
|
match run_database_operation(&state, move |store| store.submit(&entry)).await {
|
||||||
|
Ok(scores) => (StatusCode::CREATED, Json(scores)).into_response(),
|
||||||
|
Err(error) => database_error_response(&error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn router(store: HighScoreStore) -> Router {
|
||||||
|
router_with_state(AppState::new(store))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn router_with_state(state: AppState) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/healthz", get(health))
|
||||||
|
.route(
|
||||||
|
"/api/highscores",
|
||||||
|
get(list_high_scores)
|
||||||
|
.post(submit_high_score)
|
||||||
|
.layer(DefaultBodyLimit::max(MAX_SUBMISSION_BODY_BYTES)),
|
||||||
|
)
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::{sync::mpsc, time::Duration};
|
||||||
|
|
||||||
|
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 request(app: &Router, request: Request<Body>) -> StatusCode {
|
||||||
|
app.clone()
|
||||||
|
.oneshot(request)
|
||||||
|
.await
|
||||||
|
.expect("router should respond")
|
||||||
|
.status()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hold_database_lock(
|
||||||
|
store: HighScoreStore,
|
||||||
|
) -> (mpsc::SyncSender<()>, std::thread::JoinHandle<()>) {
|
||||||
|
let (locked_sender, locked_receiver) = mpsc::sync_channel(0);
|
||||||
|
let (release_sender, release_receiver) = mpsc::sync_channel(0);
|
||||||
|
let lock_thread = std::thread::spawn(move || {
|
||||||
|
let _connection = store.0.lock().expect("database lock should succeed");
|
||||||
|
locked_sender
|
||||||
|
.send(())
|
||||||
|
.expect("test should observe the held database lock");
|
||||||
|
release_receiver
|
||||||
|
.recv()
|
||||||
|
.expect("test should release the database lock");
|
||||||
|
});
|
||||||
|
locked_receiver
|
||||||
|
.recv()
|
||||||
|
.expect("database lock thread should start");
|
||||||
|
(release_sender, lock_thread)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_until_database_is_busy(database_slots: &Semaphore) {
|
||||||
|
tokio::time::timeout(Duration::from_secs(1), async {
|
||||||
|
while database_slots.available_permits() != 0 {
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("first database request should acquire admission");
|
||||||
|
}
|
||||||
|
|
||||||
|
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 api_rejects_oversized_submission_bodies() {
|
||||||
|
let app = router(HighScoreStore::open_in_memory().expect("database should open"));
|
||||||
|
let body = serde_json::to_vec(&serde_json::json!({
|
||||||
|
"name": "Player",
|
||||||
|
"score": 10,
|
||||||
|
"padding": "x".repeat(MAX_SUBMISSION_BODY_BYTES),
|
||||||
|
}))
|
||||||
|
.expect("request JSON should encode");
|
||||||
|
|
||||||
|
let status = request(
|
||||||
|
&app,
|
||||||
|
Request::post("/api/highscores")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(Body::from(body))
|
||||||
|
.expect("request should build"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||||
|
async fn busy_database_load_sheds_api_without_blocking_health() {
|
||||||
|
let store = HighScoreStore::open_in_memory().expect("database should open");
|
||||||
|
let (release_sender, lock_thread) = hold_database_lock(store.clone());
|
||||||
|
let state = AppState::new(store);
|
||||||
|
let database_slots = state.database_slots.clone();
|
||||||
|
let app = router_with_state(state);
|
||||||
|
|
||||||
|
let accepted_app = app.clone();
|
||||||
|
let accepted = tokio::spawn(async move { submit(&accepted_app, "Player", 10).await });
|
||||||
|
wait_until_database_is_busy(&database_slots).await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
tokio::time::timeout(
|
||||||
|
Duration::from_millis(250),
|
||||||
|
request(
|
||||||
|
&app,
|
||||||
|
Request::get("/healthz")
|
||||||
|
.body(Body::empty())
|
||||||
|
.expect("request should build"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("health request should not wait for the database"),
|
||||||
|
StatusCode::OK
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
submit(&app, "Other Player", 20).await,
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
request(
|
||||||
|
&app,
|
||||||
|
Request::get("/api/highscores")
|
||||||
|
.body(Body::empty())
|
||||||
|
.expect("request should build"),
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
request(
|
||||||
|
&app,
|
||||||
|
Request::builder()
|
||||||
|
.method("HEAD")
|
||||||
|
.uri("/api/highscores")
|
||||||
|
.body(Body::empty())
|
||||||
|
.expect("request should build"),
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE
|
||||||
|
);
|
||||||
|
|
||||||
|
release_sender
|
||||||
|
.send(())
|
||||||
|
.expect("database lock should be released");
|
||||||
|
assert_eq!(
|
||||||
|
accepted.await.expect("accepted request should complete"),
|
||||||
|
StatusCode::CREATED
|
||||||
|
);
|
||||||
|
lock_thread
|
||||||
|
.join()
|
||||||
|
.expect("database lock thread should stop");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||||
|
async fn canceled_request_holds_admission_until_blocking_work_stops() {
|
||||||
|
let store = HighScoreStore::open_in_memory().expect("database should open");
|
||||||
|
let (release_sender, lock_thread) = hold_database_lock(store.clone());
|
||||||
|
let state = AppState::new(store);
|
||||||
|
let database_slots = state.database_slots.clone();
|
||||||
|
let app = router_with_state(state);
|
||||||
|
|
||||||
|
let canceled_app = app.clone();
|
||||||
|
let canceled = tokio::spawn(async move { submit(&canceled_app, "Player", 10).await });
|
||||||
|
wait_until_database_is_busy(&database_slots).await;
|
||||||
|
canceled.abort();
|
||||||
|
assert!(
|
||||||
|
canceled
|
||||||
|
.await
|
||||||
|
.expect_err("request should be canceled")
|
||||||
|
.is_cancelled()
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(database_slots.available_permits(), 0);
|
||||||
|
assert_eq!(
|
||||||
|
submit(&app, "Other Player", 20).await,
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE
|
||||||
|
);
|
||||||
|
|
||||||
|
release_sender
|
||||||
|
.send(())
|
||||||
|
.expect("database lock should be released");
|
||||||
|
lock_thread
|
||||||
|
.join()
|
||||||
|
.expect("database lock thread should stop");
|
||||||
|
tokio::time::timeout(Duration::from_secs(1), async {
|
||||||
|
while database_slots.available_permits() == 0 {
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("completed blocking work should release admission");
|
||||||
|
assert_eq!(submit(&app, "Other Player", 20).await, StatusCode::CREATED);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -12,7 +12,26 @@ build-release:
|
|||||||
build-production:
|
build-production:
|
||||||
cargo build --profile 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
|
cargo +nightly fmt
|
||||||
tombi format
|
tombi format
|
||||||
fd -tf -e md -x prettier --write --prose-wrap always --print-width 80
|
fd -tf -e md -x prettier --write --prose-wrap always --print-width 80
|
||||||
@@ -25,10 +44,10 @@ _fix:
|
|||||||
|
|
||||||
fix: _fix fmt
|
fix: _fix fmt
|
||||||
|
|
||||||
clippy:
|
clippy: clippy-highscore-server
|
||||||
cargo clippy --workspace --all-targets --all-features -- -D warnings
|
cargo clippy --workspace --all-targets --all-features -- -D warnings
|
||||||
|
|
||||||
test:
|
test: test-highscore-server
|
||||||
cargo test --workspace --all-targets --all-features
|
cargo test --workspace --all-targets --all-features
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
|
|||||||
+114
-61
@@ -1,24 +1,68 @@
|
|||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use macroquad::prelude::*;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
assets::Assets,
|
assets::Assets,
|
||||||
game::{Controls, Event, Game, Nudge},
|
game::{Controls, Event, Game, Nudge},
|
||||||
persistence::{
|
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 WIDTH: f32 = 640.0;
|
||||||
const HEIGHT: f32 = 460.0;
|
const HEIGHT: f32 = 460.0;
|
||||||
const DETAIL_TIMER_SECONDS: [f32; 5] = [0.050, 0.040, 0.030, 0.020, 0.010];
|
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 ADD_PLAYER_KEYS: [KeyCode; 3] = [KeyCode::KpAdd, KeyCode::RightBracket, KeyCode::Equal];
|
||||||
const TARGET_POSITIONS: [[(f32, f32); 5]; 6] = [
|
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)],
|
(9.0, 41.0),
|
||||||
[(13.0, 50.0), (15.0, 17.0), (46.0, 9.0), (64.0, 37.0), (44.0, 62.0)],
|
(23.0, 11.0),
|
||||||
[(17.0, 55.0), (11.0, 23.0), (39.0, 7.0), (64.0, 30.0), (50.0, 59.0)],
|
(56.0, 14.0),
|
||||||
[(21.0, 60.0), (9.0, 28.0), (35.0, 7.0), (62.0, 25.0), (54.0, 56.0)],
|
(63.0, 47.0),
|
||||||
[(8.0, 35.0), (26.0, 8.0), (59.0, 18.0), (59.0, 50.0), (27.0, 61.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] = [
|
const BUMPER_VALUE_REGIONS: [(i32, i32, i32, i32); 5] = [
|
||||||
(103, 304, 121, 323),
|
(103, 304, 121, 323),
|
||||||
@@ -50,6 +94,13 @@ fn add_player_released() -> bool {
|
|||||||
ADD_PLAYER_KEYS.into_iter().any(is_key_released)
|
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)> {
|
fn visible_score_digits(mut value: u32) -> Vec<(u8, u8)> {
|
||||||
let mut digits = Vec::with_capacity(8);
|
let mut digits = Vec::with_capacity(8);
|
||||||
for position in 0..8 {
|
for position in 0..8 {
|
||||||
@@ -116,11 +167,7 @@ impl AttractAnimation {
|
|||||||
|
|
||||||
fn target_active(self, index: usize) -> bool {
|
fn target_active(self, index: usize) -> bool {
|
||||||
let index = u32::try_from(index).unwrap_or(0);
|
let index = u32::try_from(index).unwrap_or(0);
|
||||||
let first_activation = if index == 0 {
|
let first_activation = if index == 0 { 32 } else { index * 2 };
|
||||||
32
|
|
||||||
} else {
|
|
||||||
index * 2
|
|
||||||
};
|
|
||||||
self.tick >= first_activation && self.tick.wrapping_sub(index * 2) % 32 < 16
|
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 {
|
fn record_strip_active(self, index: usize) -> bool {
|
||||||
let index = u32::try_from(index).unwrap_or(0);
|
let index = u32::try_from(index).unwrap_or(0);
|
||||||
let first_activation = if index == 0 {
|
let first_activation = if index == 0 { 40 } else { index * 4 };
|
||||||
40
|
|
||||||
} else {
|
|
||||||
index * 4
|
|
||||||
};
|
|
||||||
self.tick >= first_activation && self.tick.wrapping_sub(index * 4) % 40 < 20
|
self.tick >= first_activation && self.tick.wrapping_sub(index * 4) % 40 < 20
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,11 +194,7 @@ impl AttractAnimation {
|
|||||||
|
|
||||||
fn item_index(self) -> Option<usize> {
|
fn item_index(self) -> Option<usize> {
|
||||||
let phase = (self.tick % 144) / 8;
|
let phase = (self.tick % 144) / 8;
|
||||||
let item = if phase < 10 {
|
let item = if phase < 10 { phase } else { 18 - phase };
|
||||||
phase
|
|
||||||
} else {
|
|
||||||
18 - phase
|
|
||||||
};
|
|
||||||
(item != 0).then(|| usize::try_from(item - 1).unwrap_or(0))
|
(item != 0).then(|| usize::try_from(item - 1).unwrap_or(0))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,7 +243,7 @@ impl App {
|
|||||||
screen: Screen::Loading,
|
screen: Screen::Loading,
|
||||||
return_screen: Screen::Attract,
|
return_screen: Screen::Attract,
|
||||||
game: None,
|
game: None,
|
||||||
game_random_seed: macroquad::rand::rand(),
|
game_random_seed: initial_game_random_seed(),
|
||||||
setting_row: 0,
|
setting_row: 0,
|
||||||
loading_started,
|
loading_started,
|
||||||
loading_until: loading_started + 1.1,
|
loading_until: loading_started + 1.1,
|
||||||
@@ -216,6 +255,10 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn frame(&mut self) {
|
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();
|
self.handle_global_input();
|
||||||
match self.screen {
|
match self.screen {
|
||||||
Screen::Loading => {
|
Screen::Loading => {
|
||||||
@@ -235,11 +278,13 @@ impl App {
|
|||||||
self.present();
|
self.present();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub fn set_simulation_game(&mut self, game: Game) {
|
pub fn set_simulation_game(&mut self, game: Game) {
|
||||||
self.game = Some(game);
|
self.game = Some(game);
|
||||||
self.screen = Screen::Playing;
|
self.screen = Screen::Playing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub fn set_simulation_attract(&mut self, steps: u64) {
|
pub fn set_simulation_attract(&mut self, steps: u64) {
|
||||||
self.game = None;
|
self.game = None;
|
||||||
self.screen = Screen::Attract;
|
self.screen = Screen::Attract;
|
||||||
@@ -249,6 +294,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub fn set_simulation_loading(&mut self, steps: u64) {
|
pub fn set_simulation_loading(&mut self, steps: u64) {
|
||||||
self.game = None;
|
self.game = None;
|
||||||
self.screen = Screen::Loading;
|
self.screen = Screen::Loading;
|
||||||
@@ -257,12 +303,14 @@ impl App {
|
|||||||
self.loading_until = get_time() + 60.0;
|
self.loading_until = get_time() + 60.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub fn set_simulation_highscores(&mut self) {
|
pub fn set_simulation_highscores(&mut self) {
|
||||||
self.game = None;
|
self.game = None;
|
||||||
self.return_screen = Screen::Attract;
|
self.return_screen = Screen::Attract;
|
||||||
self.screen = Screen::HighScores;
|
self.screen = Screen::HighScores;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub fn set_simulation_name_entry(&mut self) {
|
pub fn set_simulation_name_entry(&mut self) {
|
||||||
self.game = Some(Game::new(1));
|
self.game = Some(Game::new(1));
|
||||||
self.return_screen = Screen::Playing;
|
self.return_screen = Screen::Playing;
|
||||||
@@ -271,10 +319,12 @@ impl App {
|
|||||||
self.screen = Screen::NameEntry;
|
self.screen = Screen::NameEntry;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub fn render_simulation(&self) {
|
pub fn render_simulation(&self) {
|
||||||
self.draw_logical();
|
self.draw_logical();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub fn export_simulation_png(&self, path: &Path) -> Result<(), String> {
|
pub fn export_simulation_png(&self, path: &Path) -> Result<(), String> {
|
||||||
let path = path
|
let path = path
|
||||||
.to_str()
|
.to_str()
|
||||||
@@ -420,6 +470,7 @@ impl App {
|
|||||||
let now = get_time();
|
let now = get_time();
|
||||||
if now - self.last_help_click <= 0.40 {
|
if now - self.last_help_click <= 0.40 {
|
||||||
self.save();
|
self.save();
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
macroquad::miniquad::window::request_quit();
|
macroquad::miniquad::window::request_quit();
|
||||||
}
|
}
|
||||||
self.last_help_click = now;
|
self.last_help_click = now;
|
||||||
@@ -495,7 +546,8 @@ impl App {
|
|||||||
self.name.pop();
|
self.name.pop();
|
||||||
}
|
}
|
||||||
let click = is_mouse_button_pressed(MouseButton::Left).then(Self::logical_mouse);
|
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 =
|
let clicked_cancel =
|
||||||
click.is_some_and(|point| Rect::new(324.0, 256.0, 60.0, 23.0).contains(point));
|
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 {
|
if is_key_pressed(KeyCode::Escape) || clicked_cancel {
|
||||||
@@ -507,13 +559,13 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn accept_high_score_name(&mut self) {
|
fn accept_high_score_name(&mut self) {
|
||||||
insert_high_score(
|
let entry = HighScore {
|
||||||
&mut self.saved.high_scores,
|
name: self.name.clone(),
|
||||||
HighScore {
|
score: self.pending_score,
|
||||||
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();
|
self.save();
|
||||||
if self.game.as_ref().is_some_and(|game| game.finished) {
|
if self.game.as_ref().is_some_and(|game| game.finished) {
|
||||||
self.take_game_and_preserve_random_seed();
|
self.take_game_and_preserve_random_seed();
|
||||||
@@ -574,11 +626,8 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if self.attract.magnetic_records_active() {
|
if self.attract.magnetic_records_active() {
|
||||||
for (x, y, width, height) in [
|
for (x, y, width, height) in [(151, 389, 13, 49), (11, 344, 13, 49), (291, 346, 13, 49)]
|
||||||
(151, 389, 13, 49),
|
{
|
||||||
(11, 344, 13, 49),
|
|
||||||
(291, 346, 13, 49),
|
|
||||||
] {
|
|
||||||
self.draw_active_table_region(x, y, width, height);
|
self.draw_active_table_region(x, y, width, height);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -605,12 +654,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(item) = self.attract.item_index() {
|
if let Some(item) = self.attract.item_index() {
|
||||||
draw_texture(
|
draw_texture(&self.assets.diamond[item], 123.0, 300.0, WHITE);
|
||||||
&self.assets.diamond[item],
|
|
||||||
123.0,
|
|
||||||
300.0,
|
|
||||||
WHITE,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
self.draw_intro_marquee();
|
self.draw_intro_marquee();
|
||||||
}
|
}
|
||||||
@@ -929,7 +973,15 @@ impl App {
|
|||||||
} else if (66..=128).contains(&frame) {
|
} else if (66..=128).contains(&frame) {
|
||||||
draw_texture_region(&self.assets.wheel, 79.0, 33.0, 91.0, 90.0, 180.0, 90.0);
|
draw_texture_region(&self.assets.wheel, 79.0, 33.0, 91.0, 90.0, 180.0, 90.0);
|
||||||
if frame < 83 {
|
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 {
|
} else {
|
||||||
let height = (128 - frame) * 2;
|
let height = (128 - frame) * 2;
|
||||||
draw_texture_region(
|
draw_texture_region(
|
||||||
@@ -1122,14 +1174,7 @@ impl App {
|
|||||||
draw_centered("SETTINGS", 122.0, 30, BLACK);
|
draw_centered("SETTINGS", 122.0, 30, BLACK);
|
||||||
let rows = [
|
let rows = [
|
||||||
format!("GRAPHICS DETAIL / SPEED: {}", self.saved.settings.speed),
|
format!("GRAPHICS DETAIL / SPEED: {}", self.saved.settings.speed),
|
||||||
format!(
|
format!("SOUND: {}", if self.sounds_enabled { "ON" } else { "OFF" }),
|
||||||
"SOUND: {}",
|
|
||||||
if self.sounds_enabled {
|
|
||||||
"ON"
|
|
||||||
} else {
|
|
||||||
"OFF"
|
|
||||||
}
|
|
||||||
),
|
|
||||||
format!("LANGUAGE: {}", self.saved.settings.language.label()),
|
format!("LANGUAGE: {}", self.saved.settings.language.label()),
|
||||||
];
|
];
|
||||||
for (index, row) in rows.iter().enumerate() {
|
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(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, 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_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(
|
draw_text(
|
||||||
"Congratulations! This is a Top Ten Score!",
|
"Congratulations! This is a Top Ten Score!",
|
||||||
x + 9.0,
|
x + 9.0,
|
||||||
@@ -1265,6 +1316,14 @@ impl App {
|
|||||||
eprintln!("could not save settings and highscores: {error}");
|
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) {
|
fn draw_panel(x: f32, y: f32, width: f32, height: f32) {
|
||||||
@@ -1319,13 +1378,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn attract_timer_uses_the_selected_detail_callback() {
|
fn attract_timer_uses_the_selected_detail_callback() {
|
||||||
for (detail, interval) in [
|
for (detail, interval) in [(1, 0.050), (2, 0.040), (3, 0.030), (4, 0.020), (5, 0.010)] {
|
||||||
(1, 0.050),
|
|
||||||
(2, 0.040),
|
|
||||||
(3, 0.030),
|
|
||||||
(4, 0.020),
|
|
||||||
(5, 0.010),
|
|
||||||
] {
|
|
||||||
let mut animation = AttractAnimation::default();
|
let mut animation = AttractAnimation::default();
|
||||||
animation.update(interval - 0.001, detail);
|
animation.update(interval - 0.001, detail);
|
||||||
assert_eq!(animation.tick, 0);
|
assert_eq!(animation.tick, 0);
|
||||||
|
|||||||
+130
-73
@@ -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::{
|
use macroquad::{
|
||||||
audio::{PlaySoundParams, Sound, load_sound_from_bytes, play_sound, stop_sound},
|
audio::{PlaySoundParams, Sound, load_sound_from_bytes, play_sound, stop_sound},
|
||||||
prelude::{FilterMode, Image, Texture2D},
|
prelude::{FilterMode, Image, Texture2D},
|
||||||
@@ -24,6 +28,8 @@ pub struct Assets {
|
|||||||
pub ball: Texture2D,
|
pub ball: Texture2D,
|
||||||
pub digits: Texture2D,
|
pub digits: Texture2D,
|
||||||
sounds: Vec<(u16, Sound)>,
|
sounds: Vec<(u16, Sound)>,
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
sound_loader: Option<Coroutine<Vec<(u16, Sound)>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Assets {
|
impl Assets {
|
||||||
@@ -33,7 +39,8 @@ impl Assets {
|
|||||||
let inactive_table = texture(include_bytes!("../assets/original/images/dat_00998.png"));
|
let inactive_table = texture(include_bytes!("../assets/original/images/dat_00998.png"));
|
||||||
let loading = texture(include_bytes!("../assets/original/images/dat_00995.png"));
|
let loading = texture(include_bytes!("../assets/original/images/dat_00995.png"));
|
||||||
let loading_progress = texture(include_bytes!("../assets/original/images/dat_00994.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 = [
|
let help = [
|
||||||
texture(include_bytes!("../assets/original/images/dat_01001.png")),
|
texture(include_bytes!("../assets/original/images/dat_01001.png")),
|
||||||
texture(include_bytes!("../assets/original/images/dat_01002.png")),
|
texture(include_bytes!("../assets/original/images/dat_01002.png")),
|
||||||
@@ -84,78 +91,18 @@ impl Assets {
|
|||||||
let digits =
|
let digits =
|
||||||
monochrome_texture(include_bytes!("../assets/original/images/bitmap_00500.png"));
|
monochrome_texture(include_bytes!("../assets/original/images/bitmap_00500.png"));
|
||||||
|
|
||||||
let sound_bytes: [(u16, &[u8]); 16] = [
|
let sound_bytes = sound_bytes();
|
||||||
(
|
#[cfg(target_arch = "wasm32")]
|
||||||
2001,
|
let (sounds, sound_loader) = (
|
||||||
include_bytes!("../assets/original/audio/wav_02001.wav"),
|
Vec::new(),
|
||||||
),
|
Some(start_coroutine(
|
||||||
(
|
async move { load_sounds(&sound_bytes).await },
|
||||||
2002,
|
)),
|
||||||
include_bytes!("../assets/original/audio/wav_02002.wav"),
|
);
|
||||||
),
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
(
|
let sounds = load_sounds(&sound_bytes).await;
|
||||||
2004,
|
#[cfg(target_arch = "wasm32")]
|
||||||
include_bytes!("../assets/original/audio/wav_02004.wav"),
|
macroquad::window::next_frame().await;
|
||||||
),
|
|
||||||
(
|
|
||||||
2006,
|
|
||||||
include_bytes!("../assets/original/audio/wav_02006.wav"),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
2007,
|
|
||||||
include_bytes!("../assets/original/audio/wav_02007.wav"),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
2008,
|
|
||||||
include_bytes!("../assets/original/audio/wav_02008.wav"),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
2011,
|
|
||||||
include_bytes!("../assets/original/audio/wav_02011.wav"),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
2012,
|
|
||||||
include_bytes!("../assets/original/audio/wav_02012.wav"),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
2013,
|
|
||||||
include_bytes!("../assets/original/audio/wav_02013.wav"),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
2015,
|
|
||||||
include_bytes!("../assets/original/audio/wav_02015.wav"),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
2016,
|
|
||||||
include_bytes!("../assets/original/audio/wav_02016.wav"),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
2017,
|
|
||||||
include_bytes!("../assets/original/audio/wav_02017.wav"),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
2019,
|
|
||||||
include_bytes!("../assets/original/audio/wav_02019.wav"),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
2020,
|
|
||||||
include_bytes!("../assets/original/audio/wav_02020.wav"),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
2021,
|
|
||||||
include_bytes!("../assets/original/audio/wav_02021.wav"),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
2022,
|
|
||||||
include_bytes!("../assets/original/audio/wav_02022.wav"),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
let mut sounds = Vec::with_capacity(sound_bytes.len());
|
|
||||||
for (id, bytes) in sound_bytes {
|
|
||||||
if let Ok(sound) = load_sound_from_bytes(bytes).await {
|
|
||||||
sounds.push((id, sound));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
active_table,
|
active_table,
|
||||||
@@ -178,9 +125,23 @@ impl Assets {
|
|||||||
ball,
|
ball,
|
||||||
digits,
|
digits,
|
||||||
sounds,
|
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) {
|
pub fn play(&self, id: u16, enabled: bool) {
|
||||||
if !enabled {
|
if !enabled {
|
||||||
return;
|
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 {
|
fn texture(bytes: &[u8]) -> Texture2D {
|
||||||
let texture = Texture2D::from_file_with_format(bytes, None);
|
let texture = Texture2D::from_file_with_format(bytes, None);
|
||||||
texture.set_filter(FilterMode::Nearest);
|
texture.set_filter(FilterMode::Nearest);
|
||||||
|
|||||||
@@ -77,15 +77,16 @@ mod tests {
|
|||||||
fn zero_bounds_still_advance_the_seed() {
|
fn zero_bounds_still_advance_the_seed() {
|
||||||
let mut random = BorlandRandom::new(7);
|
let mut random = BorlandRandom::new(7);
|
||||||
assert_eq!(random.below(0), 0);
|
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]
|
#[test]
|
||||||
fn unit_interval_is_the_exact_unsigned_seed_fraction() {
|
fn unit_interval_is_the_exact_unsigned_seed_fraction() {
|
||||||
let mut random = BorlandRandom::new(0xfedc_ba98);
|
let mut random = BorlandRandom::new(0xfedc_ba98);
|
||||||
let expected_seed = 0xfedc_ba98_u32
|
let expected_seed = 0xfedc_ba98_u32.wrapping_mul(MULTIPLIER).wrapping_add(1);
|
||||||
.wrapping_mul(MULTIPLIER)
|
|
||||||
.wrapping_add(1);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
random.unit_interval().to_bits(),
|
random.unit_interval().to_bits(),
|
||||||
(f64::from(expected_seed) / TWO_TO_32).to_bits()
|
(f64::from(expected_seed) / TWO_TO_32).to_bits()
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ use crate::{original_physics::MilliVec, real48::Real48};
|
|||||||
const SEARCH_RADIUS: i32 = 54_000;
|
const SEARCH_RADIUS: i32 = 54_000;
|
||||||
const ONE: Real48 = Real48::from_bytes([0x81, 0, 0, 0, 0, 0]);
|
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: Real48 = Real48::from_bytes([0x82, 0, 0, 0, 0, 0]);
|
||||||
const TWO_FIFTHS: Real48 =
|
const TWO_FIFTHS: Real48 = Real48::from_bytes([0x7f, 0xcd, 0xcc, 0xcc, 0xcc, 0x4c]);
|
||||||
Real48::from_bytes([0x7f, 0xcd, 0xcc, 0xcc, 0xcc, 0x4c]);
|
|
||||||
const THOUSAND: Real48 = Real48::from_bytes([0x8a, 0, 0, 0, 0, 0x7a]);
|
const THOUSAND: Real48 = Real48::from_bytes([0x8a, 0, 0, 0, 0, 0x7a]);
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
@@ -171,14 +170,11 @@ fn penetration(geometry: Geometry, delta: i32, ball: MilliVec) -> i32 {
|
|||||||
.wrapping_add(43_000)
|
.wrapping_add(43_000)
|
||||||
.wrapping_sub(geometry.pivot.y),
|
.wrapping_sub(geometry.pivot.y),
|
||||||
);
|
);
|
||||||
geometry
|
geometry.pivot.y.wrapping_sub(
|
||||||
.pivot
|
Real48::from_i32(numerator)
|
||||||
.y
|
.divide(Real48::from_i32(edge_dx))
|
||||||
.wrapping_sub(
|
.round_i32(),
|
||||||
Real48::from_i32(numerator)
|
)
|
||||||
.divide(Real48::from_i32(edge_dx))
|
|
||||||
.round_i32(),
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
geometry.positive_edge.y.wrapping_add(43_000)
|
geometry.positive_edge.y.wrapping_add(43_000)
|
||||||
}
|
}
|
||||||
@@ -299,8 +295,14 @@ mod tests {
|
|||||||
let velocities = [
|
let velocities = [
|
||||||
MilliVec { x: 0, y: 2_000 },
|
MilliVec { x: 0, y: 2_000 },
|
||||||
MilliVec { x: 1_000, y: 2_000 },
|
MilliVec { x: 1_000, y: 2_000 },
|
||||||
MilliVec { x: -1_000, y: 2_000 },
|
MilliVec {
|
||||||
MilliVec { x: 2_700, y: -2_700 },
|
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 hash = 14_695_981_039_346_656_037_u64;
|
||||||
let mut hits = 0_u32;
|
let mut hits = 0_u32;
|
||||||
@@ -315,10 +317,10 @@ mod tests {
|
|||||||
Real48::from_bytes([0x80, 0, 0, 0, 0, 0]),
|
Real48::from_bytes([0x80, 0, 0, 0, 0, 0]),
|
||||||
3_800,
|
3_800,
|
||||||
);
|
);
|
||||||
let (hit, velocity, movement) = response.map_or(
|
let (hit, velocity, movement) = response
|
||||||
(0_u32, input_velocity, MilliVec::default()),
|
.map_or((0_u32, input_velocity, MilliVec::default()), |response| {
|
||||||
|response| (1, response.velocity, response.movement),
|
(1, response.velocity, response.movement)
|
||||||
);
|
});
|
||||||
hits += hit;
|
hits += hit;
|
||||||
for value in [
|
for value in [
|
||||||
i32_bits(x),
|
i32_bits(x),
|
||||||
@@ -342,40 +344,73 @@ mod tests {
|
|||||||
fn four_direction_vectors_match_the_reconstructed_c_harness() {
|
fn four_direction_vectors_match_the_reconstructed_c_harness() {
|
||||||
let cases = [
|
let cases = [
|
||||||
(
|
(
|
||||||
MilliVec { x: 104_000, y: 384_000 },
|
MilliVec {
|
||||||
|
x: 104_000,
|
||||||
|
y: 384_000,
|
||||||
|
},
|
||||||
MilliVec { x: 1_000, y: 2_000 },
|
MilliVec { x: 1_000, y: 2_000 },
|
||||||
-1,
|
-1,
|
||||||
FlipperSide::Left,
|
FlipperSide::Left,
|
||||||
Real48::from_bytes([0x80, 0, 0, 0, 0, 0]),
|
Real48::from_bytes([0x80, 0, 0, 0, 0, 0]),
|
||||||
MilliVec { x: 1_266, y: 1_511 },
|
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 {
|
||||||
MilliVec { x: -1_000, y: 2_000 },
|
x: 209_000,
|
||||||
|
y: 419_000,
|
||||||
|
},
|
||||||
|
MilliVec {
|
||||||
|
x: -1_000,
|
||||||
|
y: 2_000,
|
||||||
|
},
|
||||||
1,
|
1,
|
||||||
FlipperSide::Right,
|
FlipperSide::Right,
|
||||||
Real48::from_bytes([0x80, 0, 0, 0, 0, 0x40]),
|
Real48::from_bytes([0x80, 0, 0, 0, 0, 0x40]),
|
||||||
MilliVec { x: -737, y: 2_263 },
|
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 },
|
MilliVec { x: 1_000, y: 2_000 },
|
||||||
1,
|
1,
|
||||||
FlipperSide::Left,
|
FlipperSide::Left,
|
||||||
Real48::from_bytes([0x7f, 0, 0, 0, 0, 0]),
|
Real48::from_bytes([0x7f, 0, 0, 0, 0, 0]),
|
||||||
MilliVec { x: 622, y: 2_320 },
|
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 {
|
||||||
MilliVec { x: -1_000, y: 2_000 },
|
x: 209_000,
|
||||||
|
y: 385_000,
|
||||||
|
},
|
||||||
|
MilliVec {
|
||||||
|
x: -1_000,
|
||||||
|
y: 2_000,
|
||||||
|
},
|
||||||
-1,
|
-1,
|
||||||
FlipperSide::Right,
|
FlipperSide::Right,
|
||||||
Real48::ZERO,
|
Real48::ZERO,
|
||||||
MilliVec { x: -1_280, y: 1_560 },
|
MilliVec {
|
||||||
MilliVec { x: 7_385, y: -9_000 },
|
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 {
|
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() {
|
fn boundary_vectors_match_the_live_original_binary() {
|
||||||
for (ball, velocity, delta, side, expected_velocity, expected_movement) in [
|
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 },
|
MilliVec { x: 1_000, y: 2_000 },
|
||||||
-1,
|
-1,
|
||||||
FlipperSide::Left,
|
FlipperSide::Left,
|
||||||
@@ -406,7 +444,10 @@ mod tests {
|
|||||||
MilliVec::default(),
|
MilliVec::default(),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
MilliVec { x: 209_000, y: 419_000 },
|
MilliVec {
|
||||||
|
x: 209_000,
|
||||||
|
y: 419_000,
|
||||||
|
},
|
||||||
MilliVec { x: 1_000, y: 2_000 },
|
MilliVec { x: 1_000, y: 2_000 },
|
||||||
1,
|
1,
|
||||||
FlipperSide::Right,
|
FlipperSide::Right,
|
||||||
|
|||||||
+268
-177
@@ -1,20 +1,34 @@
|
|||||||
|
use macroquad::prelude::{Rect, Vec2, vec2};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
borland_random::BorlandRandom,
|
borland_random::BorlandRandom,
|
||||||
flipper_physics::{FlipperSide, moving_flipper_response},
|
flipper_physics::{FlipperSide, moving_flipper_response},
|
||||||
geometry::Segment,
|
geometry::Segment,
|
||||||
original_physics::{
|
original_physics::{
|
||||||
CollisionMaterial, CollisionResponse, GRAVITY_MILLI_PER_STEP,
|
CollisionMaterial,
|
||||||
MAXIMUM_SPEED_MILLI_PER_STEP, MilliVec, StaticCollisionCandidate,
|
CollisionResponse,
|
||||||
ball_collision_response, capture_collision_candidate, circle_collision_candidate_at,
|
GRAVITY_MILLI_PER_STEP,
|
||||||
line_collision_candidate_at, milli_distance, path_intersects_circle,
|
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,
|
real48::Real48,
|
||||||
table::{
|
table::{
|
||||||
BUMPERS, EFFECT_SENSOR, LOCK_HOLES, PASSIVE_CIRCLES, TARGET_SENSORS, WALLS,
|
BUMPERS,
|
||||||
|
EFFECT_SENSOR,
|
||||||
|
LOCK_HOLES,
|
||||||
|
PASSIVE_CIRCLES,
|
||||||
SPECIAL_HOLE_SENSOR,
|
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 LEFT_FLIPPER_RAISED_TIP: Vec2 = Vec2::new(133.0, 377.0);
|
||||||
const RIGHT_FLIPPER_RAISED_TIP: Vec2 = Vec2::new(181.0, 376.0);
|
const RIGHT_FLIPPER_RAISED_TIP: Vec2 = Vec2::new(181.0, 376.0);
|
||||||
@@ -368,6 +382,7 @@ pub struct Game {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Game {
|
impl Game {
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub fn new(player_count: usize) -> Self {
|
pub fn new(player_count: usize) -> Self {
|
||||||
Self::new_with_seed(player_count, macroquad::rand::rand())
|
Self::new_with_seed(player_count, macroquad::rand::rand())
|
||||||
}
|
}
|
||||||
@@ -446,6 +461,7 @@ impl Game {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub(crate) fn begin_claw_scenario(&mut self, terminal_frame: u8) -> Vec<Event> {
|
pub(crate) fn begin_claw_scenario(&mut self, terminal_frame: u8) -> Vec<Event> {
|
||||||
self.ball.in_launcher = false;
|
self.ball.in_launcher = false;
|
||||||
self.ball.position = CLAW_TRIGGER_CENTER;
|
self.ball.position = CLAW_TRIGGER_CENTER;
|
||||||
@@ -455,6 +471,7 @@ impl Game {
|
|||||||
events
|
events
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub(crate) fn begin_effect_scenario(&mut self, effect: u8) {
|
pub(crate) fn begin_effect_scenario(&mut self, effect: u8) {
|
||||||
self.target_effect = effect.min(7);
|
self.target_effect = effect.min(7);
|
||||||
self.object_active[usize::from(EFFECT_SENSOR.id)] = self.target_effect != 0;
|
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) {
|
pub(crate) fn begin_target_scenario(&mut self) {
|
||||||
self.ball.in_launcher = false;
|
self.ball.in_launcher = false;
|
||||||
self.ball.position = vec2(170.0, 230.0);
|
self.ball.position = vec2(170.0, 230.0);
|
||||||
@@ -483,7 +501,8 @@ impl Game {
|
|||||||
let variation = self.random.below(3_800) / 40;
|
let variation = self.random.below(3_800) / 40;
|
||||||
launch_velocity = -MAXIMUM_SPEED_MILLI_PER_STEP + i32::from(variation);
|
launch_velocity = -MAXIMUM_SPEED_MILLI_PER_STEP + i32::from(variation);
|
||||||
} else if launch_velocity > -2_280 {
|
} 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.in_launcher = false;
|
||||||
self.ball.velocity = MilliVec {
|
self.ball.velocity = MilliVec {
|
||||||
@@ -516,14 +535,15 @@ impl Game {
|
|||||||
} else if self.tilted {
|
} else if self.tilted {
|
||||||
self.flipper_release_latch[1] = true;
|
self.flipper_release_latch[1] = true;
|
||||||
}
|
}
|
||||||
self.flipper_inputs.left_raised = controls.left_flipper
|
self.flipper_inputs.left_raised =
|
||||||
&& !self.tilted
|
controls.left_flipper && !self.tilted && !self.flipper_release_latch[0];
|
||||||
&& !self.flipper_release_latch[0];
|
self.flipper_inputs.right_raised =
|
||||||
self.flipper_inputs.right_raised = controls.right_flipper
|
controls.right_flipper && !self.tilted && !self.flipper_release_latch[1];
|
||||||
&& !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 controls.launch_down {
|
||||||
if self.launcher_was_down {
|
if self.launcher_was_down {
|
||||||
self.launcher_hold_seconds += frame_time.min(0.05);
|
self.launcher_hold_seconds += frame_time.min(0.05);
|
||||||
@@ -546,7 +566,9 @@ impl Game {
|
|||||||
} else if self.launcher_was_down {
|
} else if self.launcher_was_down {
|
||||||
self.fire_launcher();
|
self.fire_launcher();
|
||||||
events.push(Event::Launch);
|
events.push(Event::Launch);
|
||||||
events.push(Event::Sound(2002));
|
if !self.tilted {
|
||||||
|
events.push(Event::Sound(2002));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !self.tilted && controls.nudge != Nudge::None {
|
if !self.tilted && controls.nudge != Nudge::None {
|
||||||
@@ -576,10 +598,7 @@ impl Game {
|
|||||||
self.score_mode = ScoreMode::Multiball;
|
self.score_mode = ScoreMode::Multiball;
|
||||||
}
|
}
|
||||||
for _ in 0..substeps {
|
for _ in 0..substeps {
|
||||||
if self.fixed_update(events)
|
if self.fixed_update(events) || self.finished || self.claw.ball_suspended {
|
||||||
|| self.finished
|
|
||||||
|| self.claw.ball_suspended
|
|
||||||
{
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -609,22 +628,18 @@ impl Game {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.pending_flipper_edges[0] = match (
|
self.pending_flipper_edges[0] =
|
||||||
self.flipper_inputs.left_raised,
|
match (self.flipper_inputs.left_raised, self.flippers.left_raised) {
|
||||||
self.flippers.left_raised,
|
(true, false) => -1,
|
||||||
) {
|
(false, true) => 1,
|
||||||
(true, false) => -1,
|
_ => 0,
|
||||||
(false, true) => 1,
|
};
|
||||||
_ => 0,
|
self.pending_flipper_edges[1] =
|
||||||
};
|
match (self.flipper_inputs.right_raised, self.flippers.right_raised) {
|
||||||
self.pending_flipper_edges[1] = match (
|
(true, false) => -1,
|
||||||
self.flipper_inputs.right_raised,
|
(false, true) => 1,
|
||||||
self.flippers.right_raised,
|
_ => 0,
|
||||||
) {
|
};
|
||||||
(true, false) => -1,
|
|
||||||
(false, true) => 1,
|
|
||||||
_ => 0,
|
|
||||||
};
|
|
||||||
self.flippers = self.flipper_inputs;
|
self.flippers = self.flipper_inputs;
|
||||||
for edge in self.pending_flipper_edges {
|
for edge in self.pending_flipper_edges {
|
||||||
if edge != 0 {
|
if edge != 0 {
|
||||||
@@ -706,7 +721,11 @@ impl Game {
|
|||||||
match nudge {
|
match nudge {
|
||||||
Nudge::Left | Nudge::Right => {
|
Nudge::Left | Nudge::Right => {
|
||||||
let amount = (50 - i32::from(self.random.below(20))) * SCALAR;
|
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);
|
velocity.x = velocity.x.wrapping_add(signed);
|
||||||
}
|
}
|
||||||
Nudge::Center => {
|
Nudge::Center => {
|
||||||
@@ -726,12 +745,10 @@ impl Game {
|
|||||||
}
|
}
|
||||||
if let Some(secondary) = &mut self.secondary_ball {
|
if let Some(secondary) = &mut self.secondary_ball {
|
||||||
let slot_impulse = match nudge {
|
let slot_impulse = match nudge {
|
||||||
Nudge::Left | Nudge::Right => {
|
Nudge::Left | Nudge::Right => MilliVec {
|
||||||
MilliVec {
|
x: if nudge == Nudge::Left { -600 } else { 600 },
|
||||||
x: if nudge == Nudge::Left { -600 } else { 600 },
|
y: 0,
|
||||||
y: 0,
|
},
|
||||||
}
|
|
||||||
}
|
|
||||||
Nudge::Center => {
|
Nudge::Center => {
|
||||||
let horizontal = (i32::from(self.random.below(21)) - 10) * SCALAR;
|
let horizontal = (i32::from(self.random.below(21)) - 10) * SCALAR;
|
||||||
let vertical = -(i32::from(self.random.below(100)) + 50) * SCALAR;
|
let vertical = -(i32::from(self.random.below(100)) + 50) * SCALAR;
|
||||||
@@ -1070,13 +1087,7 @@ impl Game {
|
|||||||
best = Some((id, false, candidate));
|
best = Some((id, false, candidate));
|
||||||
}
|
}
|
||||||
ball.velocity = velocity.to_velocity_per_second();
|
ball.velocity = velocity.to_velocity_per_second();
|
||||||
(
|
(best, SensorScanResult { action }, predicted)
|
||||||
best,
|
|
||||||
SensorScanResult {
|
|
||||||
action,
|
|
||||||
},
|
|
||||||
predicted,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn find_static_collision_candidate_in_range(
|
fn find_static_collision_candidate_in_range(
|
||||||
@@ -1317,12 +1328,7 @@ impl Game {
|
|||||||
collided || scan.action == BallAction::Suspend
|
collided || scan.action == BallAction::Suspend
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_bumper_rule(
|
fn apply_bumper_rule(&mut self, object_id: u8, auxiliary_fired: bool, events: &mut Vec<Event>) {
|
||||||
&mut self,
|
|
||||||
object_id: u8,
|
|
||||||
auxiliary_fired: bool,
|
|
||||||
events: &mut Vec<Event>,
|
|
||||||
) {
|
|
||||||
if self.tilted || !BUMPERS.iter().any(|bumper| bumper.id == object_id) {
|
if self.tilted || !BUMPERS.iter().any(|bumper| bumper.id == object_id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1504,11 +1510,7 @@ impl Game {
|
|||||||
if !self.tilted
|
if !self.tilted
|
||||||
&& (TARGET_SENSORS.into_iter().any(|sensor| {
|
&& (TARGET_SENSORS.into_iter().any(|sensor| {
|
||||||
self.object_active[usize::from(sensor.id)]
|
self.object_active[usize::from(sensor.id)]
|
||||||
&& trigger_broadphase_contains(
|
&& trigger_broadphase_contains(predicted_position, sensor.center, sensor.radius)
|
||||||
predicted_position,
|
|
||||||
sensor.center,
|
|
||||||
sensor.radius,
|
|
||||||
)
|
|
||||||
}) || self.object_active[usize::from(EFFECT_SENSOR.id)]
|
}) || self.object_active[usize::from(EFFECT_SENSOR.id)]
|
||||||
&& trigger_broadphase_contains(
|
&& trigger_broadphase_contains(
|
||||||
predicted_position,
|
predicted_position,
|
||||||
@@ -1549,56 +1551,32 @@ impl Game {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(completed_action) =
|
if let Some(completed_action) = self.check_lock_holes(
|
||||||
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(
|
|
||||||
ball,
|
ball,
|
||||||
ball_number,
|
ball_number,
|
||||||
|
ball_count,
|
||||||
old_position,
|
old_position,
|
||||||
movement_velocity,
|
predicted_position,
|
||||||
|
&mut capture_candidate,
|
||||||
events,
|
events,
|
||||||
);
|
) {
|
||||||
self.check_target_sensors(
|
action = completed_action;
|
||||||
ball,
|
|
||||||
old_position,
|
|
||||||
movement_velocity,
|
|
||||||
150..=152,
|
|
||||||
events,
|
|
||||||
);
|
|
||||||
SensorScanResult {
|
|
||||||
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)]
|
#[allow(clippy::cast_possible_truncation, clippy::too_many_arguments)]
|
||||||
@@ -1832,7 +1810,14 @@ impl Game {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let effect_index = usize::from(EFFECT_SENSOR.id);
|
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(
|
let touched = path_intersects_circle(
|
||||||
old_position,
|
old_position,
|
||||||
movement_velocity,
|
movement_velocity,
|
||||||
@@ -1858,7 +1843,8 @@ impl Game {
|
|||||||
self.players[self.current_player].secondary_score = 0;
|
self.players[self.current_player].secondary_score = 0;
|
||||||
}
|
}
|
||||||
7 if self.record_contacts[usize::from(SPECIAL_HOLE_SENSOR.id)] != 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()
|
&& self.secondary_ball.is_none()
|
||||||
&& matches!(
|
&& matches!(
|
||||||
self.multiball_state,
|
self.multiball_state,
|
||||||
@@ -1898,17 +1884,27 @@ impl Game {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let mut triggered = false;
|
let mut triggered = false;
|
||||||
|
let predicted_position = old_position.add(movement_velocity);
|
||||||
for sensor in TARGET_SENSORS {
|
for sensor in TARGET_SENSORS {
|
||||||
if !record_ids.contains(&sensor.id) {
|
if !record_ids.contains(&sensor.id) {
|
||||||
continue;
|
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(
|
let touched = path_intersects_circle(
|
||||||
old_position,
|
old_position,
|
||||||
movement_velocity,
|
movement_velocity,
|
||||||
sensor.center,
|
sensor.center,
|
||||||
sensor.radius,
|
sensor.radius,
|
||||||
);
|
);
|
||||||
let contact_index = usize::from(sensor.id);
|
|
||||||
let entered = touched && !self.trigger_flags[contact_index];
|
let entered = touched && !self.trigger_flags[contact_index];
|
||||||
self.trigger_flags[contact_index] = touched;
|
self.trigger_flags[contact_index] = touched;
|
||||||
if !entered {
|
if !entered {
|
||||||
@@ -1999,10 +1995,7 @@ impl Game {
|
|||||||
.round_i32()
|
.round_i32()
|
||||||
.wrapping_neg();
|
.wrapping_neg();
|
||||||
let predicted = old_position.add(*velocity);
|
let predicted = old_position.add(*velocity);
|
||||||
if predicted.x < min_x
|
if predicted.x < min_x || predicted.x > max_x || predicted.y < min_y || predicted.y > max_y
|
||||||
|| predicted.x > max_x
|
|
||||||
|| predicted.y < min_y
|
|
||||||
|| predicted.y > max_y
|
|
||||||
{
|
{
|
||||||
self.object_active[object_id] = false;
|
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>) {
|
fn begin_claw_capture(&mut self, terminal_frame: u8, events: &mut Vec<Event>) {
|
||||||
debug_assert!(CLAW_TERMINAL_FRAMES.contains(&terminal_frame));
|
debug_assert!(CLAW_TERMINAL_FRAMES.contains(&terminal_frame));
|
||||||
if self.claw.active {
|
if self.claw.active {
|
||||||
@@ -2179,6 +2173,7 @@ impl Game {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.ball_double = BallDoubleState::Inactive;
|
self.ball_double = BallDoubleState::Inactive;
|
||||||
|
self.multiball_state = MultiballState::Unavailable;
|
||||||
self.special_hole_gate = SpecialHoleGate::Enabled;
|
self.special_hole_gate = SpecialHoleGate::Enabled;
|
||||||
let player = &mut self.players[self.current_player];
|
let player = &mut self.players[self.current_player];
|
||||||
if player.extra_balls > 0 {
|
if player.extra_balls > 0 {
|
||||||
@@ -2225,7 +2220,11 @@ impl Game {
|
|||||||
fn special_respawn_pending(&self) -> bool {
|
fn special_respawn_pending(&self) -> bool {
|
||||||
self.secondary_ball.is_none()
|
self.secondary_ball.is_none()
|
||||||
&& self.score_mode == ScoreMode::Normal
|
&& 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) {
|
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) {
|
fn apply_flipper_response_to_ball(ball: &mut Ball, delta: i32, side: FlipperSide) {
|
||||||
let position = MilliVec::from_position(ball.position);
|
let position = MilliVec::from_position(ball.position);
|
||||||
let velocity = MilliVec::from_velocity_per_second(ball.velocity);
|
let velocity = MilliVec::from_velocity_per_second(ball.velocity);
|
||||||
if let Some(response) =
|
if let Some(response) = moving_flipper_response(
|
||||||
moving_flipper_response(
|
position,
|
||||||
position,
|
velocity,
|
||||||
velocity,
|
delta,
|
||||||
delta,
|
side,
|
||||||
side,
|
Real48::from_bytes([0x80, 0, 0, 0, 0, 0]),
|
||||||
Real48::from_bytes([0x80, 0, 0, 0, 0, 0]),
|
MAXIMUM_SPEED_MILLI_PER_STEP,
|
||||||
MAXIMUM_SPEED_MILLI_PER_STEP,
|
) {
|
||||||
)
|
|
||||||
{
|
|
||||||
ball.position = position.add(response.movement).to_position();
|
ball.position = position.add(response.movement).to_position();
|
||||||
ball.velocity = response.velocity.to_velocity_per_second();
|
ball.velocity = response.velocity.to_velocity_per_second();
|
||||||
}
|
}
|
||||||
@@ -2379,11 +2376,7 @@ mod tests {
|
|||||||
game.ball.in_launcher = false;
|
game.ball.in_launcher = false;
|
||||||
game.ball.position = center;
|
game.ball.position = center;
|
||||||
game.ball.velocity = Vec2::ZERO;
|
game.ball.velocity = Vec2::ZERO;
|
||||||
game.check_sensor_objects(
|
game.check_sensor_objects(MilliVec::from_position(center), MilliVec::default(), events);
|
||||||
MilliVec::from_position(center),
|
|
||||||
MilliVec::default(),
|
|
||||||
events,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2397,7 +2390,8 @@ mod tests {
|
|||||||
) -> CaptureStep {
|
) -> CaptureStep {
|
||||||
let mut ball = game.ball;
|
let mut ball = game.ball;
|
||||||
let mut capture_candidate = None;
|
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(
|
let result = game.capture_record_step(
|
||||||
&mut ball,
|
&mut ball,
|
||||||
1,
|
1,
|
||||||
@@ -2456,8 +2450,8 @@ mod tests {
|
|||||||
game.object_active[usize::from(wall.id)] = true;
|
game.object_active[usize::from(wall.id)] = true;
|
||||||
game.ball.in_launcher = false;
|
game.ball.in_launcher = false;
|
||||||
game.ball.position = midpoint - normal;
|
game.ball.position = midpoint - normal;
|
||||||
game.ball.velocity = MilliVec::from_velocity_per_second(normal * 200.0)
|
game.ball.velocity =
|
||||||
.to_velocity_per_second();
|
MilliVec::from_velocity_per_second(normal * 200.0).to_velocity_per_second();
|
||||||
game.fixed_update(&mut Vec::new());
|
game.fixed_update(&mut Vec::new());
|
||||||
if game.last_collision_id != Some(wall.id) {
|
if game.last_collision_id != Some(wall.id) {
|
||||||
missed_walls.push(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!(
|
assert!(
|
||||||
missed_circles.is_empty(),
|
missed_circles.is_empty(),
|
||||||
"missed production circle records: {missed_circles:?}"
|
"missed production circle records: {missed_circles:?}"
|
||||||
@@ -2656,6 +2653,17 @@ mod tests {
|
|||||||
assert_eq!(game.launcher_frame(), 10);
|
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]
|
#[test]
|
||||||
fn charged_ball_clears_the_shooter_lane() {
|
fn charged_ball_clears_the_shooter_lane() {
|
||||||
for detail in 1..=5 {
|
for detail in 1..=5 {
|
||||||
@@ -2802,6 +2810,7 @@ mod tests {
|
|||||||
let mut special = Game::new(1);
|
let mut special = Game::new(1);
|
||||||
special.ball_double = BallDoubleState::Active;
|
special.ball_double = BallDoubleState::Active;
|
||||||
special.multiball_state = MultiballState::Ready;
|
special.multiball_state = MultiballState::Ready;
|
||||||
|
special.record_contacts[148] = 2;
|
||||||
special.drain(&mut Vec::new());
|
special.drain(&mut Vec::new());
|
||||||
assert_eq!(special.ball_double, BallDoubleState::Active);
|
assert_eq!(special.ball_double, BallDoubleState::Active);
|
||||||
assert_eq!(special.ball.position, vec2(17.0, 23.0));
|
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.multiball_state, MultiballState::Unavailable);
|
||||||
assert_eq!(special.record_contacts[148], 0);
|
assert_eq!(special.record_contacts[148], 0);
|
||||||
assert!(!special_events.contains(&Event::Drain));
|
assert!(!special_events.contains(&Event::Drain));
|
||||||
assert!(!special_events
|
assert!(
|
||||||
.iter()
|
!special_events
|
||||||
.any(|event| event.sound_resource().is_some()));
|
.iter()
|
||||||
|
.any(|event| event.sound_resource().is_some())
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -3179,10 +3190,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
MilliVec::from_velocity_per_second(game.ball.velocity),
|
MilliVec::from_velocity_per_second(game.ball.velocity),
|
||||||
MilliVec {
|
MilliVec { x: 0, y: 3_055 }
|
||||||
x: 0,
|
|
||||||
y: 3_055,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
assert_eq!(game.special_hole_gate, SpecialHoleGate::Suppressed);
|
assert_eq!(game.special_hole_gate, SpecialHoleGate::Suppressed);
|
||||||
|
|
||||||
@@ -3219,6 +3227,10 @@ mod tests {
|
|||||||
assert_eq!(game.record_contacts[148], 0);
|
assert_eq!(game.record_contacts[148], 0);
|
||||||
game.drain(&mut events);
|
game.drain(&mut events);
|
||||||
assert!(game.secondary_ball.is_none());
|
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();
|
events.clear();
|
||||||
game.apply_wall_rule(84, &mut events);
|
game.apply_wall_rule(84, &mut events);
|
||||||
@@ -3240,6 +3252,7 @@ mod tests {
|
|||||||
fn record_two_response_continues_after_special_respawn_like_live_original() {
|
fn record_two_response_continues_after_special_respawn_like_live_original() {
|
||||||
let mut game = Game::new(1);
|
let mut game = Game::new(1);
|
||||||
game.multiball_state = MultiballState::Active;
|
game.multiball_state = MultiballState::Active;
|
||||||
|
game.record_contacts[148] = 2;
|
||||||
game.ball.in_launcher = false;
|
game.ball.in_launcher = false;
|
||||||
game.ball.position = vec2(157.0, 453.0);
|
game.ball.position = vec2(157.0, 453.0);
|
||||||
game.ball.velocity = MilliVec { x: 0, y: 3_000 }.to_velocity_per_second();
|
game.ball.velocity = MilliVec { x: 0, y: 3_000 }.to_velocity_per_second();
|
||||||
@@ -3255,10 +3268,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
MilliVec::from_velocity_per_second(game.ball.velocity),
|
MilliVec::from_velocity_per_second(game.ball.velocity),
|
||||||
MilliVec {
|
MilliVec { x: -302, y: -1_809 }
|
||||||
x: -302,
|
|
||||||
y: -1_809,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
assert_eq!(game.multiball_state, MultiballState::Unavailable);
|
assert_eq!(game.multiball_state, MultiballState::Unavailable);
|
||||||
assert_eq!(game.special_hole_gate, SpecialHoleGate::Suppressed);
|
assert_eq!(game.special_hole_gate, SpecialHoleGate::Suppressed);
|
||||||
@@ -3297,9 +3307,7 @@ mod tests {
|
|||||||
spin: Real48::ZERO,
|
spin: Real48::ZERO,
|
||||||
capture_age: 0,
|
capture_age: 0,
|
||||||
};
|
};
|
||||||
let old_position = MilliVec::from_position(
|
let old_position = MilliVec::from_position(sensor.center - vec2(sensor.radius + 1.0, 0.0));
|
||||||
sensor.center - vec2(sensor.radius + 1.0, 0.0),
|
|
||||||
);
|
|
||||||
let current_position = MilliVec::from_position(sensor.center);
|
let current_position = MilliVec::from_position(sensor.center);
|
||||||
let movement = MilliVec {
|
let movement = MilliVec {
|
||||||
x: current_position.x.wrapping_sub(old_position.x),
|
x: current_position.x.wrapping_sub(old_position.x),
|
||||||
@@ -3391,7 +3399,10 @@ mod tests {
|
|||||||
BallAction::Keep
|
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.multiball_state, MultiballState::Active);
|
||||||
assert_eq!(game.target_effect, 0);
|
assert_eq!(game.target_effect, 0);
|
||||||
}
|
}
|
||||||
@@ -3511,7 +3522,6 @@ mod tests {
|
|||||||
assert!(game.secondary_ball.is_none());
|
assert!(game.secondary_ball.is_none());
|
||||||
assert_eq!(game.player().balls, 3);
|
assert_eq!(game.player().balls, 3);
|
||||||
assert_eq!(game.score_mode, ScoreMode::Normal);
|
assert_eq!(game.score_mode, ScoreMode::Normal);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -3605,10 +3615,7 @@ mod tests {
|
|||||||
|
|
||||||
assert!(game.fixed_update(&mut events));
|
assert!(game.fixed_update(&mut events));
|
||||||
assert_eq!(game.last_collision_id, Some(57));
|
assert_eq!(game.last_collision_id, Some(57));
|
||||||
assert_eq!(
|
assert_eq!(game.ball.spin.bytes(), [0x83, 0x5d, 0x8f, 0xc2, 0xf5, 0xa8]);
|
||||||
game.ball.spin.bytes(),
|
|
||||||
[0x83, 0x5d, 0x8f, 0xc2, 0xf5, 0xa8]
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
MilliVec::from_position(game.ball.position),
|
MilliVec::from_position(game.ball.position),
|
||||||
MilliVec {
|
MilliVec {
|
||||||
@@ -3645,10 +3652,7 @@ mod tests {
|
|||||||
|
|
||||||
assert!(game.fixed_update(&mut events));
|
assert!(game.fixed_update(&mut events));
|
||||||
assert_eq!(game.last_collision_id, Some(72));
|
assert_eq!(game.last_collision_id, Some(72));
|
||||||
assert_eq!(
|
assert_eq!(game.ball.spin.bytes(), [0x83, 0x86, 0xeb, 0x51, 0xb8, 0x2e]);
|
||||||
game.ball.spin.bytes(),
|
|
||||||
[0x83, 0x86, 0xeb, 0x51, 0xb8, 0x2e]
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
MilliVec::from_position(game.ball.position),
|
MilliVec::from_position(game.ball.position),
|
||||||
MilliVec {
|
MilliVec {
|
||||||
@@ -3658,7 +3662,10 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
MilliVec::from_velocity_per_second(game.ball.velocity),
|
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]
|
#[test]
|
||||||
fn raised_flipper_tips_use_their_asymmetric_swept_bounds() {
|
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);
|
let mut game = Game::new(1);
|
||||||
game.object_active.fill(false);
|
game.object_active.fill(false);
|
||||||
game.object_active[usize::from(object_id)] = true;
|
game.object_active[usize::from(object_id)] = true;
|
||||||
@@ -4012,7 +4018,13 @@ mod tests {
|
|||||||
let velocity_after_edge = game.ball.velocity;
|
let velocity_after_edge = game.ball.velocity;
|
||||||
assert_eq!(game.ball.position, vec2(138.124, 355.0));
|
assert_eq!(game.ball.position, vec2(138.124, 355.0));
|
||||||
let raw_velocity = MilliVec::from_velocity_per_second(velocity_after_edge);
|
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]);
|
assert_eq!(game.pending_flipper_edges, [0, 0]);
|
||||||
|
|
||||||
game.apply_flipper_kicks();
|
game.apply_flipper_kicks();
|
||||||
@@ -4102,20 +4114,20 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tilt_counter_decays_once_per_selected_detail_callback() {
|
fn tilt_counter_decays_once_per_selected_detail_callback() {
|
||||||
for (detail, interval) in [
|
for (detail, interval) in [(1, 0.050), (2, 0.040), (3, 0.030), (4, 0.020), (5, 0.010)] {
|
||||||
(1, 0.050),
|
|
||||||
(2, 0.040),
|
|
||||||
(3, 0.030),
|
|
||||||
(4, 0.020),
|
|
||||||
(5, 0.010),
|
|
||||||
] {
|
|
||||||
let mut game = Game::new(1);
|
let mut game = Game::new(1);
|
||||||
game.tilt_counter = 2;
|
game.tilt_counter = 2;
|
||||||
|
|
||||||
game.update(interval - 0.001, detail, Controls::default());
|
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());
|
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");
|
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(
|
game.check_sensor_objects(
|
||||||
MilliVec::from_position(game.ball.position),
|
MilliVec::from_position(game.ball.position),
|
||||||
MilliVec::default(),
|
MilliVec::default(),
|
||||||
@@ -4355,6 +4367,47 @@ mod tests {
|
|||||||
assert_eq!(game.player().score, 1_000);
|
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]
|
#[test]
|
||||||
fn magnetic_rectangles_use_old_position_for_entry_and_prediction_for_exit() {
|
fn magnetic_rectangles_use_old_position_for_entry_and_prediction_for_exit() {
|
||||||
let mut game = Game::new_with_seed(1, 7);
|
let mut game = Game::new_with_seed(1, 7);
|
||||||
@@ -4462,11 +4515,7 @@ mod tests {
|
|||||||
5
|
5
|
||||||
);
|
);
|
||||||
|
|
||||||
complete_stationary_type_three_capture(
|
complete_stationary_type_three_capture(&mut game, SPECIAL_HOLE_SENSOR.center, &mut events);
|
||||||
&mut game,
|
|
||||||
SPECIAL_HOLE_SENSOR.center,
|
|
||||||
&mut events,
|
|
||||||
);
|
|
||||||
assert!(game.wheel_holes.iter().all(|filled| *filled));
|
assert!(game.wheel_holes.iter().all(|filled| *filled));
|
||||||
assert_eq!(game.multiball_state, MultiballState::Ready);
|
assert_eq!(game.multiball_state, MultiballState::Ready);
|
||||||
assert_eq!(game.record_contacts[148], 2);
|
assert_eq!(game.record_contacts[148], 2);
|
||||||
@@ -4696,7 +4745,11 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(game.panel_frame, None);
|
assert_eq!(game.panel_frame, None);
|
||||||
assert_eq!(game.wheel_holes, [false; 5]);
|
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!(
|
assert_eq!(
|
||||||
events,
|
events,
|
||||||
[
|
[
|
||||||
@@ -4758,7 +4811,11 @@ mod tests {
|
|||||||
game.record_countdowns[51] = 5;
|
game.record_countdowns[51] = 5;
|
||||||
|
|
||||||
game.timer_tick(0.030, 0, &mut Vec::new());
|
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;
|
game.ball.in_launcher = false;
|
||||||
for expected in (0..5).rev() {
|
for expected in (0..5).rev() {
|
||||||
@@ -4876,4 +4933,38 @@ mod tests {
|
|||||||
assert!(game.tilted);
|
assert!(game.tilted);
|
||||||
assert!(game.ball.in_launcher);
|
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
@@ -7,11 +7,13 @@ mod geometry;
|
|||||||
mod original_physics;
|
mod original_physics;
|
||||||
mod persistence;
|
mod persistence;
|
||||||
mod real48;
|
mod real48;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
mod simulation;
|
mod simulation;
|
||||||
mod table;
|
mod table;
|
||||||
|
|
||||||
use app::App;
|
use app::App;
|
||||||
use macroquad::prelude::*;
|
use macroquad::prelude::*;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use simulation::{Request, Scenario, Simulation, usage};
|
use simulation::{Request, Scenario, Simulation, usage};
|
||||||
|
|
||||||
const WINDOW_WIDTH: i32 = 640;
|
const WINDOW_WIDTH: i32 = 640;
|
||||||
@@ -30,18 +32,21 @@ fn window_conf() -> Conf {
|
|||||||
|
|
||||||
#[macroquad::main(window_conf)]
|
#[macroquad::main(window_conf)]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
let request = match Request::parse(std::env::args()) {
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
Ok(request) => request,
|
{
|
||||||
Err(error) => {
|
let request = match Request::parse(std::env::args()) {
|
||||||
eprintln!("{error}");
|
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;
|
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;
|
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> {
|
async fn run_simulation(request: Request) -> Result<(), String> {
|
||||||
let mut simulation = Simulation::new(request.scenario, request.seed);
|
let mut simulation = Simulation::new(request.scenario, request.seed);
|
||||||
simulation.advance_to(request.target_step);
|
simulation.advance_to(request.target_step);
|
||||||
|
|||||||
@@ -73,11 +73,7 @@ impl CollisionMaterial {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const fn circle(
|
pub const fn circle(normal_rebound: f64, tangent_coupling: f64, normal_kick: f64) -> Self {
|
||||||
normal_rebound: f64,
|
|
||||||
tangent_coupling: f64,
|
|
||||||
normal_kick: f64,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
Self {
|
||||||
normal_rebound,
|
normal_rebound,
|
||||||
tangent_coupling,
|
tangent_coupling,
|
||||||
@@ -173,8 +169,7 @@ impl MilliVec {
|
|||||||
// `maximum / speed` is mathematically equivalent, but can differ by
|
// `maximum / speed` is mathematically equivalent, but can differ by
|
||||||
// one millipixel because each Real48 operation is rounded
|
// one millipixel because each Real48 operation is rounded
|
||||||
// independently.
|
// independently.
|
||||||
let excess = Real48::from_i32(speed.wrapping_sub(maximum))
|
let excess = Real48::from_i32(speed.wrapping_sub(maximum)).divide(Real48::from_i32(speed));
|
||||||
.divide(Real48::from_i32(speed));
|
|
||||||
self.x = self
|
self.x = self
|
||||||
.x
|
.x
|
||||||
.wrapping_sub(Real48::from_i32(self.x).multiply(excess).round_i32());
|
.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()
|
.round_i32()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cross_at_endpoint(
|
fn cross_at_endpoint(point: MilliVec, current: MilliVec, predicted: MilliVec) -> i32 {
|
||||||
point: MilliVec,
|
|
||||||
current: MilliVec,
|
|
||||||
predicted: MilliVec,
|
|
||||||
) -> i32 {
|
|
||||||
predicted
|
predicted
|
||||||
.x
|
.x
|
||||||
.wrapping_sub(point.x)
|
.wrapping_sub(point.x)
|
||||||
@@ -383,9 +374,7 @@ pub fn line_collision_candidate_at(
|
|||||||
let vertical_units = normal_y.divide(THOUSAND);
|
let vertical_units = normal_y.divide(THOUSAND);
|
||||||
let distance = Real48::from_i32(old_position.x.wrapping_sub(start.x))
|
let distance = Real48::from_i32(old_position.x.wrapping_sub(start.x))
|
||||||
.multiply(vertical_units)
|
.multiply(vertical_units)
|
||||||
.subtract(
|
.subtract(Real48::from_i32(old_position.y.wrapping_sub(start.y)).multiply(horizontal_units))
|
||||||
Real48::from_i32(old_position.y.wrapping_sub(start.y)).multiply(horizontal_units),
|
|
||||||
)
|
|
||||||
.divide(length_units);
|
.divide(length_units);
|
||||||
let distance = if distance.compare(ZERO).is_lt() {
|
let distance = if distance.compare(ZERO).is_lt() {
|
||||||
ZERO.subtract(distance)
|
ZERO.subtract(distance)
|
||||||
@@ -525,10 +514,7 @@ pub fn capture_collision_candidate(
|
|||||||
let radius_milli = (radius * 1_000.0).round() as i32;
|
let radius_milli = (radius * 1_000.0).round() as i32;
|
||||||
let delta = MilliVec {
|
let delta = MilliVec {
|
||||||
x: center.x.wrapping_sub(old_position.x),
|
x: center.x.wrapping_sub(old_position.x),
|
||||||
y: center
|
y: center.y.wrapping_sub(old_position.y).wrapping_sub(2_000),
|
||||||
.y
|
|
||||||
.wrapping_sub(old_position.y)
|
|
||||||
.wrapping_sub(2_000),
|
|
||||||
};
|
};
|
||||||
let surface_distance = milli_distance(delta).wrapping_sub(radius_milli);
|
let surface_distance = milli_distance(delta).wrapping_sub(radius_milli);
|
||||||
let normal_x = Real48::from_i32(center.y.wrapping_sub(old_position.y));
|
let normal_x = Real48::from_i32(center.y.wrapping_sub(old_position.y));
|
||||||
@@ -541,9 +527,7 @@ pub fn capture_collision_candidate(
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let normal_velocity = normal_velocity(velocity, normal_x, normal_y, length);
|
let normal_velocity = normal_velocity(velocity, normal_x, normal_y, length);
|
||||||
if normal_velocity >= 0
|
if normal_velocity >= 0 || normal_velocity.wrapping_abs() < surface_distance.wrapping_abs() {
|
||||||
|| normal_velocity.wrapping_abs() < surface_distance.wrapping_abs()
|
|
||||||
{
|
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
Some(StaticCollisionCandidate {
|
Some(StaticCollisionCandidate {
|
||||||
@@ -681,8 +665,14 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn float_views_roundtrip_every_gameplay_velocity_millipixel() {
|
fn float_views_roundtrip_every_gameplay_velocity_millipixel() {
|
||||||
for value in -10_000..=10_000 {
|
for value in -10_000..=10_000 {
|
||||||
let milli = MilliVec { x: value, y: -value };
|
let milli = MilliVec {
|
||||||
assert_eq!(MilliVec::from_velocity_per_second(milli.to_velocity_per_second()), milli);
|
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 {
|
if speed <= maximum {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let excess = Real48::from_i32(speed - maximum)
|
let excess = Real48::from_i32(speed - maximum).divide(Real48::from_i32(speed));
|
||||||
.divide(Real48::from_i32(speed));
|
|
||||||
let expected = MilliVec {
|
let expected = MilliVec {
|
||||||
x: x.wrapping_sub(Real48::from_i32(x).multiply(excess).round_i32()),
|
x: x.wrapping_sub(Real48::from_i32(x).multiply(excess).round_i32()),
|
||||||
y: y.wrapping_sub(Real48::from_i32(y).multiply(excess).round_i32()),
|
y: y.wrapping_sub(Real48::from_i32(y).multiply(excess).round_i32()),
|
||||||
@@ -716,7 +705,10 @@ mod tests {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
assert!(mismatches.is_empty(), "speed-clamp mismatches: {mismatches:?}");
|
assert!(
|
||||||
|
mismatches.is_empty(),
|
||||||
|
"speed-clamp mismatches: {mismatches:?}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -783,7 +775,9 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(candidate.surface_distance, -800);
|
assert_eq!(candidate.surface_distance, -800);
|
||||||
assert_ne!(
|
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 }
|
MilliVec { x: 1_000, y: 0 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -887,16 +881,15 @@ mod tests {
|
|||||||
fn surface_distance_orders_candidate_contacts() {
|
fn surface_distance_orders_candidate_contacts() {
|
||||||
let old = MilliVec::default();
|
let old = MilliVec::default();
|
||||||
let velocity = MilliVec { x: 10_000, y: 0 };
|
let velocity = MilliVec { x: 10_000, y: 0 };
|
||||||
let near =
|
let near = line_collision_response(
|
||||||
line_collision_response(
|
old,
|
||||||
old,
|
velocity,
|
||||||
velocity,
|
vec2(2.0, 1.0),
|
||||||
vec2(2.0, 1.0),
|
vec2(2.0, -1.0),
|
||||||
vec2(2.0, -1.0),
|
CollisionMaterial::line(0.6, 0.1),
|
||||||
CollisionMaterial::line(0.6, 0.1),
|
Real48::ZERO,
|
||||||
Real48::ZERO,
|
)
|
||||||
)
|
.expect("near rail should be crossed");
|
||||||
.expect("near rail should be crossed");
|
|
||||||
let far = line_collision_response(
|
let far = line_collision_response(
|
||||||
old,
|
old,
|
||||||
velocity,
|
velocity,
|
||||||
|
|||||||
@@ -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 directories::ProjectDirs;
|
||||||
use serde::{Deserialize, Serialize};
|
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_HIGHSCORES: &[u8] = include_bytes!("../assets/original/HISCORES.DAT");
|
||||||
const ORIGINAL_INI: &str = include_str!("../../original/TDKPIN.INI");
|
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 {
|
pub struct Persistence {
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub struct Persistence;
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
impl Persistence {
|
impl Persistence {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
let path = ProjectDirs::from("com", "kiwi-hamburg", "TDK Pinball Machine").map_or_else(
|
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)
|
fs::read_to_string(&self.path)
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|text| serde_json::from_str(&text).ok())
|
.and_then(|text| serde_json::from_str(&text).ok())
|
||||||
.unwrap_or_else(|| SavedData {
|
.unwrap_or_else(native_default_saved_data)
|
||||||
settings: load_adjacent_original_settings()
|
|
||||||
.unwrap_or_else(|| parse_original_settings(ORIGINAL_INI)),
|
|
||||||
high_scores: parse_original_high_scores(ORIGINAL_HIGHSCORES),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn save(&self, data: &SavedData) -> io::Result<()> {
|
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> {
|
fn load_adjacent_original_settings() -> Option<Settings> {
|
||||||
let executable = std::env::current_exe().ok()?;
|
let executable = std::env::current_exe().ok()?;
|
||||||
let exact_name = executable.with_extension("INI");
|
let exact_name = executable.with_extension("INI");
|
||||||
@@ -154,7 +202,10 @@ pub fn parse_original_settings(text: &str) -> Settings {
|
|||||||
_ => Language::English,
|
_ => Language::English,
|
||||||
};
|
};
|
||||||
} else if key.trim().eq_ignore_ascii_case("Speed") {
|
} 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") {
|
} else if key.trim().eq_ignore_ascii_case("Sound") {
|
||||||
settings.sounds = value != 0;
|
settings.sounds = value != 0;
|
||||||
}
|
}
|
||||||
@@ -249,14 +300,16 @@ mod tests {
|
|||||||
let settings = parse_original_settings(ORIGINAL_INI);
|
let settings = parse_original_settings(ORIGINAL_INI);
|
||||||
assert_eq!(settings.speed, 3);
|
assert_eq!(settings.speed, 3);
|
||||||
assert_eq!(settings.language, Language::German);
|
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]
|
#[test]
|
||||||
fn original_ini_values_are_case_insensitive_and_clamped() {
|
fn original_ini_values_are_case_insensitive_and_clamped() {
|
||||||
let settings = parse_original_settings(
|
let settings =
|
||||||
"[settings]\nlanguage=5\nSPEED=9\nSound=0\nSounds=1\n",
|
parse_original_settings("[settings]\nlanguage=5\nSPEED=9\nSound=0\nSounds=1\n");
|
||||||
);
|
|
||||||
assert_eq!(settings.speed, 3);
|
assert_eq!(settings.speed, 3);
|
||||||
assert_eq!(settings.language, Language::Spanish);
|
assert_eq!(settings.language, Language::Spanish);
|
||||||
assert!(!settings.sounds);
|
assert!(!settings.sounds);
|
||||||
|
|||||||
+7
-13
@@ -167,8 +167,7 @@ impl Real48 {
|
|||||||
}
|
}
|
||||||
let mut numerator = self.significand();
|
let mut numerator = self.significand();
|
||||||
let denominator = right.significand();
|
let denominator = right.significand();
|
||||||
let mut exponent =
|
let mut exponent = i16::from(self.exponent()) - i16::from(right.exponent()) + EXPONENT_BIAS;
|
||||||
i16::from(self.exponent()) - i16::from(right.exponent()) + EXPONENT_BIAS;
|
|
||||||
if numerator < denominator {
|
if numerator < denominator {
|
||||||
numerator <<= 1;
|
numerator <<= 1;
|
||||||
exponent -= 1;
|
exponent -= 1;
|
||||||
@@ -254,16 +253,8 @@ impl Real48 {
|
|||||||
assert!(highest_bit < 32, "Real48 integer overflow");
|
assert!(highest_bit < 32, "Real48 integer overflow");
|
||||||
let significand = self.significand();
|
let significand = self.significand();
|
||||||
let shift = i16::try_from(FRACTION_BITS).unwrap_or(39) - highest_bit;
|
let shift = i16::try_from(FRACTION_BITS).unwrap_or(39) - highest_bit;
|
||||||
let mut magnitude = if shift >= 64 {
|
let mut magnitude = if shift >= 64 { 0 } else { significand >> shift };
|
||||||
0
|
if round && shift > 0 && shift <= 40 && (significand >> (shift - 1)) & 1 != 0 {
|
||||||
} else {
|
|
||||||
significand >> shift
|
|
||||||
};
|
|
||||||
if round
|
|
||||||
&& shift > 0
|
|
||||||
&& shift <= 40
|
|
||||||
&& (significand >> (shift - 1)) & 1 != 0
|
|
||||||
{
|
|
||||||
magnitude += 1;
|
magnitude += 1;
|
||||||
}
|
}
|
||||||
let limit = if self.negative() {
|
let limit = if self.negative() {
|
||||||
@@ -344,7 +335,10 @@ mod tests {
|
|||||||
assert_eq!(HALF.round_i32(), 1);
|
assert_eq!(HALF.round_i32(), 1);
|
||||||
assert_eq!(Real48::from_bytes([0x80, 0, 0, 0, 0, 0x80]).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!(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!(TWO.sqrt().bytes(), [0x81, 0xfa, 0x33, 0xf3, 0x04, 0x35]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
ONE_AND_HALF.sqrt().bytes(),
|
ONE_AND_HALF.sqrt().bytes(),
|
||||||
|
|||||||
+33
-10
@@ -1,11 +1,13 @@
|
|||||||
use crate::game::{ClawSpriteBank, Controls, Event, Game};
|
|
||||||
use serde::Serialize;
|
|
||||||
use std::{
|
use std::{
|
||||||
fs,
|
fs,
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
str::FromStr,
|
str::FromStr,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::game::{ClawSpriteBank, Controls, Event, Game};
|
||||||
|
|
||||||
pub const SIMULATION_HZ: u32 = 120;
|
pub const SIMULATION_HZ: u32 = 120;
|
||||||
const SIMULATION_HZ_F64: f64 = 120.0;
|
const SIMULATION_HZ_F64: f64 = 120.0;
|
||||||
const SIMULATION_DT: f32 = 1.0 / 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::Claw1
|
||||||
| Scenario::Claw6
|
| Scenario::Claw6
|
||||||
| Scenario::Claw7
|
| Scenario::Claw7
|
||||||
| Scenario::Claw18 => {
|
| Scenario::Claw18 => Controls::default(),
|
||||||
Controls::default()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -507,9 +507,24 @@ mod tests {
|
|||||||
.iter()
|
.iter()
|
||||||
.flat_map(|snapshot| snapshot.events.iter().map(String::as_str))
|
.flat_map(|snapshot| snapshot.events.iter().map(String::as_str))
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
assert_eq!(events.iter().filter(|event| **event == "StopSound").count(), 3);
|
assert_eq!(
|
||||||
assert_eq!(events.iter().filter(|event| **event == "Sound(2013)").count(), 4);
|
events.iter().filter(|event| **event == "StopSound").count(),
|
||||||
assert_eq!(events.iter().filter(|event| **event == "Sound(2012)").count(), 2);
|
3
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
events
|
||||||
|
.iter()
|
||||||
|
.filter(|event| **event == "Sound(2013)")
|
||||||
|
.count(),
|
||||||
|
4
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
events
|
||||||
|
.iter()
|
||||||
|
.filter(|event| **event == "Sound(2012)")
|
||||||
|
.count(),
|
||||||
|
2
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -521,7 +536,10 @@ mod tests {
|
|||||||
|
|
||||||
simulation.advance_to(22);
|
simulation.advance_to(22);
|
||||||
assert_eq!(simulation.game.target_rotation_state, Some(6));
|
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);
|
simulation.advance_to(26);
|
||||||
assert_eq!(simulation.game.target_rotation_state, None);
|
assert_eq!(simulation.game.target_rotation_state, None);
|
||||||
}
|
}
|
||||||
@@ -561,7 +579,12 @@ mod tests {
|
|||||||
.filter(|event| **event == "ClawRelease")
|
.filter(|event| **event == "ClawRelease")
|
||||||
.count();
|
.count();
|
||||||
assert!(captures >= 1, "{} must enter the claw", scenario.name());
|
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!(captures >= releases);
|
||||||
assert!(simulation.game.ball.position.is_finite());
|
assert!(simulation.game.ball.position.is_finite());
|
||||||
assert!(simulation.game.ball.velocity.is_finite());
|
assert!(simulation.game.ball.velocity.is_finite());
|
||||||
|
|||||||
@@ -9,9 +9,10 @@
|
|||||||
//! each object's bounds by `param_26`. They must not be enlarged again by the
|
//! each object's bounds by `param_26`. They must not be enlarged again by the
|
||||||
//! radius of the rendered ball.
|
//! radius of the rendered ball.
|
||||||
|
|
||||||
use crate::geometry::Segment;
|
|
||||||
use macroquad::prelude::Vec2;
|
use macroquad::prelude::Vec2;
|
||||||
|
|
||||||
|
use crate::geometry::Segment;
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug)]
|
#[derive(Clone, Copy, Debug)]
|
||||||
pub struct TableSegment {
|
pub struct TableSegment {
|
||||||
pub id: u8,
|
pub id: u8,
|
||||||
@@ -425,7 +426,10 @@ mod tests {
|
|||||||
.iter()
|
.iter()
|
||||||
.find(|wall| wall.id == id)
|
.find(|wall| wall.id == id)
|
||||||
.expect("relative line record must be present");
|
.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 [
|
for (id, expected_center) in [
|
||||||
(54, Vec2::new(53.0, 292.0)),
|
(54, Vec2::new(53.0, 292.0)),
|
||||||
|
|||||||
@@ -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/).
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
load("tdkpin-rs.wasm");
|
||||||
@@ -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
@@ -0,0 +1,191 @@
|
|||||||
|
"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;
|
||||||
|
let highScoreRetryDelayMs = highScoreRetryInitialDelayMs;
|
||||||
|
let highScoreRetryAt = 0;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function retryAfterDelay(response) {
|
||||||
|
if (response.status !== 429 && response.status !== 503) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const value = response.headers.get("Retry-After")?.trim();
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const seconds = Number(value);
|
||||||
|
if (Number.isFinite(seconds) && seconds >= 0) {
|
||||||
|
const delay = seconds * 1000;
|
||||||
|
return Number.isFinite(delay) ? delay : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = Date.parse(value);
|
||||||
|
return Number.isFinite(timestamp)
|
||||||
|
? Math.max(0, timestamp - Date.now())
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleHighScoreRetry(retryAfterMs) {
|
||||||
|
const jitteredDelay = highScoreRetryDelayMs * (0.5 + Math.random());
|
||||||
|
const delay =
|
||||||
|
retryAfterMs === null ? jitteredDelay : retryAfterMs + jitteredDelay;
|
||||||
|
highScoreRetryAt = Date.now() + delay;
|
||||||
|
highScoreRetryDelayMs = Math.min(
|
||||||
|
highScoreRetryDelayMs * 2,
|
||||||
|
highScoreRetryMaxDelayMs,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetHighScoreRetry() {
|
||||||
|
highScoreRetryDelayMs = highScoreRetryInitialDelayMs;
|
||||||
|
highScoreRetryAt = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushHighScoreSubmission() {
|
||||||
|
if (highScoreRequestInFlight) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Date.now() < highScoreRetryAt) {
|
||||||
|
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;
|
||||||
|
let retryAfterMs = null;
|
||||||
|
try {
|
||||||
|
const response = await fetch(highScoreApi, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: decoder.decode(bytes),
|
||||||
|
keepalive: true,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
retryAfterMs = retryAfterDelay(response);
|
||||||
|
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;
|
||||||
|
resetHighScoreRetry();
|
||||||
|
} catch (error) {
|
||||||
|
scheduleHighScoreRetry(retryAfterMs);
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
})();
|
||||||
Executable
BIN
Binary file not shown.
@@ -0,0 +1,8 @@
|
|||||||
|
[package]
|
||||||
|
name = "tdkpin-web-storage"
|
||||||
|
version = "1.0.0"
|
||||||
|
edition = "2024"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
path = "src/lib.rs"
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user