Compare commits
244 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6c28f7ede | ||
|
|
cd5c15aeb2 | ||
|
|
ccaad3abc8 | ||
|
|
22f00f59f5 | ||
|
|
a85815fdf9 | ||
|
|
09004dc2c0 | ||
|
|
22c8a57a16 | ||
|
|
2d8bb4ab88 | ||
|
|
8e345cfb4c | ||
|
|
200b26c2cd | ||
|
|
66e38af457 | ||
|
|
c80477d248 | ||
|
|
9a688c1574 | ||
|
|
fd2b6338a8 | ||
|
|
2cfde18414 | ||
|
|
0ab4cc2880 | ||
|
|
6b7c455315 | ||
|
|
c38767f4d3 | ||
|
|
321d9aea10 | ||
|
|
92e1e62132 | ||
|
|
84d980bb24 | ||
|
|
892b377eb3 | ||
|
|
bc21095fa1 | ||
|
|
eb3b100056 | ||
|
|
276f6f0744 | ||
|
|
6d734f1d51 | ||
|
|
03b9b1228b | ||
|
|
10978e890b | ||
|
|
6d86281c63 | ||
|
|
0b1bff7eab | ||
|
|
f70ded30b9 | ||
|
|
c18012cb50 | ||
|
|
25a3356547 | ||
|
|
6463a3b2f6 | ||
|
|
7f6c486e9c | ||
|
|
3f30810271 | ||
|
|
4968e2c846 | ||
|
|
39b1e7de16 | ||
|
|
2a0e469e83 | ||
|
|
b3541e6b8a | ||
|
|
4b04e8673d | ||
|
|
75a84dc148 | ||
|
|
514d07ecb5 | ||
|
|
e37ee87ea3 | ||
|
|
38ef16a8f9 | ||
|
|
2f0304eb81 | ||
|
|
1da9ed1ce6 | ||
|
|
c5103d049f | ||
|
|
c4066d6879 | ||
|
|
1432afd6e6 | ||
|
|
1fa0df85f7 | ||
|
|
8baeba6987 | ||
|
|
e028e342ad | ||
|
|
263237a152 | ||
|
|
4ec1b952f2 | ||
|
|
aa9d389540 | ||
|
|
d1423420e6 | ||
|
|
47640f3486 | ||
|
|
cb7595c95b | ||
|
|
8b4eceebfa | ||
|
|
850fd33943 | ||
|
|
8835e1c57a | ||
|
|
b635ed1c2d | ||
|
|
4e6cecfe27 | ||
|
|
d52ba6373a | ||
|
|
002c2888ac | ||
|
|
5d85e898d6 | ||
|
|
eed890dc81 | ||
|
|
57fabffe60 | ||
|
|
89fb670f93 | ||
|
|
8e51390018 | ||
|
|
e7e54619ae | ||
|
|
9c51cc94ee | ||
|
|
df674426c5 | ||
|
|
24d120004d | ||
|
|
88f57ce6df | ||
|
|
3a5dc0f1c8 | ||
|
|
3fff9ef140 | ||
|
|
ca1c6d8602 | ||
|
|
e6bcf20807 | ||
|
|
1ee25b3dd2 | ||
|
|
468bdebe3a | ||
|
|
2eb3ff3406 | ||
|
|
efbe99a9e2 | ||
|
|
fdc882cb04 | ||
|
|
a7778c648d | ||
|
|
7e2f580877 | ||
|
|
f18a89974a | ||
|
|
bf1ced43f8 | ||
|
|
6435027147 | ||
|
|
4d906ec20e | ||
|
|
ff7b711fe0 | ||
|
|
d42f9b2d9a | ||
|
|
faaefbacff | ||
|
|
8d650c5c52 | ||
|
|
79ea52af80 | ||
|
|
6b017b0fe9 | ||
|
|
8f8b1e80db | ||
|
|
c883d9e4c1 | ||
|
|
19b03bc77c | ||
|
|
be68af0d56 | ||
|
|
2f09cb5539 | ||
|
|
aa6cea07df | ||
|
|
ebe123ed7e | ||
|
|
9afd3a7e92 | ||
|
|
efb9c37380 | ||
|
|
c00cfa3de0 | ||
|
|
da53e084f0 | ||
|
|
22df1b0b66 | ||
|
|
c0680cad0f | ||
|
|
0fef1dc9db | ||
|
|
c92ff3971c | ||
|
|
50afc3111d | ||
|
|
1417722438 | ||
|
|
1973c3c5ca | ||
|
|
0f8e411b92 | ||
|
|
4b04e43ff8 | ||
|
|
bb682ed79e | ||
|
|
4ab093a9d8 | ||
|
|
aafba77d62 | ||
|
|
feecda78d0 | ||
|
|
1eb6ceca19 | ||
|
|
e72839e0f3 | ||
|
|
bb59c61638 | ||
|
|
593aed81cc | ||
|
|
0f55909533 | ||
|
|
c92d7d9d89 | ||
|
|
34a377d91b | ||
|
|
3dcdfb4986 | ||
|
|
0209975af6 | ||
|
|
c9b7e92f39 | ||
|
|
c56a47cb60 | ||
|
|
bdb84f5d90 | ||
|
|
33838b7fa7 | ||
|
|
33e9ad2f79 | ||
|
|
db62519f9b | ||
|
|
ec978de0b3 | ||
|
|
d9a7694031 | ||
|
|
42dcbff857 | ||
|
|
5923d341a0 | ||
|
|
cd4796024e | ||
|
|
cff948708f | ||
|
|
ea10c16811 | ||
|
|
474d31576f | ||
|
|
73ff32c243 | ||
|
|
0a50c733b0 | ||
|
|
1386378ca1 | ||
|
|
30f48ab897 | ||
|
|
d2f6b8b46c | ||
|
|
247377fca3 | ||
|
|
be39401716 | ||
|
|
d2a3b7ae2e | ||
|
|
39ab605279 | ||
|
|
cf9d893f3f | ||
|
|
e6d0bd4953 | ||
|
|
1b57f6ddec | ||
|
|
d38ea312a7 | ||
|
|
06aca986ac | ||
|
|
a126466037 | ||
|
|
9a53d7e5db | ||
|
|
d2d8f7740f | ||
|
|
29af265958 | ||
|
|
291bcc581d | ||
|
|
26edec1bbf | ||
|
|
da4fdc90e0 | ||
|
|
ee6c4f2f4f | ||
|
|
9b9f0cdbcb | ||
|
|
e14ffe44d6 | ||
|
|
d5bc0bb27c | ||
|
|
0790074ffc | ||
|
|
1a44daf2ce | ||
|
|
9679d68154 | ||
|
|
80ba94203d | ||
|
|
87631af678 | ||
|
|
2683f5d9c9 | ||
|
|
c92076f232 | ||
|
|
99751b39b2 | ||
|
|
2c0372cdc0 | ||
|
|
0eae43a8ce | ||
|
|
76d207e2e5 | ||
|
|
b945b8ed71 | ||
|
|
d249ec0e5f | ||
|
|
0dab5da0c7 | ||
|
|
c93a1b3074 | ||
|
|
7f6fea91f6 | ||
|
|
aaa253bde0 | ||
|
|
df37260e26 | ||
|
|
b77ff78cfc | ||
|
|
9fb9786e54 | ||
|
|
72bb2eb58b | ||
|
|
55010d2bcd | ||
|
|
f735e5d3ba | ||
|
|
6e436db5e7 | ||
|
|
2953ef8917 | ||
|
|
7675e59868 | ||
|
|
357aa30618 | ||
|
|
22426f677f | ||
|
|
cf997b9cbc | ||
|
|
07d5607218 | ||
|
|
614a081ca6 | ||
|
|
71cdc342c9 | ||
|
|
214b9077ab | ||
|
|
76eb0517c2 | ||
|
|
820000f93b | ||
|
|
fa6b3b69d5 | ||
|
|
ca4ba6e2d7 | ||
|
|
ebfff1a62f | ||
|
|
ba04d7f491 | ||
|
|
054f0c0d23 | ||
|
|
45ff01e1fe | ||
|
|
7801dbb25f | ||
|
|
10eb4df09c | ||
|
|
09e21fa493 | ||
|
|
0541c115c5 | ||
|
|
325e07de59 | ||
|
|
03262dbf40 | ||
|
|
d8fdfeef74 | ||
|
|
005d945ca8 | ||
|
|
a6c20aab3b | ||
|
|
627ca68cff | ||
|
|
84d42e22ac | ||
|
|
37866ebb6d | ||
|
|
7030794fa1 | ||
|
|
ec1c8ed263 | ||
|
|
12f140d888 | ||
|
|
53bbae84af | ||
|
|
511636400c | ||
|
|
79db410b09 | ||
|
|
fedb32486a | ||
|
|
183494a22e | ||
|
|
325daeb0d9 | ||
|
|
7ed1b94b8f | ||
|
|
43f1f92900 | ||
|
|
5f146b0daf | ||
|
|
734da610b7 | ||
|
|
ea19ff918b | ||
|
|
ea1e548e61 | ||
|
|
d43829ad48 | ||
|
|
dbcedb7144 | ||
|
|
46a80cbcc8 | ||
|
|
5d89ad7ada | ||
|
|
30541304dd | ||
|
|
376c8eb901 | ||
|
|
66992e8770 |
10
.github/workflows/deploy.yml
vendored
10
.github/workflows/deploy.yml
vendored
@@ -57,6 +57,10 @@ jobs:
|
||||
type=ref,event=pr
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Get short commit SHA
|
||||
id: vars
|
||||
run: echo "short_sha=$(echo ${{ github.sha }} | cut -c1-8)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
@@ -64,3 +68,9 @@ jobs:
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_COMMIT=${{ github.sha }}
|
||||
GIT_COMMIT_SHORT=${{ steps.vars.outputs.short_sha }}
|
||||
GIT_BRANCH=${{ github.ref_name }}
|
||||
GIT_TAG=${{ github.ref_type == 'tag' && github.ref_name || '' }}
|
||||
GIT_DIRTY=false
|
||||
|
||||
817
CHANGELOG.md
817
CHANGELOG.md
@@ -1,3 +1,820 @@
|
||||
## [4.33.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.33.5...v4.33.6) (2025-10-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **levels:** increase container height to prevent abacus clipping ([cd5c15a](https://github.com/antialias/soroban-abacus-flashcards/commit/cd5c15aeb260c568fe7ad9b6a4f51c4d6498b2b8))
|
||||
|
||||
## [4.33.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.33.4...v4.33.5) (2025-10-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **levels:** revert delayed column change, keep overflow hidden ([22f00f5](https://github.com/antialias/soroban-abacus-flashcards/commit/22f00f59f5facc36a846408dcd196ec54ea676b1))
|
||||
|
||||
## [4.33.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.33.3...v4.33.4) (2025-10-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **levels:** stabilize slider position and prevent abacus clipping ([09004dc](https://github.com/antialias/soroban-abacus-flashcards/commit/09004dc2c055031ee2f71c964ceee6f7b1d42ecd))
|
||||
|
||||
## [4.33.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.33.2...v4.33.3) (2025-10-20)
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **levels:** move slider into level display pane above abacus ([2d8bb4a](https://github.com/antialias/soroban-abacus-flashcards/commit/2d8bb4ab8804f399d1ccc8a18feff9f09eca8029))
|
||||
|
||||
## [4.33.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.33.1...v4.33.2) (2025-10-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **levels:** add fixed height to entire level display pane ([200b26c](https://github.com/antialias/soroban-abacus-flashcards/commit/200b26c2cd35d1d637ede9dcfc3dbbc7f3f19320))
|
||||
|
||||
## [4.33.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.33.0...v4.33.1) (2025-10-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **levels:** only animate abacus, not container with background/border ([c80477d](https://github.com/antialias/soroban-abacus-flashcards/commit/c80477d24877ddada5f3f4405abbf05e1d753b5d))
|
||||
|
||||
## [4.33.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.32.1...v4.33.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **abacus-react:** add BigInt support for 30-digit Dan level abacuses ([0ab4cc2](https://github.com/antialias/soroban-abacus-flashcards/commit/0ab4cc288066b75a6ea4371f65098db5c0fc8847))
|
||||
* **levels:** add hover interaction and smooth React Spring transitions ([fd2b633](https://github.com/antialias/soroban-abacus-flashcards/commit/fd2b6338a84c3bbc683eff216a8da3b155749f0f))
|
||||
|
||||
## [4.32.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.32.0...v4.32.1) (2025-10-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **levels:** use correct dark mode styling from homepage + docs update ([c38767f](https://github.com/antialias/soroban-abacus-flashcards/commit/c38767f4d399fa2caa5cd4e0185689d0207fbdaf))
|
||||
|
||||
## [4.32.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.31.1...v4.32.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **levels:** add dark mode styling and responsive scaling to abacus ([92e1e62](https://github.com/antialias/soroban-abacus-flashcards/commit/92e1e621321039206f65b3605f5797bbdc6beafc))
|
||||
|
||||
## [4.31.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.31.0...v4.31.1) (2025-10-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **levels:** use correct AbacusReact API with direct props ([892b377](https://github.com/antialias/soroban-abacus-flashcards/commit/892b377eb3bbd555dd2566bf58e946e9faa7b9f6))
|
||||
|
||||
## [4.31.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.30.0...v4.31.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **levels:** implement interactive slider for exploring kyu & dan ranks ([eb3b100](https://github.com/antialias/soroban-abacus-flashcards/commit/eb3b1000563536d4143ba1f4ec04e59e8dd2e608))
|
||||
|
||||
## [4.30.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.29.0...v4.30.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **levels:** create true horizontal slider with abacus visualizations ([6d734f1](https://github.com/antialias/soroban-abacus-flashcards/commit/6d734f1d51f5ba1367f55923e58bd977413d754e))
|
||||
|
||||
## [4.29.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.28.0...v4.29.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **levels:** replace kyu grid with interactive slider and abacus visualizations ([10978e8](https://github.com/antialias/soroban-abacus-flashcards/commit/10978e890beee65dea78ddcce52cfe5315d58063))
|
||||
|
||||
## [4.28.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.27.0...v4.28.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **levels:** add informational footer section ([0b1bff7](https://github.com/antialias/soroban-abacus-flashcards/commit/0b1bff7eab8f5da84ae309dbda336e168c2fe3fd))
|
||||
|
||||
## [4.27.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.26.0...v4.27.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **levels:** add Dan levels ladder visualization ([c18012c](https://github.com/antialias/soroban-abacus-flashcards/commit/c18012cb505a1f2a86ebed7579b379a4d7d97f2c))
|
||||
|
||||
## [4.26.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.25.1...v4.26.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **levels:** add kyu level data and cards ([6463a3b](https://github.com/antialias/soroban-abacus-flashcards/commit/6463a3b2f6371ebebac1048197fb44178997d2ef))
|
||||
|
||||
## [4.25.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.25.0...v4.25.1) (2025-10-20)
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **homepage:** make entire "Your Journey" card clickable ([3f30810](https://github.com/antialias/soroban-abacus-flashcards/commit/3f30810271418f3acf3df17e41d9a897a3312c34))
|
||||
|
||||
## [4.25.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.24.3...v4.25.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **levels:** add Kyu & Dan levels page with homepage link ([39b1e7d](https://github.com/antialias/soroban-abacus-flashcards/commit/39b1e7de16f15412c91cf648c714e31e2de7a6bc))
|
||||
|
||||
|
||||
### Styles
|
||||
|
||||
* **homepage:** adjust journey emoji sizing and spacing ([2a0e469](https://github.com/antialias/soroban-abacus-flashcards/commit/2a0e469e83b99c88f091bfecd770e0b4c1cb6310))
|
||||
|
||||
## [4.24.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.24.2...v4.24.3) (2025-10-20)
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **homepage:** align all skill icon panes horizontally ([4b04e86](https://github.com/antialias/soroban-abacus-flashcards/commit/4b04e8673da228863d4ec1869897ee431fa3d753))
|
||||
|
||||
## [4.24.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.24.1...v4.24.2) (2025-10-20)
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **homepage:** tighten mini abacus vertical padding ([514d07e](https://github.com/antialias/soroban-abacus-flashcards/commit/514d07ecb5f65a3c0982b8e90994e1c17ebaa59c))
|
||||
|
||||
## [4.24.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.24.0...v4.24.1) (2025-10-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **homepage:** adjust mini abacus container height ([c4066d6](https://github.com/antialias/soroban-abacus-flashcards/commit/c4066d687925bbe7737ebfeefdada7365ff97c6c))
|
||||
* **homepage:** fix MiniAbacus runtime error and improve sizing ([1fa0df8](https://github.com/antialias/soroban-abacus-flashcards/commit/1fa0df85f7d3988cbc61701d89476419ccf0a13c))
|
||||
* **homepage:** use correct AbacusReact API and fix clipping/styling issues ([1432afd](https://github.com/antialias/soroban-abacus-flashcards/commit/1432afd6e6bd547bd0da76dbeea1c2b71244826f))
|
||||
* **homepage:** use direct conditionals for mini abacus padding ([38ef16a](https://github.com/antialias/soroban-abacus-flashcards/commit/38ef16a8f91f8ab4ad0d717b0321e2002636fafb))
|
||||
|
||||
|
||||
### Styles
|
||||
|
||||
* **homepage:** add more padding around mini abacus ([c5103d0](https://github.com/antialias/soroban-abacus-flashcards/commit/c5103d049f73a8f7ef26915edfbef9ea56d59094))
|
||||
* **homepage:** balance mini abacus padding horizontally and vertically ([2f0304e](https://github.com/antialias/soroban-abacus-flashcards/commit/2f0304eb81cdf84c21b0554c9cd4bd5478896dd8))
|
||||
* **homepage:** increase mini abacus padding to '5' ([1da9ed1](https://github.com/antialias/soroban-abacus-flashcards/commit/1da9ed1ce6995c605622fc2863f248e5e91ab9c3))
|
||||
|
||||
## [4.24.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.23.0...v4.24.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **homepage:** add animated mini abacus to "Read and set numbers" card ([e028e34](https://github.com/antialias/soroban-abacus-flashcards/commit/e028e342ad4bc01491e05a4ba074628155926fd8))
|
||||
|
||||
## [4.23.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.22.0...v4.23.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **homepage:** add more visual embellishments to learning cards ([4ec1b95](https://github.com/antialias/soroban-abacus-flashcards/commit/4ec1b952f202d50f6db287c41732ec65ca17c142))
|
||||
|
||||
## [4.22.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.21.1...v4.22.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **homepage:** enhance "What You'll Learn" with visual cards ([d142342](https://github.com/antialias/soroban-abacus-flashcards/commit/d1423420e653b26b2f89d9d17ae5d597807d6979))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **tutorial:** reduce tooltip z-index to scroll under nav bar ([47640f3](https://github.com/antialias/soroban-abacus-flashcards/commit/47640f3486c6d4a7107d59bdcce043f76fabbb1d))
|
||||
|
||||
## [4.21.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.21.0...v4.21.1) (2025-10-20)
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **homepage:** rearrange tutorial demo layout side by side ([8b4ecee](https://github.com/antialias/soroban-abacus-flashcards/commit/8b4eceebfaaaf07e38ea64c7fe015aec86ac754f))
|
||||
|
||||
## [4.21.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.20.7...v4.21.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **tutorial:** add silentErrors prop to suppress error messages ([8835e1c](https://github.com/antialias/soroban-abacus-flashcards/commit/8835e1c57ab8adcecefe0db082360dd98fbfaac7))
|
||||
|
||||
## [4.20.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.20.6...v4.20.7) (2025-10-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **home:** use Panda CSS token() for dynamic colors and center arrows properly ([d52ba63](https://github.com/antialias/soroban-abacus-flashcards/commit/d52ba6373a4577655dc1e5f5ff4926af7f7d96c3))
|
||||
|
||||
## [4.20.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.20.5...v4.20.6) (2025-10-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **homepage:** use inline styles for journey level colors ([5d85e89](https://github.com/antialias/soroban-abacus-flashcards/commit/5d85e898d65d44d8d09bee952fad44b5a9c0cd20)), closes [#4ade80](https://github.com/antialias/soroban-abacus-flashcards/issues/4ade80) [#60a5](https://github.com/antialias/soroban-abacus-flashcards/issues/60a5) [#a78](https://github.com/antialias/soroban-abacus-flashcards/issues/a78) [#fbbf24](https://github.com/antialias/soroban-abacus-flashcards/issues/fbbf24)
|
||||
|
||||
## [4.20.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.20.4...v4.20.5) (2025-10-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **docker:** include Panda CSS styled-system in production image ([57fabff](https://github.com/antialias/soroban-abacus-flashcards/commit/57fabffe605d953b4a4d7e05032401cbf1ab2d14))
|
||||
|
||||
## [4.20.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.20.3...v4.20.4) (2025-10-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **homepage:** use inline styles for Your Journey text contrast ([8e51390](https://github.com/antialias/soroban-abacus-flashcards/commit/8e5139001818d7013e1b2654ac707f7429316d58)), closes [#e5e7](https://github.com/antialias/soroban-abacus-flashcards/issues/e5e7) [#e5e7](https://github.com/antialias/soroban-abacus-flashcards/issues/e5e7) [#9ca3](https://github.com/antialias/soroban-abacus-flashcards/issues/9ca3) [#d1d5](https://github.com/antialias/soroban-abacus-flashcards/issues/d1d5)
|
||||
|
||||
## [4.20.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.20.2...v4.20.3) (2025-10-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **homepage:** use explicit RGBA colors for Your Journey text ([9c51cc9](https://github.com/antialias/soroban-abacus-flashcards/commit/9c51cc94eec4efcab9c0b9d1190f5b79c0c7d365))
|
||||
|
||||
## [4.20.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.20.1...v4.20.2) (2025-10-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **homepage:** improve text contrast in Your Journey section ([24d1200](https://github.com/antialias/soroban-abacus-flashcards/commit/24d120004dccecc1ce2f08c1b73eec902868fb23))
|
||||
* **tutorial:** resolve TypeScript errors in TutorialPlayer ([88f57ce](https://github.com/antialias/soroban-abacus-flashcards/commit/88f57ce6df125142d6ea7feec60c475926bd4929))
|
||||
|
||||
## [4.20.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.20.0...v4.20.1) (2025-10-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **homepage:** correct positioning of progression arrows in Your Journey section ([3fff9ef](https://github.com/antialias/soroban-abacus-flashcards/commit/3fff9ef140bf1f462042f8319ed6c5e2a376e4ba))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **homepage:** move What You'll Learn above tutorial ([ca1c6d8](https://github.com/antialias/soroban-abacus-flashcards/commit/ca1c6d86029c891e019a96ba161e49b08b5be1bf))
|
||||
|
||||
## [4.20.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.19.0...v4.20.0) (2025-10-19)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **tutorial:** add hideTooltip prop and improve dark mode coaching bar ([1ee25b3](https://github.com/antialias/soroban-abacus-flashcards/commit/1ee25b3dd2f0ee9dd7ed571ba818b7ca5a247f85))
|
||||
|
||||
## [4.19.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.18.1...v4.19.0) (2025-10-19)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **tutorial:** add fill color support for dark mode column posts and reckoning bar ([2eb3ff3](https://github.com/antialias/soroban-abacus-flashcards/commit/2eb3ff340613301df20bf14f5b461371a27d7f05))
|
||||
|
||||
## [4.18.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.18.0...v4.18.1) (2025-10-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **tutorial:** use correct customStyles API for dark mode frame styling ([fdc882c](https://github.com/antialias/soroban-abacus-flashcards/commit/fdc882cb046e3d8835fbca59841e9af5329bcc52))
|
||||
|
||||
## [4.18.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.17.2...v4.18.0) (2025-10-19)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **tutorial:** add dark mode styling for coaching bar and abacus frame ([7e2f580](https://github.com/antialias/soroban-abacus-flashcards/commit/7e2f580877af9d21409f427778fa3569c950fcf5))
|
||||
|
||||
## [4.17.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.17.1...v4.17.2) (2025-10-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **tutorial:** correct column index calculation for variable column counts ([bf1ced4](https://github.com/antialias/soroban-abacus-flashcards/commit/bf1ced43f801938b05f01548eea5fe771de1b58f))
|
||||
|
||||
## [4.17.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.17.0...v4.17.1) (2025-10-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **tutorial:** filter bead highlights when using fewer columns ([4d906ec](https://github.com/antialias/soroban-abacus-flashcards/commit/4d906ec20e90a9b0b3838f9b8428e0c68992f381))
|
||||
|
||||
## [4.17.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.16.0...v4.17.0) (2025-10-19)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **tutorial:** add dark theme and column control props ([d42f9b2](https://github.com/antialias/soroban-abacus-flashcards/commit/d42f9b2d9ad630826c55b753dc581c469e8f9083))
|
||||
|
||||
|
||||
### Styles
|
||||
|
||||
* **homepage:** soften tutorial styling for dark theme cohesion ([faaefba](https://github.com/antialias/soroban-abacus-flashcards/commit/faaefbacff419b337aa0fac4a101d5106a18c77c)), closes [#f9](https://github.com/antialias/soroban-abacus-flashcards/issues/f9)
|
||||
|
||||
## [4.16.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.15.1...v4.16.0) (2025-10-19)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **tutorial:** add hideNavigation prop to TutorialPlayer ([79ea52a](https://github.com/antialias/soroban-abacus-flashcards/commit/79ea52af80c8cbb482bbdd87f77caf32ada737ee))
|
||||
|
||||
|
||||
### Styles
|
||||
|
||||
* **homepage:** update tutorial container to match dark theme ([6b017b0](https://github.com/antialias/soroban-abacus-flashcards/commit/6b017b0fe92d4277843d9fe2645c22366f219d76))
|
||||
|
||||
## [4.15.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.15.0...v4.15.1) (2025-10-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **tutorial:** resolve React hydration error in TutorialPlayer ([c883d9e](https://github.com/antialias/soroban-abacus-flashcards/commit/c883d9e4c1b3a2f52c9d41e3ddce7418399f2649))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* replace demo component with real TutorialPlayer system ([19b03bc](https://github.com/antialias/soroban-abacus-flashcards/commit/19b03bc77c649cf51d7b9a3617417c6ec8229ac7))
|
||||
|
||||
## [4.15.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.14.6...v4.15.0) (2025-10-19)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* redesign homepage with educational vision and interactive demo ([2f09cb5](https://github.com/antialias/soroban-abacus-flashcards/commit/2f09cb5539f2bb0b8c77359c6f774c3742313e1e))
|
||||
|
||||
## [4.14.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.14.5...v4.14.6) (2025-10-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* replace native alerts with inline confirmations in ModerationPanel ([ebe123e](https://github.com/antialias/soroban-abacus-flashcards/commit/ebe123ed7edf24fbc7b8765ed709455a8513d6d5))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* add UI style guide documenting no native alerts rule ([9afd3a7](https://github.com/antialias/soroban-abacus-flashcards/commit/9afd3a7e925fddb76fa587747881b61f7cb077a5))
|
||||
|
||||
## [4.14.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.14.4...v4.14.5) (2025-10-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **rooms:** add real-time ownership transfer updates via WebSocket ([c00cfa3](https://github.com/antialias/soroban-abacus-flashcards/commit/c00cfa3de011720f3399fa340182b347f7e0d456))
|
||||
|
||||
## [4.14.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.14.3...v4.14.4) (2025-10-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **arcade:** add host-only game selection with clear messaging ([22df1b0](https://github.com/antialias/soroban-abacus-flashcards/commit/22df1b0b661efe69fac1a6bd716531c904757412))
|
||||
* **arcade:** add host-only game selection with clear messaging ([c0680ca](https://github.com/antialias/soroban-abacus-flashcards/commit/c0680cad0fa26af0933e93a06c50317bf443cc7d))
|
||||
|
||||
## [4.14.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.14.2...v4.14.3) (2025-10-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **docker:** add qpdf for PDF linearization and validation ([c92ff39](https://github.com/antialias/soroban-abacus-flashcards/commit/c92ff3971c853e4e55ccd632ff3ee292fcce8315))
|
||||
|
||||
## [4.14.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.14.1...v4.14.2) (2025-10-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **docker:** add packages/templates for Typst flashcard generation ([1417722](https://github.com/antialias/soroban-abacus-flashcards/commit/14177224380b8c37413123bee344c9b762055a15))
|
||||
|
||||
## [4.14.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.14.0...v4.14.1) (2025-10-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **deployment:** pass git info to Docker build for deployment info modal ([4b04e43](https://github.com/antialias/soroban-abacus-flashcards/commit/4b04e43ff8c9e9f239d7f5e306aab338b535296f))
|
||||
|
||||
## [4.14.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.13.15...v4.14.0) (2025-10-19)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **card-sorting:** add spectator mode UX enhancements ([4ab093a](https://github.com/antialias/soroban-abacus-flashcards/commit/4ab093a9d8ba5b290da44aaa6aa71ad7d7149b32))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* **arcade:** add comprehensive spectator mode documentation ([1eb6cec](https://github.com/antialias/soroban-abacus-flashcards/commit/1eb6ceca19805be53dfe3194e07e68002b2d09a7))
|
||||
* **arcade:** spec spectator mode UX enhancements for card sorting ([aafba77](https://github.com/antialias/soroban-abacus-flashcards/commit/aafba77d62b47403f21f5dd72859d6a6fbb1efac))
|
||||
|
||||
## [4.13.15](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.13.14...v4.13.15) (2025-10-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **docker:** bypass PEP 668 externally-managed-environment error ([bb59c61](https://github.com/antialias/soroban-abacus-flashcards/commit/bb59c61638e60b0678043e954e044d9390f88e7f))
|
||||
|
||||
## [4.13.14](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.13.13...v4.13.14) (2025-10-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **docker:** install py3-pip for Python dependency installation ([0f55909](https://github.com/antialias/soroban-abacus-flashcards/commit/0f55909533414bdc07f113b93bb8bfa21367959b))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* add Panda CSS styling framework documentation ([c92d7d9](https://github.com/antialias/soroban-abacus-flashcards/commit/c92d7d9d89a72e012c30fc5ac88fa96e7a526f83))
|
||||
* **arcade:** fix incorrect Tailwind references - use Panda CSS ([34a377d](https://github.com/antialias/soroban-abacus-flashcards/commit/34a377d91b37ad47968b85aedd112f9fcf72ad63))
|
||||
|
||||
## [4.13.13](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.13.12...v4.13.13) (2025-10-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **docker:** install Python dependencies for flashcard generation ([c9b7e92](https://github.com/antialias/soroban-abacus-flashcards/commit/c9b7e92f39ee7aa7f13606c2836763144df102e7))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **arcade:** standardize game card themes with preset system ([0209975](https://github.com/antialias/soroban-abacus-flashcards/commit/0209975af642944cc5a434c0b44205a87e634e7e)), closes [#99f6e4](https://github.com/antialias/soroban-abacus-flashcards/issues/99f6e4) [#5eead4](https://github.com/antialias/soroban-abacus-flashcards/issues/5eead4)
|
||||
|
||||
## [4.13.12](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.13.11...v4.13.12) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **card-sorting:** use blue gradient matching other game cards ([bdb84f5](https://github.com/antialias/soroban-abacus-flashcards/commit/bdb84f5d909542060fa886a83a5af62c4a785a98))
|
||||
|
||||
## [4.13.11](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.13.10...v4.13.11) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **card-sorting:** match game selector background to other games ([db62519](https://github.com/antialias/soroban-abacus-flashcards/commit/db62519f9beb0b4bc6120e1fd5ec251cfde5c3c1)), closes [#ccfbf1](https://github.com/antialias/soroban-abacus-flashcards/issues/ccfbf1) [#99f6e4](https://github.com/antialias/soroban-abacus-flashcards/issues/99f6e4)
|
||||
* **docker:** copy core package with Python scripts to production image ([33e9ad2](https://github.com/antialias/soroban-abacus-flashcards/commit/33e9ad2f79b591f1c5ee57a6691e1bcf48420859))
|
||||
|
||||
## [4.13.10](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.13.9...v4.13.10) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add Typst to Docker image for flashcard generation ([d9a7694](https://github.com/antialias/soroban-abacus-flashcards/commit/d9a769403187bf70fb069be7ffe77417a62271a5))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* remove 'Complete Soroban Learning Platform' section ([42dcbff](https://github.com/antialias/soroban-abacus-flashcards/commit/42dcbff85708ad378550634cbf7a3345eccb578e))
|
||||
|
||||
## [4.13.9](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.13.8...v4.13.9) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* set color on abacus container div for numeral visibility ([cd47960](https://github.com/antialias/soroban-abacus-flashcards/commit/cd4796024e41f731ae5d83c82f6582e19d6eaf99)), closes [#1f2937](https://github.com/antialias/soroban-abacus-flashcards/issues/1f2937)
|
||||
|
||||
## [4.13.8](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.13.7...v4.13.8) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* use color instead of fill for numeral styling ([ea10c16](https://github.com/antialias/soroban-abacus-flashcards/commit/ea10c16811eb969b9963417079c330ae9ff295ba))
|
||||
|
||||
## [4.13.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.13.6...v4.13.7) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add dark color for abacus numerals ([73ff32c](https://github.com/antialias/soroban-abacus-flashcards/commit/73ff32c2432beb62710e57aa8b3b4793eca43fda)), closes [#1f2937](https://github.com/antialias/soroban-abacus-flashcards/issues/1f2937)
|
||||
* use app-wide abacus config and remove instruction text ([0a50c73](https://github.com/antialias/soroban-abacus-flashcards/commit/0a50c733b089c7c341f0fdef47da78d1c61a3cb5))
|
||||
|
||||
## [4.13.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.13.5...v4.13.6) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* simplify abacus pane with light background ([30f48ab](https://github.com/antialias/soroban-abacus-flashcards/commit/30f48ab8976976688e089b07ece7fdae6d7ada79))
|
||||
|
||||
## [4.13.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.13.4...v4.13.5) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* correct AbacusReact API usage and add structural styling ([247377f](https://github.com/antialias/soroban-abacus-flashcards/commit/247377fca35ee3433e02ad594ecc1c4f391f0143)), closes [#fbbf24](https://github.com/antialias/soroban-abacus-flashcards/issues/fbbf24) [#a78](https://github.com/antialias/soroban-abacus-flashcards/issues/a78)
|
||||
|
||||
## [4.13.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.13.3...v4.13.4) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **card-sorting:** increase card tile sizes to contain abacuses ([d2a3b7a](https://github.com/antialias/soroban-abacus-flashcards/commit/d2a3b7ae2e3f6819b8d9ace32be22f04f748d1bc))
|
||||
|
||||
## [4.13.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.13.2...v4.13.3) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **card-sorting:** increase SVG size to fill card containers ([cf9d893](https://github.com/antialias/soroban-abacus-flashcards/commit/cf9d893f3fdbef6e91cd0ba283d602b9215569f1))
|
||||
|
||||
## [4.13.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.13.1...v4.13.2) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* show initial value and improve numeral contrast ([1b57f6d](https://github.com/antialias/soroban-abacus-flashcards/commit/1b57f6ddecf3a118f2e4fadd1a91be1256f5a034)), closes [#fbbf24](https://github.com/antialias/soroban-abacus-flashcards/issues/fbbf24)
|
||||
|
||||
## [4.13.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.13.0...v4.13.1) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* use defaultValue for interactive abacus control ([06aca98](https://github.com/antialias/soroban-abacus-flashcards/commit/06aca986ace4d76b70f2fd2f5e57f66758185b38))
|
||||
|
||||
## [4.13.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.12.0...v4.13.0) (2025-10-18)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* make home page abacus interactive with audio ([9a53d7e](https://github.com/antialias/soroban-abacus-flashcards/commit/9a53d7e5db18853aca4e2e0c7abc799217feaecf))
|
||||
|
||||
## [4.12.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.11.1...v4.12.0) (2025-10-18)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* redesign home page with component showcase ([29af265](https://github.com/antialias/soroban-abacus-flashcards/commit/29af265958f9fdab0253b92e153c01575840454d))
|
||||
|
||||
## [4.11.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.11.0...v4.11.1) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **card-sorting:** center AbacusReact SVGs in card tiles ([26edec1](https://github.com/antialias/soroban-abacus-flashcards/commit/26edec1bbf038264405ec9d161edcd18f67a6fc6))
|
||||
|
||||
## [4.11.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.10.6...v4.11.0) (2025-10-18)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **home:** redesign home page to showcase complete platform ([ee6c4f2](https://github.com/antialias/soroban-abacus-flashcards/commit/ee6c4f2f4f39e3b30f59c54866c3857c218fb80f))
|
||||
|
||||
## [4.10.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.10.5...v4.10.6) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **card-sorting:** position slots flow horizontally with wrap ([e14ffe4](https://github.com/antialias/soroban-abacus-flashcards/commit/e14ffe44d66d0c97bc0cc4e0c255698e88ce723a))
|
||||
|
||||
## [4.10.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.10.4...v4.10.5) (2025-10-18)
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **arcade:** merge /arcade/room into /arcade route ([0790074](https://github.com/antialias/soroban-abacus-flashcards/commit/0790074ffc5008bce9a162fe0ddbd1d5c214c4f7))
|
||||
|
||||
## [4.10.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.10.3...v4.10.4) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **card-sorting:** match Python card layout with flex wrap ([9679d68](https://github.com/antialias/soroban-abacus-flashcards/commit/9679d68154ac8b6a2f905ec7d17a34b39bc00237))
|
||||
|
||||
## [4.10.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.10.2...v4.10.3) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **arcade:** remove broken query param from game URLs ([87631af](https://github.com/antialias/soroban-abacus-flashcards/commit/87631af6788bd7b42e671374e55ec0ad8435900c))
|
||||
|
||||
## [4.10.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.10.1...v4.10.2) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **card-sorting:** faithfully port UI/UX from Python original ([c92076f](https://github.com/antialias/soroban-abacus-flashcards/commit/c92076f232930aa12d9a0230fa745b73b5cc04d9)), closes [#2c5f76](https://github.com/antialias/soroban-abacus-flashcards/issues/2c5f76) [#1976d2](https://github.com/antialias/soroban-abacus-flashcards/issues/1976d2)
|
||||
|
||||
## [4.10.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.10.0...v4.10.1) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **arcade:** remove legacy master-organizer placeholder ([76d207e](https://github.com/antialias/soroban-abacus-flashcards/commit/76d207e2e5244f84bc0d76fe3d753034f1991228))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* remove old single-player complement-race version ([0eae43a](https://github.com/antialias/soroban-abacus-flashcards/commit/0eae43a8ce16c1c080c04c352ba750f55165b694))
|
||||
|
||||
## [4.10.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.9.0...v4.10.0) (2025-10-18)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **card-sorting:** add UI components and fix AbacusReact props ([d249ec0](https://github.com/antialias/soroban-abacus-flashcards/commit/d249ec0e5ff4610f55f35f762d726e0c98ac366c))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* remove old single-player memory-quiz version ([0dab5da](https://github.com/antialias/soroban-abacus-flashcards/commit/0dab5da0c7f9186695b1970c85e5c09ea0e33c5f))
|
||||
|
||||
## [4.9.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.8.0...v4.9.0) (2025-10-18)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **card-sorting:** implement Provider with arcade session integration ([7f6fea9](https://github.com/antialias/soroban-abacus-flashcards/commit/7f6fea91f6dcc69a173eea86bcefc9921f1c1664))
|
||||
|
||||
## [4.8.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.7.1...v4.8.0) (2025-10-18)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **arcade:** add Card Sorting Challenge game scaffolding ([df37260](https://github.com/antialias/soroban-abacus-flashcards/commit/df37260e26bbb146493e0834e093afd98fa3f2a4))
|
||||
|
||||
## [4.7.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.7.0...v4.7.1) (2025-10-18)
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **arcade:** remove number-guesser and math-sprint games ([9fb9786](https://github.com/antialias/soroban-abacus-flashcards/commit/9fb9786e54928e81ecf226b36d343a73143fb674))
|
||||
|
||||
## [4.7.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.10...v4.7.0) (2025-10-18)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **complement-race:** enable adaptive AI difficulty in arcade ([55010d2](https://github.com/antialias/soroban-abacus-flashcards/commit/55010d2bcd953718d8fea428b1f7f613a193779c))
|
||||
|
||||
## [4.6.10](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.9...v4.6.10) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** improve AI speech bubble positioning ([6e436db](https://github.com/antialias/soroban-abacus-flashcards/commit/6e436db5e709d944ebffed6936ea1f8e4bd2e19e))
|
||||
* **docker:** remove reference to deleted @soroban/client package ([2953ef8](https://github.com/antialias/soroban-abacus-flashcards/commit/2953ef8917f7b13f6eb562eb7d58d14179a718da))
|
||||
|
||||
## [4.6.9](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.8...v4.6.9) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** add missing AI commentary cooldown updates ([357aa30](https://github.com/antialias/soroban-abacus-flashcards/commit/357aa30618f80d659ae515f94b7b9254bb458910))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* remove dead Python bridge and unused packages ([22426f6](https://github.com/antialias/soroban-abacus-flashcards/commit/22426f677f9b127441377b95571f0066a0990d3f))
|
||||
|
||||
## [4.6.8](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.7...v4.6.8) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** counter-flip AI speech bubbles to make text readable ([07d5607](https://github.com/antialias/soroban-abacus-flashcards/commit/07d5607218aee03e813eceff5d161a7838d66bcb))
|
||||
|
||||
## [4.6.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.6...v4.6.7) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** use active local players pattern from navbar ([71cdc34](https://github.com/antialias/soroban-abacus-flashcards/commit/71cdc342c97ca53b5e7e4202d4d344199e8ddd98))
|
||||
|
||||
## [4.6.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.5...v4.6.6) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** use local player emoji instead of first active player ([76eb051](https://github.com/antialias/soroban-abacus-flashcards/commit/76eb0517c202d1b9160b49dec0b99ff4972daff2))
|
||||
|
||||
## [4.6.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.4...v4.6.5) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** flip player avatar to face right in practice mode ([fa6b3b6](https://github.com/antialias/soroban-abacus-flashcards/commit/fa6b3b69d5a4a7eb70f8c18fc8c122c54c4d504a))
|
||||
|
||||
## [4.6.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.3...v4.6.4) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** flip AI racers to face right in practice mode ([ebfff1a](https://github.com/antialias/soroban-abacus-flashcards/commit/ebfff1a62fd104d531a8158345c8c012ec8a55d3))
|
||||
|
||||
## [4.6.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.2...v4.6.3) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** balance AI speeds to match original implementation ([054f0c0](https://github.com/antialias/soroban-abacus-flashcards/commit/054f0c0d235dc2b0042a0f6af48840d23a4c5ff8))
|
||||
|
||||
## [4.6.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.1...v4.6.2) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **build:** resolve Docker build failures preventing deployment ([7801dbb](https://github.com/antialias/soroban-abacus-flashcards/commit/7801dbb25fb0a33429c70f11294264f7238ce7a4))
|
||||
|
||||
## [4.6.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.0...v4.6.1) (2025-10-18)
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **complement-race:** move AI opponents from server-side to client-side ([09e21fa](https://github.com/antialias/soroban-abacus-flashcards/commit/09e21fa4934c634d0ce46381ef7e40238fc134c3))
|
||||
|
||||
## [4.6.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.5.0...v4.6.0) (2025-10-18)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **complement-race:** restore AI opponents in practice and survival modes ([325e07d](https://github.com/antialias/soroban-abacus-flashcards/commit/325e07de5929169aa333ef16f7bca5b41eeb1622))
|
||||
|
||||
## [4.5.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.15...v4.5.0) (2025-10-18)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **complement-race:** add infinite win condition for Steam Sprint mode ([d8fdfee](https://github.com/antialias/soroban-abacus-flashcards/commit/d8fdfeef74a5d3bb9684254af1c9d64d264b46ad))
|
||||
|
||||
## [4.4.15](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.14...v4.4.15) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** track previous position to detect route threshold crossing ([a6c20aa](https://github.com/antialias/soroban-abacus-flashcards/commit/a6c20aab3b245d9893808d188d16a35ab80cfca9))
|
||||
|
||||
## [4.4.14](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.13...v4.4.14) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** remove dual game loop conflict preventing route progression ([84d42e2](https://github.com/antialias/soroban-abacus-flashcards/commit/84d42e22ac0cdd25e87e45dc698029ad7ed78559))
|
||||
|
||||
## [4.4.13](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.12...v4.4.13) (2025-10-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** show new passengers when route changes ([ec1c8ed](https://github.com/antialias/soroban-abacus-flashcards/commit/ec1c8ed263844f56477c1f709041339b42b48f4e))
|
||||
|
||||
## [4.4.12](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.11...v4.4.12) (2025-10-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** track physical car indices to prevent boarding issues ([53bbae8](https://github.com/antialias/soroban-abacus-flashcards/commit/53bbae84af7317d5e12109db2054cc70ca5bea27))
|
||||
* **complement-race:** update passenger display when state changes ([5116364](https://github.com/antialias/soroban-abacus-flashcards/commit/511636400c19776b58c6bddf8f7c9cc398a05236))
|
||||
|
||||
## [4.4.11](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.10...v4.4.11) (2025-10-17)
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **logging:** replace per-frame debug logging with event-based logging ([fedb324](https://github.com/antialias/soroban-abacus-flashcards/commit/fedb32486ab5c6c619ebc03570b6c66529a1344e))
|
||||
|
||||
## [4.4.10](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.9...v4.4.10) (2025-10-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** correct passenger boarding to use multiplayer fields ([7ed1b94](https://github.com/antialias/soroban-abacus-flashcards/commit/7ed1b94b8fa620cb4f64ba43e160ef511704f3ce))
|
||||
|
||||
## [4.4.9](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.8...v4.4.9) (2025-10-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** reduce initial momentum from 50 to 10 to prevent train sailing past first station ([5f146b0](https://github.com/antialias/soroban-abacus-flashcards/commit/5f146b0daf74d54e1c7b9a57d3a2f37e73849ff2))
|
||||
|
||||
## [4.4.8](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.7...v4.4.8) (2025-10-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** implement client-side momentum with continuous decay for smooth train movement ([ea19ff9](https://github.com/antialias/soroban-abacus-flashcards/commit/ea19ff918bc70ad3eb0339e18dbd32195f34816e))
|
||||
|
||||
## [4.4.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.6...v4.4.7) (2025-10-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** add missing useRef import ([d43829a](https://github.com/antialias/soroban-abacus-flashcards/commit/d43829ad48f7ee879a46879f5e6ac1256db1f564))
|
||||
|
||||
## [4.4.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.5...v4.4.6) (2025-10-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** restore smooth train movement with client-side game loop ([46a80cb](https://github.com/antialias/soroban-abacus-flashcards/commit/46a80cbcc8ec39224d4edaf540da25611d48fbdd))
|
||||
|
||||
## [4.4.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.4...v4.4.5) (2025-10-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** add missing useEffect import ([3054130](https://github.com/antialias/soroban-abacus-flashcards/commit/30541304dd0f0801860dd62967f7f7cae717bcdd))
|
||||
|
||||
## [4.4.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.3...v4.4.4) (2025-10-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** add pressure decay system and improve logging ([66992e8](https://github.com/antialias/soroban-abacus-flashcards/commit/66992e877065a42d00379ef8fae0a6e252b0ffcb))
|
||||
|
||||
## [4.4.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.2...v4.4.3) (2025-10-17)
|
||||
|
||||
|
||||
|
||||
32
Dockerfile
32
Dockerfile
@@ -13,7 +13,6 @@ WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
|
||||
COPY apps/web/package.json ./apps/web/
|
||||
COPY packages/core/client/node/package.json ./packages/core/client/node/
|
||||
COPY packages/core/client/typescript/package.json ./packages/core/client/typescript/
|
||||
COPY packages/abacus-react/package.json ./packages/abacus-react/
|
||||
COPY packages/templates/package.json ./packages/templates/
|
||||
|
||||
@@ -22,6 +21,21 @@ RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Builder stage
|
||||
FROM base AS builder
|
||||
|
||||
# Accept git information as build arguments
|
||||
ARG GIT_COMMIT
|
||||
ARG GIT_COMMIT_SHORT
|
||||
ARG GIT_BRANCH
|
||||
ARG GIT_TAG
|
||||
ARG GIT_DIRTY
|
||||
|
||||
# Set as environment variables for build scripts
|
||||
ENV GIT_COMMIT=${GIT_COMMIT}
|
||||
ENV GIT_COMMIT_SHORT=${GIT_COMMIT_SHORT}
|
||||
ENV GIT_BRANCH=${GIT_BRANCH}
|
||||
ENV GIT_TAG=${GIT_TAG}
|
||||
ENV GIT_DIRTY=${GIT_DIRTY}
|
||||
|
||||
COPY . .
|
||||
|
||||
# Generate Panda CSS styled-system before building
|
||||
@@ -34,8 +48,8 @@ RUN turbo build --filter=@soroban/web
|
||||
FROM node:18-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
# Install Python and build tools for better-sqlite3 (needed at runtime)
|
||||
RUN apk add --no-cache python3 py3-setuptools make g++
|
||||
# Install Python, pip, build tools for better-sqlite3, Typst, and qpdf (needed at runtime)
|
||||
RUN apk add --no-cache python3 py3-pip py3-setuptools make g++ typst qpdf
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
@@ -45,6 +59,9 @@ RUN adduser --system --uid 1001 nextjs
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next ./apps/web/.next
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
|
||||
|
||||
# Copy Panda CSS generated styles
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/styled-system ./apps/web/styled-system
|
||||
|
||||
# Copy server files (compiled from TypeScript)
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/server.js ./apps/web/
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/dist ./apps/web/dist
|
||||
@@ -56,6 +73,15 @@ COPY --from=builder --chown=nextjs:nodejs /app/apps/web/drizzle ./apps/web/drizz
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/node_modules ./apps/web/node_modules
|
||||
|
||||
# Copy core package (needed for Python flashcard generation scripts)
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/packages/core ./packages/core
|
||||
|
||||
# Copy templates package (needed for Typst templates)
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/packages/templates ./packages/templates
|
||||
|
||||
# Install Python dependencies for flashcard generation
|
||||
RUN pip3 install --no-cache-dir --break-system-packages -r packages/core/requirements.txt
|
||||
|
||||
# Copy package.json files for module resolution
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/package.json ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/package.json ./apps/web/
|
||||
|
||||
@@ -16,6 +16,7 @@ Following `docs/terminology-user-player-room.md`:
|
||||
- **PLAYER ROSTER** - All PLAYERS belonging to a USER (can have many)
|
||||
- **ACTIVE PLAYERS** - PLAYERS where `isActive = true` - these are the ones that actually participate in games
|
||||
- **ROOM MEMBER** - A USER's participation in a multiplayer room (tracked in `room_members` table)
|
||||
- **SPECTATOR** - A room member who watches another player's game without participating (see Spectator Mode section)
|
||||
|
||||
**Important:** A USER can have many PLAYERS in their roster, but only the ACTIVE PLAYERS (where `isActive = true`) participate in games. This enables "hot-potato" style local multiplayer where multiple people share the same device. This is LOCAL play (not networked), even though multiple PLAYERS participate.
|
||||
|
||||
@@ -27,25 +28,46 @@ In arcade sessions:
|
||||
|
||||
## Critical Architectural Requirements
|
||||
|
||||
### 1. Mode Isolation (MUST ENFORCE)
|
||||
### 1. Game Synchronization Modes
|
||||
|
||||
**Local Play** (`/arcade/[game-name]`)
|
||||
The arcade system supports three synchronization patterns:
|
||||
|
||||
#### Local Play (No Network Sync)
|
||||
**Route**: Custom route or dedicated local page
|
||||
**Use Case**: Practice, offline play, or games that should never be visible to others
|
||||
|
||||
- MUST NOT sync game state across the network
|
||||
- MUST NOT use room data, even if the USER is currently a member of an active room
|
||||
- MUST create isolated, per-USER game sessions
|
||||
- MUST pass `roomId: undefined` to `useArcadeSession`
|
||||
- Game state lives only in the current browser tab/session
|
||||
- CAN have multiple ACTIVE PLAYERS from the same USER (local multiplayer / hot-potato)
|
||||
- State is NOT shared across the network, only within the browser session
|
||||
|
||||
**Room-Based Play** (`/arcade/room`)
|
||||
#### Room-Based with Spectator Mode (RECOMMENDED PATTERN)
|
||||
**Route**: `/arcade/room` (or use room context anywhere)
|
||||
**Use Case**: Most arcade games - enables spectating even for single-player games
|
||||
|
||||
- MUST sync game state across all room members via network
|
||||
- MUST use the USER's current active room
|
||||
- MUST coordinate moves via server WebSocket
|
||||
- SYNCS game state across all room members via network
|
||||
- Uses the USER's current active room (`roomId: roomData?.id`)
|
||||
- Coordinates moves via server WebSocket
|
||||
- Game state is shared across all ACTIVE PLAYERS from all USERS in the room
|
||||
- When a PLAYER makes a move, all room members see it in real-time
|
||||
- CAN ALSO have multiple ACTIVE PLAYERS per USER (networked + local multiplayer combined)
|
||||
- **Non-playing room members become SPECTATORS** (see Spectator Mode section)
|
||||
- When a PLAYER makes a move, all room members see it in real-time (players + spectators)
|
||||
- CAN have multiple ACTIVE PLAYERS per USER (networked + local multiplayer combined)
|
||||
|
||||
**✅ This is the PREFERRED pattern** - even for single-player games like Card Sorting, because:
|
||||
- Enables spectator mode automatically
|
||||
- Creates social experience ("watch me solve this!")
|
||||
- No extra code needed
|
||||
- Works seamlessly with multiplayer games too
|
||||
|
||||
#### Pure Multiplayer (Room-Only)
|
||||
**Route**: `/arcade/room` with validation
|
||||
**Use Case**: Games that REQUIRE multiple players (e.g., competitive battles)
|
||||
|
||||
- Same as Room-Based with Spectator Mode
|
||||
- Plus: Validates minimum player count before starting
|
||||
- Plus: May prevent game start if `activePlayers.length < minPlayers`
|
||||
|
||||
### 2. Room ID Usage Rules
|
||||
|
||||
@@ -258,6 +280,327 @@ sendMove({
|
||||
- Mode: Room-based play (roomId: "room_xyz")
|
||||
- 5 total active PLAYERS across 2 devices, all synced over network
|
||||
|
||||
5. **Single-player game with spectators (Card Sorting):**
|
||||
- USER A: "guest_abc"
|
||||
- Active PLAYERS: ["player_001"]
|
||||
- Playing Card Sorting Challenge
|
||||
- USER B: "guest_def"
|
||||
- Active PLAYERS: [] (none selected)
|
||||
- Spectating USER A's game
|
||||
- USER C: "guest_ghi"
|
||||
- Active PLAYERS: ["player_005"]
|
||||
- Spectating USER A's game (could play after USER A finishes)
|
||||
- Mode: Room-based play (roomId: "room_xyz")
|
||||
- All room members see USER A's card placements in real-time
|
||||
- Spectators cannot interact with the game state
|
||||
|
||||
## Spectator Mode
|
||||
|
||||
### Overview
|
||||
|
||||
Spectator mode is automatically enabled when using room-based sync (`roomId: roomData?.id`). Any room member who is not actively playing becomes a spectator and can watch the game in real-time.
|
||||
|
||||
**Key Benefits**:
|
||||
- Creates social/collaborative experience even for single-player games
|
||||
- "Watch me solve this!" engagement
|
||||
- Learning by observation
|
||||
- Cheering/coaching opportunity
|
||||
- No extra implementation needed
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **Automatic Role Assignment**:
|
||||
- Room members with active PLAYERs in the game → **Players**
|
||||
- Room members without active PLAYERs in the game → **Spectators**
|
||||
|
||||
2. **State Synchronization**:
|
||||
- All game state updates broadcast to entire room via `game:${roomId}` socket room
|
||||
- Spectators receive same state updates as players
|
||||
- Spectators see game in real-time as it happens
|
||||
|
||||
3. **Interaction Control**:
|
||||
- Players can make moves (send move actions)
|
||||
- Spectators can only observe (no move actions permitted)
|
||||
- Server validates PLAYER ownership before accepting moves
|
||||
|
||||
### Implementation Pattern
|
||||
|
||||
**Provider** (Room-Based with Spectator Support):
|
||||
|
||||
```typescript
|
||||
export function CardSortingProvider({ children }: { children: ReactNode }) {
|
||||
const { data: viewerId } = useViewerId()
|
||||
const { roomData } = useRoomData() // ✅ Fetch room data
|
||||
const { activePlayers, players } = useGameMode()
|
||||
|
||||
// Find local player (if any)
|
||||
const localPlayerId = useMemo(() => {
|
||||
return Array.from(activePlayers).find((id) => {
|
||||
const player = players.get(id)
|
||||
return player?.isLocal !== false
|
||||
})
|
||||
}, [activePlayers, players])
|
||||
|
||||
const { state, sendMove, exitSession } = useArcadeSession<CardSortingState>({
|
||||
userId: viewerId || '',
|
||||
roomId: roomData?.id, // ✅ Enable spectator mode
|
||||
initialState,
|
||||
applyMove: applyMoveOptimistically,
|
||||
})
|
||||
|
||||
// Actions check if local player exists before allowing moves
|
||||
const startGame = useCallback(() => {
|
||||
if (!localPlayerId) {
|
||||
console.warn('[CardSorting] No local player - spectating only')
|
||||
return // ✅ Spectators cannot start game
|
||||
}
|
||||
sendMove({ type: 'START_GAME', playerId: localPlayerId, ... })
|
||||
}, [localPlayerId, sendMove])
|
||||
|
||||
// ... rest of provider
|
||||
}
|
||||
```
|
||||
|
||||
**Key Implementation Points**:
|
||||
- Always check `if (!localPlayerId)` before allowing moves
|
||||
- Return early or show "Spectating..." message
|
||||
- Don't throw errors - spectating is a valid state
|
||||
- UI should indicate spectator vs player role
|
||||
|
||||
### UI/UX Considerations
|
||||
|
||||
#### 1. Spectator Indicators
|
||||
|
||||
Show visual feedback when user is spectating:
|
||||
|
||||
```typescript
|
||||
{!localPlayerId && state.gamePhase === 'playing' && (
|
||||
<div className={spectatorBannerStyles}>
|
||||
👀 Spectating {state.playerMetadata.name}'s game
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
#### 2. Disabled Controls
|
||||
|
||||
Disable interactive elements for spectators:
|
||||
|
||||
```typescript
|
||||
<button
|
||||
onClick={placeCard}
|
||||
disabled={!localPlayerId} // Spectators can't interact
|
||||
className={css({
|
||||
opacity: !localPlayerId ? 0.5 : 1,
|
||||
cursor: !localPlayerId ? 'not-allowed' : 'pointer',
|
||||
})}
|
||||
>
|
||||
Place Card
|
||||
</button>
|
||||
```
|
||||
|
||||
#### 3. Join Prompt
|
||||
|
||||
For games that support multiple players, show "Join Game" option:
|
||||
|
||||
```typescript
|
||||
{!localPlayerId && state.gamePhase === 'setup' && (
|
||||
<button onClick={selectPlayerAndJoin}>
|
||||
Join as Player
|
||||
</button>
|
||||
)}
|
||||
```
|
||||
|
||||
#### 4. Real-Time Updates
|
||||
|
||||
Ensure spectators see smooth updates:
|
||||
- Use optimistic UI updates (same as players)
|
||||
- Show animations for state changes
|
||||
- Display current player's moves as they happen
|
||||
|
||||
### When to Use Spectator Mode
|
||||
|
||||
**✅ Use Spectator Mode (room-based sync) For**:
|
||||
- Single-player puzzle games (Card Sorting, Sudoku, etc.)
|
||||
- Turn-based competitive games (Matching Pairs Battle)
|
||||
- Cooperative games (Memory Lightning)
|
||||
- Any game where watching is educational/entertaining
|
||||
- Social/family game nights
|
||||
- Classroom settings (teacher demonstrates, students watch)
|
||||
|
||||
**❌ Avoid Spectator Mode (use local-only) For**:
|
||||
- Private practice sessions
|
||||
- Timed competitive games where watching gives unfair advantage
|
||||
- Games with personal/sensitive content
|
||||
- Offline/no-network scenarios
|
||||
- Performance-critical games (reduce network overhead)
|
||||
|
||||
### Example Scenarios
|
||||
|
||||
#### Scenario 1: Family Game Night - Card Sorting
|
||||
|
||||
```
|
||||
Room: "Smith Family Game Night"
|
||||
|
||||
USER A (Dad): Playing Card Sorting
|
||||
- Active PLAYER: "Dad 👨"
|
||||
- State: Placing cards, 6/8 complete
|
||||
- Can interact with game
|
||||
|
||||
USER B (Mom): Spectating
|
||||
- Active PLAYERS: [] (none selected)
|
||||
- State: Sees Dad's card placements in real-time
|
||||
- Cannot place cards
|
||||
- Can cheer and help
|
||||
|
||||
USER C (Kid): Spectating
|
||||
- Active PLAYER: "Emma 👧" (selected but not in this game)
|
||||
- State: Watching to learn strategy
|
||||
- Will play next round
|
||||
|
||||
Flow:
|
||||
1. Dad starts Card Sorting
|
||||
2. Mom and Kid see setup phase
|
||||
3. Dad places cards one by one
|
||||
4. Mom/Kid see each placement instantly
|
||||
5. Dad checks solution
|
||||
6. Everyone sees the score together
|
||||
7. Kid says "My turn!" and starts their own game
|
||||
8. Dad and Mom become spectators
|
||||
```
|
||||
|
||||
#### Scenario 2: Classroom - Memory Lightning
|
||||
|
||||
```
|
||||
Room: "Ms. Johnson's 3rd Grade"
|
||||
|
||||
USER A (Teacher): Playing cooperatively with 2 students
|
||||
- Active PLAYERS: ["Teacher 👩🏫", "Student 1 👦"]
|
||||
- State: Memorizing cards
|
||||
- Both can participate
|
||||
|
||||
USER B-F (5 other students): Spectating
|
||||
- Watching demonstration
|
||||
- Learning the rules
|
||||
- Will join next round
|
||||
|
||||
Flow:
|
||||
1. Teacher demonstrates with 2 students
|
||||
2. Other students watch and learn
|
||||
3. Round ends
|
||||
4. Teacher sets up new round
|
||||
5. New students join as players
|
||||
6. Previous players become spectators
|
||||
```
|
||||
|
||||
### Server-Side Handling
|
||||
|
||||
The server must handle spectators correctly:
|
||||
|
||||
```typescript
|
||||
// Validate move ownership
|
||||
socket.on('game-move', ({ move, roomId }) => {
|
||||
const session = getSession(roomId)
|
||||
|
||||
// Check if PLAYER making move is in the active players list
|
||||
if (!session.activePlayers.includes(move.playerId)) {
|
||||
return {
|
||||
error: 'PLAYER not in game - spectators cannot make moves'
|
||||
}
|
||||
}
|
||||
|
||||
// Check if USER owns this PLAYER
|
||||
const playerOwner = getPlayerOwner(move.playerId)
|
||||
if (playerOwner !== socket.userId) {
|
||||
return {
|
||||
error: 'USER does not own this PLAYER'
|
||||
}
|
||||
}
|
||||
|
||||
// Valid move - apply and broadcast
|
||||
const newState = validator.validateMove(session.state, move)
|
||||
io.to(`game:${roomId}`).emit('state-update', newState) // ALL room members get update
|
||||
})
|
||||
```
|
||||
|
||||
**Key Server Logic**:
|
||||
- Validate PLAYER is in `session.activePlayers`
|
||||
- Validate USER owns PLAYER
|
||||
- Broadcast to entire room (players + spectators)
|
||||
- Spectators receive updates but cannot send moves
|
||||
|
||||
### Testing Spectator Mode
|
||||
|
||||
```typescript
|
||||
describe('Spectator Mode', () => {
|
||||
it('should allow room members to spectate single-player games', () => {
|
||||
// Setup: USER A and USER B in same room
|
||||
// Action: USER A starts Card Sorting (single-player)
|
||||
// Assert: USER B receives game state updates
|
||||
// Assert: USER B cannot make moves
|
||||
// Assert: USER B sees USER A's card placements in real-time
|
||||
})
|
||||
|
||||
it('should prevent spectators from making moves', () => {
|
||||
// Setup: USER A playing, USER B spectating
|
||||
// Action: USER B attempts to place a card
|
||||
// Assert: Server rejects move (PLAYER not in activePlayers)
|
||||
// Assert: Client UI disables controls for USER B
|
||||
})
|
||||
|
||||
it('should show spectator indicator in UI', () => {
|
||||
// Setup: USER B spectating USER A's game
|
||||
// Assert: UI shows "Spectating [Player Name]" banner
|
||||
// Assert: Interactive controls are disabled
|
||||
// Assert: Game state is visible
|
||||
})
|
||||
|
||||
it('should allow spectator to join next round', () => {
|
||||
// Setup: USER B spectating USER A's Card Sorting game
|
||||
// Action: USER A finishes game, returns to setup
|
||||
// Action: USER B starts new game
|
||||
// Assert: USER A becomes spectator
|
||||
// Assert: USER B becomes active player
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Migration Path
|
||||
|
||||
**For existing games**:
|
||||
|
||||
If your game currently uses `roomId: roomData?.id`, it already supports spectator mode! You just need to:
|
||||
|
||||
1. ✅ Check for `!localPlayerId` before allowing moves
|
||||
2. ✅ Add spectator UI indicators
|
||||
3. ✅ Disable controls when spectating
|
||||
4. ✅ Test spectator experience
|
||||
|
||||
**Example Fix**:
|
||||
|
||||
```typescript
|
||||
// Before (will crash for spectators)
|
||||
const placeCard = useCallback((cardId, position) => {
|
||||
sendMove({
|
||||
type: 'PLACE_CARD',
|
||||
playerId: localPlayerId, // ❌ Will be undefined for spectators!
|
||||
...
|
||||
})
|
||||
}, [localPlayerId, sendMove])
|
||||
|
||||
// After (spectator-safe)
|
||||
const placeCard = useCallback((cardId, position) => {
|
||||
if (!localPlayerId) {
|
||||
console.warn('Spectators cannot place cards')
|
||||
return // ✅ Spectators blocked from moving
|
||||
}
|
||||
sendMove({
|
||||
type: 'PLACE_CARD',
|
||||
playerId: localPlayerId,
|
||||
...
|
||||
})
|
||||
}, [localPlayerId, sendMove])
|
||||
```
|
||||
|
||||
## Common Mistakes to Avoid
|
||||
|
||||
### Mistake 1: Conditional Room Usage
|
||||
|
||||
440
apps/web/.claude/ARCADE_ROUTING_ARCHITECTURE.md
Normal file
440
apps/web/.claude/ARCADE_ROUTING_ARCHITECTURE.md
Normal file
@@ -0,0 +1,440 @@
|
||||
# Arcade Routing Architecture - Complete Overview
|
||||
|
||||
## 1. Current /arcade Page
|
||||
|
||||
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/app/arcade/page.tsx` (lines 1-129)
|
||||
|
||||
**Purpose:** The main arcade landing page - displays the "Champion Arena"
|
||||
|
||||
**Key Components:**
|
||||
- `ArcadeContent()` - Renders the main arcade interface
|
||||
- Uses `EnhancedChampionArena` component which contains `GameSelector`
|
||||
- The `GameSelector` displays all available games as cards
|
||||
- `GameSelector` includes both legacy games and registry games
|
||||
|
||||
**Current Flow:**
|
||||
1. User navigates to `/arcade`
|
||||
2. Page renders `FullscreenProvider` wrapper
|
||||
3. Displays `PageWithNav` with title "🏟️ Champion Arena"
|
||||
4. Content area shows `EnhancedChampionArena` → `GameSelector`
|
||||
5. `GameSelector` renders `GameCard` components for each game
|
||||
6. When user clicks a game card, `GameCard` calls `router.push(config.url)`
|
||||
7. For registry games, `config.url` is `/arcade/room?game={gameName}`
|
||||
8. For legacy games, URL would be direct to their page
|
||||
|
||||
**State Management:**
|
||||
- `GameModeContext` provides player selection (emoji, name, color)
|
||||
- `PageWithNav` wraps content and provides mini-nav with:
|
||||
- Active player list
|
||||
- Add player button
|
||||
- Game mode indicator (single/battle/tournament)
|
||||
- Exit session handler
|
||||
|
||||
## 2. Current /arcade/room Page
|
||||
|
||||
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/app/arcade/room/page.tsx` (lines 1-359)
|
||||
|
||||
**Purpose:** "Magical place" that shows either a game OR the game chooser, driven by room state
|
||||
|
||||
**Three States:**
|
||||
|
||||
### State 1: Loading
|
||||
- Shows "Loading room..." message
|
||||
- Waits for `useRoomData()` hook to resolve
|
||||
|
||||
### State 2: Game Selection UI (when `!roomData.gameName`)
|
||||
- Shows large game selection buttons
|
||||
- User clicks to select a game
|
||||
- Calls `setRoomGame()` mutation to save selection to room
|
||||
- Invokes `handleGameSelect()` which:
|
||||
1. Checks if game exists in registry via `hasGame(gameType)`
|
||||
2. If registry game: calls `setRoomGame({roomId, gameName: gameType})`
|
||||
3. If legacy game: maps to internal name via `GAME_TYPE_TO_NAME`, then calls `setRoomGame()`
|
||||
4. Game selection is persisted to the room database
|
||||
|
||||
### State 3: Game Display (when `roomData.gameName` is set)
|
||||
- Checks game registry first via `hasGame(roomData.gameName)`
|
||||
- If registry game:
|
||||
- Gets game definition via `getGame(roomData.gameName)`
|
||||
- Renders: `<Provider><GameComponent /></Provider>`
|
||||
- Provider and GameComponent come from game registry definition
|
||||
- If legacy game:
|
||||
- Switch statement with TODO for individual games
|
||||
- Currently only shows "Game not yet supported"
|
||||
|
||||
**Key Hook:**
|
||||
- `useRoomData()` - Fetches current room from API and subscribes to socket updates
|
||||
- Returns `roomData` with fields: `id`, `name`, `code`, `gameName`, `gameConfig`, `members`, `memberPlayers`
|
||||
- Also returns `isLoading` boolean
|
||||
|
||||
**Navigation Flow:**
|
||||
1. User navigates to `/arcade`
|
||||
2. `GameCard` onClick calls `router.push('/arcade/room?game={gameName}')`
|
||||
3. User arrives at `/arcade/room`
|
||||
4. If NOT in a room yet: Shows error with link back to `/arcade`
|
||||
5. If in room but no game selected: Shows game selection UI
|
||||
6. If game selected: Loads and displays game
|
||||
|
||||
## 3. The "Mini App Nav" - GameContextNav Component
|
||||
|
||||
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/components/nav/GameContextNav.tsx` (lines 1-372)
|
||||
|
||||
**What It Is:**
|
||||
The "mini app nav" is actually a sophisticated component within the `PageWithNav` wrapper that intelligently shows different UI based on context:
|
||||
|
||||
**Components & Props:**
|
||||
- `navTitle` - Current page title (e.g., "Champion Arena", "Choose Game", "Speed Complement Race")
|
||||
- `navEmoji` - Icon emoji for current page
|
||||
- `gameMode` - Computed from active player count: 'none' | 'single' | 'battle' | 'tournament'
|
||||
- `activePlayers` - Array of selected players
|
||||
- `inactivePlayers` - Array of available but unselected players
|
||||
- `shouldEmphasize` - Boolean to emphasize player selection
|
||||
- `showFullscreenSelection` - Boolean to show fullscreen mode for player selection
|
||||
- `roomInfo` - Optional arcade room data (roomId, roomName, gameName, playerCount, joinCode)
|
||||
- `networkPlayers` - Remote players from room members
|
||||
|
||||
**Three Display Modes:**
|
||||
|
||||
### Mode 1: Fullscreen Player Selection
|
||||
- When `showFullscreenSelection === true`
|
||||
- Displays:
|
||||
- Large title with emoji
|
||||
- Game mode indicator
|
||||
- Fullscreen player selection UI
|
||||
- Shows all inactive players for selection
|
||||
|
||||
### Mode 2: Solo Mode (NOT in room)
|
||||
- When `roomInfo` is undefined
|
||||
- Shows:
|
||||
- **Game Title Section** (left side):
|
||||
- `GameTitleMenu` with game title and emoji
|
||||
- Menu options: Setup, New Game, Quit
|
||||
- `GameModeIndicator`
|
||||
- **Player Section** (right side):
|
||||
- `ActivePlayersList` - shows selected players
|
||||
- `AddPlayerButton` - add more players
|
||||
|
||||
### Mode 3: Room Mode (IN a room)
|
||||
- When `roomInfo` is defined
|
||||
- Shows:
|
||||
- **Hidden:** Game title section (display: none)
|
||||
- **Room Info Pane** (left side):
|
||||
- `RoomInfo` component with room details
|
||||
- Game mode indicator with color/emoji
|
||||
- Room name, player count, join code
|
||||
- `NetworkPlayerIndicator` components for remote players
|
||||
- **Player Section** (may be hidden):
|
||||
- Shows local active players
|
||||
- Add player button (for local players only)
|
||||
|
||||
**Key Sub-Components:**
|
||||
- `GameTitleMenu` - Menu for game options (setup, new game, quit)
|
||||
- `GameModeIndicator` - Shows 🎯 Solo, ⚔️ Battle, 🏆 Tournament, 👥 Select
|
||||
- `RoomInfo` - Displays room metadata
|
||||
- `NetworkPlayerIndicator` - Shows remote players with scores/streaks
|
||||
- `ActivePlayersList` - List of selected players
|
||||
- `AddPlayerButton` - Button to add more players with popover
|
||||
- `FullscreenPlayerSelection` - Large player picker for fullscreen mode
|
||||
- `PendingInvitations` - Banner for room invitations
|
||||
|
||||
**State Management:**
|
||||
- Lifted from `PageWithNav` to preserve state across remounts:
|
||||
- `showPopover` / `setShowPopover` - AddPlayerButton popover state
|
||||
- `activeTab` / `setActiveTab` - 'add' or 'invite' tab selection
|
||||
|
||||
## 4. Navigation Flow
|
||||
|
||||
### Flow 1: Solo Player → Game Selection → Room Creation → Game Start
|
||||
|
||||
```
|
||||
/arcade (Champion Arena)
|
||||
↓ [Select players - updates GameModeContext]
|
||||
↓ [Click game card - GameCard.onClick → router.push]
|
||||
/arcade/room (if not in room, shows game selector)
|
||||
↓ [Select game - calls setRoomGame mutation]
|
||||
↓ [Room created, gameName saved to roomData]
|
||||
↓ [useRoomData refetch updates roomData.gameName]
|
||||
/arcade/room (now displays the game)
|
||||
↓ [Game Provider and Component render]
|
||||
```
|
||||
|
||||
### Flow 2: Multiplayer - Room Invitation
|
||||
|
||||
```
|
||||
User A: Creates room via Champion Arena
|
||||
User B: Receives invitation
|
||||
User B: Joins room via /arcade/room
|
||||
User B: Sees same game selection (if set) or game selector (if not set)
|
||||
```
|
||||
|
||||
### Flow 3: Exit Game
|
||||
|
||||
```
|
||||
/arcade/room (in-game)
|
||||
↓ [Click "Quit" or "Exit Session" in GameContextNav]
|
||||
↓ [onExitSession callback → router.push('/arcade')]
|
||||
/arcade (back to champion arena)
|
||||
↓ Player selection reset by GameModeContext
|
||||
```
|
||||
|
||||
## 5. Game Chooser / Game Selection System
|
||||
|
||||
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/components/GameSelector.tsx` (lines 1-112)
|
||||
|
||||
**How It Works:**
|
||||
1. `GameSelector` component gets all games from both sources:
|
||||
- Legacy `GAMES_CONFIG` (currently empty)
|
||||
- Registry games via `getAllGames()`
|
||||
|
||||
2. For each game, creates `GameCard` component with configuration including `url` field
|
||||
|
||||
3. Game Cards rendered in 2-column grid (responsive)
|
||||
|
||||
4. When card clicked:
|
||||
- `GameCard` checks `activePlayerCount` against game's `maxPlayers`
|
||||
- If valid: calls `router.push(config.url)` - client-side navigation via Next.js
|
||||
- If invalid: blocks navigation with warning
|
||||
|
||||
**Two Game Systems:**
|
||||
|
||||
### Registry Games (NEW - Modular)
|
||||
- Location: `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/arcade-games/`
|
||||
- File: `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/lib/arcade/game-registry.ts`
|
||||
- Examples: `complement-race`, `memory-quiz`, `matching`
|
||||
- Each game has: `manifest` (metadata), `Provider` (context), `GameComponent` (UI)
|
||||
- Games registered globally via `registerGame()` function
|
||||
|
||||
### Legacy Games (OLD)
|
||||
- Location: Directly in `/app/arcade/` directory
|
||||
- Examples: `/app/arcade/complement-race/page.tsx`
|
||||
- Currently, only complement-race is partially migrated
|
||||
- Direct URL structure: `/arcade/{gameName}/page.tsx`
|
||||
|
||||
**Game Config Structure (for display):**
|
||||
```javascript
|
||||
{
|
||||
name: string, // Display name
|
||||
fullName?: string, // Longer name for detailed view
|
||||
description: string, // Short description
|
||||
longDescription?: string, // Detailed description
|
||||
icon: emoji, // Game icon emoji
|
||||
gradient: css gradient, // Background gradient
|
||||
borderColor: css color, // Border color for availability
|
||||
maxPlayers: number, // Player limit for validation
|
||||
chips?: string[], // Feature labels
|
||||
color?: 'green'|'purple'|'blue', // Color theme
|
||||
difficulty?: string, // Difficulty level
|
||||
available: boolean, // Is game available
|
||||
}
|
||||
```
|
||||
|
||||
## 6. Key Components Summary
|
||||
|
||||
### PageWithNav - Main Layout Wrapper
|
||||
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/components/PageWithNav.tsx` (lines 1-192)
|
||||
|
||||
**Responsibilities:**
|
||||
- Wraps all game/arcade pages
|
||||
- Manages GameContextNav state (mini-nav)
|
||||
- Handles player configuration dialog
|
||||
- Shows moderation notifications
|
||||
- Renders top navigation bar via `AppNavBar`
|
||||
|
||||
**Key Props:**
|
||||
- `navTitle` - Passed to GameContextNav
|
||||
- `navEmoji` - Passed to GameContextNav
|
||||
- `gameName` - Internal game name for API
|
||||
- `emphasizePlayerSelection` - Highlight player controls
|
||||
- `onExitSession` - Callback when user exits
|
||||
- `onSetup`, `onNewGame` - Game-specific callbacks
|
||||
- `children` - Page content
|
||||
|
||||
### AppNavBar - Top Navigation Bar
|
||||
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/components/AppNavBar.tsx` (lines 1-625)
|
||||
|
||||
**Variants:**
|
||||
- `full` - Standard navigation (default for non-game pages)
|
||||
- `minimal` - Game navigation (auto-selected for `/arcade` and `/games`)
|
||||
|
||||
**Minimal Nav Features:**
|
||||
- Hamburger menu (left) with:
|
||||
- Site navigation (Home, Create, Guide, Games)
|
||||
- Controls (Fullscreen, Exit Arcade)
|
||||
- Abacus style dropdown
|
||||
- Centered game context (navSlot)
|
||||
- Fullscreen indicator badge
|
||||
|
||||
### EnhancedChampionArena - Main Arcade Display
|
||||
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/components/EnhancedChampionArena.tsx` (lines 1-40)
|
||||
|
||||
**Responsibilities:**
|
||||
- Container for game selector
|
||||
- Full-height flex layout
|
||||
- Passes configuration to `GameSelector`
|
||||
|
||||
### GameSelector - Game Grid
|
||||
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/components/GameSelector.tsx` (lines 1-112)
|
||||
|
||||
**Responsibilities:**
|
||||
- Fetches all games from registry
|
||||
- Arranges in responsive grid
|
||||
- Shows header "🎮 Available Games"
|
||||
- Renders GameCard for each game
|
||||
|
||||
### GameCard - Individual Game Button
|
||||
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/components/GameCard.tsx` (lines 1-241)
|
||||
|
||||
**Responsibilities:**
|
||||
- Displays game with icon, name, description
|
||||
- Shows feature chips and player count indicator
|
||||
- Validates player count against game requirements
|
||||
- Handles click to navigate to game
|
||||
- Two variants: compact and detailed
|
||||
|
||||
## 7. State Management
|
||||
|
||||
### GameModeContext
|
||||
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/contexts/GameModeContext.tsx` (lines 1-325)
|
||||
|
||||
**Manages:**
|
||||
- Local players (Map<string, Player>)
|
||||
- Active players (Set<string>)
|
||||
- Game mode (computed from active player count)
|
||||
- Player CRUD operations (add, update, remove)
|
||||
|
||||
**Key Features:**
|
||||
- Fetches players from user's local DB via `useUserPlayers()`
|
||||
- Creates 4 default players if none exist
|
||||
- When in room: merges room members' players (marked as isLocal: false)
|
||||
- Syncs to room members via `notifyRoomOfPlayerUpdate()`
|
||||
|
||||
**Computed Values:**
|
||||
- `activePlayerCount` - Size of activePlayers set
|
||||
- `gameMode`:
|
||||
- 1 player → 'single'
|
||||
- 2 players → 'battle'
|
||||
- 3+ players → 'tournament'
|
||||
|
||||
### useRoomData Hook
|
||||
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/hooks/useRoomData.ts` (lines 1-450+)
|
||||
|
||||
**Manages:**
|
||||
- Current room fetching via TanStack Query
|
||||
- Socket.io real-time updates
|
||||
- Room state (members, players, game name)
|
||||
- Moderation events (kicked, banned, invitations)
|
||||
|
||||
**Key Operations:**
|
||||
- `fetchCurrentRoom()` - GET `/api/arcade/rooms/current`
|
||||
- `createRoomApi()` - POST `/api/arcade/rooms`
|
||||
- `joinRoomApi()` - POST `/api/arcade/rooms/{id}/join`
|
||||
- `leaveRoomApi()` - POST `/api/arcade/rooms/{id}/leave`
|
||||
- `setRoomGame()` - Updates room's gameName and gameConfig
|
||||
|
||||
**Socket Events:**
|
||||
- `join-user-channel` - Personal notifications
|
||||
- `join-room` - Subscribe to room updates
|
||||
- `room-joined` - Refresh when entering room
|
||||
- `member-joined` - When player joins
|
||||
- `member-left` - When player leaves
|
||||
- `room-players-updated` - When players change
|
||||
- Moderation events (kicked, banned, etc.)
|
||||
|
||||
## 8. Routing Summary
|
||||
|
||||
**Current URL Structure:**
|
||||
|
||||
```
|
||||
/ → Home page (Soroban Generator)
|
||||
/create → Create flashcards
|
||||
/guide → Tutorial guide
|
||||
/games → Games library (external game pages)
|
||||
/arcade → Champion Arena (main landing with game selector)
|
||||
/arcade/room → Active game display or game selection UI
|
||||
/arcade/room?game={name} → Query param for game selection (optional)
|
||||
/arcade/complement-race → OLD: Direct complement-race page (legacy)
|
||||
/arcade/complement-race/practice → Complement-race practice mode
|
||||
/arcade/complement-race/sprint → Complement-race sprint mode
|
||||
/arcade/complement-race/survival → Complement-race survival mode
|
||||
/arcade/memory-quiz → Memory quiz game page (legacy structure)
|
||||
```
|
||||
|
||||
**Query Parameters:**
|
||||
- `/arcade/room?game={gameName}` - Optional game selection (parsed by GameCard)
|
||||
|
||||
## 9. Key Differences: /arcade vs /arcade/room
|
||||
|
||||
| Aspect | /arcade | /arcade/room |
|
||||
|--------|---------|--------------|
|
||||
| **Purpose** | Game selection hub | Active game display or selection within room |
|
||||
| **Displays** | GameSelector with all games | Selected game OR game selector if no game in room |
|
||||
| **Room Context** | Optional (can start solo) | Usually in a room (fetches via useRoomData) |
|
||||
| **Navigation** | Click game → /arcade/room | Click game → Saves to room → Displays game |
|
||||
| **GameContextNav** | Shows player selector | Shows room info when joined |
|
||||
| **Player State** | Local only | Local + remote (room members) |
|
||||
| **Exit Button** | Usually hidden | Shows "Exit Session" to return to /arcade |
|
||||
| **Socket Connection** | Optional | Always connected (in room) |
|
||||
| **Page Transition** | User controls | Driven by room state updates |
|
||||
|
||||
## 10. Planning the Merge (/arcade/room → /arcade)
|
||||
|
||||
**Challenges to Consider:**
|
||||
|
||||
1. **URL Consolidation:**
|
||||
- `/arcade/room` would become a sub-path or handled by `/arcade` with state
|
||||
- Query param `?game={name}` could drive game selection
|
||||
- Current: `/arcade/room?game=complement-race`
|
||||
- Could become: `/arcade?game=complement-race&mode=play`
|
||||
|
||||
2. **Route Disambiguation:**
|
||||
- `/arcade` needs to handle: game selection display, game display, game loading
|
||||
- Same page different modes based on state
|
||||
- Or: Sub-routes like `/arcade/select`, `/arcade/play`
|
||||
|
||||
3. **State Layering:**
|
||||
- Local game mode (solo player, GameModeContext)
|
||||
- Room state (multiplayer, useRoomData)
|
||||
- Both need to coexist
|
||||
|
||||
4. **Navigation Preservation:**
|
||||
- Currently: `GameCard` → `router.push('/arcade/room?game=X')`
|
||||
- After merge: Would need new logic
|
||||
- Fullscreen state must persist (uses Next.js router, not reload)
|
||||
|
||||
5. **PageWithNav Behavior:**
|
||||
- Mini-nav shows game selection UI vs room info
|
||||
- Currently determined by `roomInfo` presence
|
||||
- After merge: Need same logic but one route
|
||||
|
||||
6. **Game Display:**
|
||||
- Currently: `/arcade/room` fetches game from registry
|
||||
- New: `/arcade` would need same game registry lookup
|
||||
- Game Provider/Component rendering must work identically
|
||||
|
||||
**Merge Strategy Options:**
|
||||
|
||||
### Option A: Single Route with Modes
|
||||
```
|
||||
/arcade
|
||||
├── Mode: browse (default, show GameSelector)
|
||||
├── Mode: select (game selected, show GameSelector for confirmation)
|
||||
└── Mode: play (in-game, show game display)
|
||||
```
|
||||
|
||||
### Option B: Sub-routes
|
||||
```
|
||||
/arcade
|
||||
├── /arcade (selector)
|
||||
├── /arcade/play (game display)
|
||||
└── /arcade/configure (player config)
|
||||
```
|
||||
|
||||
### Option C: Query-Parameter Driven
|
||||
```
|
||||
/arcade
|
||||
├── /arcade (default - selector)
|
||||
├── /arcade?game=X (game loading)
|
||||
└── /arcade?game=X&playing=true (in-game)
|
||||
```
|
||||
|
||||
**Recommendation:** Option C (Query-driven) is closest to current architecture and requires minimal changes to existing logic.
|
||||
404
apps/web/.claude/CARD_SORTING_AUDIT.md
Normal file
404
apps/web/.claude/CARD_SORTING_AUDIT.md
Normal file
@@ -0,0 +1,404 @@
|
||||
# Card Sorting Challenge - Arcade Architecture Audit
|
||||
|
||||
**Date**: 2025-10-18
|
||||
**Auditor**: Claude Code
|
||||
**Reference**: `.claude/ARCADE_ARCHITECTURE.md`
|
||||
**Update**: 2025-10-18 - Spectator mode recognized as intentional feature
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The Card Sorting Challenge game was audited against the Arcade Architecture requirements documented in `.claude/ARCADE_ARCHITECTURE.md`. The initial audit identified the room-based sync pattern as a potential issue, but this was later recognized as an **intentional spectator mode feature**.
|
||||
|
||||
**Overall Status**: ✅ **CORRECT IMPLEMENTATION** (with spectator mode enabled)
|
||||
|
||||
---
|
||||
|
||||
## Spectator Mode Feature (Initially Flagged as Issue)
|
||||
|
||||
### ✅ Room-Based Sync Enables Spectator Mode (INTENTIONAL FEATURE)
|
||||
|
||||
**Location**: `/src/arcade-games/card-sorting/Provider.tsx` lines 286, 312
|
||||
|
||||
**Initial Assessment**: The provider **ALWAYS** calls `useRoomData()` and **ALWAYS** passes `roomId: roomData?.id` to `useArcadeSession`. This was initially flagged as a mode isolation violation.
|
||||
|
||||
```typescript
|
||||
const { roomData } = useRoomData() // Line 286
|
||||
...
|
||||
const { state, sendMove, exitSession } = useArcadeSession<CardSortingState>({
|
||||
userId: viewerId || '',
|
||||
roomId: roomData?.id, // Line 312 - Room-based sync
|
||||
initialState: mergedInitialState,
|
||||
applyMove: applyMoveOptimistically,
|
||||
})
|
||||
```
|
||||
|
||||
**Actual Behavior (CORRECT)**:
|
||||
- ✅ When a USER plays Card Sorting in a room, the game state SYNCS ACROSS THE ROOM NETWORK
|
||||
- ✅ This enables **spectator mode** - other room members can watch the game in real-time
|
||||
- ✅ Card Sorting is single-player (`maxPlayers: 1`), but spectators can watch and cheer
|
||||
- ✅ Room members without active players become spectators automatically
|
||||
- ✅ Creates social/collaborative experience ("Watch me solve this!")
|
||||
|
||||
**Supported By Architecture** (ARCADE_ARCHITECTURE.md, Spectator Mode section):
|
||||
> Spectator mode is automatically enabled when using room-based sync (`roomId: roomData?.id`).
|
||||
> Any room member who is not actively playing becomes a spectator and can watch the game in real-time.
|
||||
>
|
||||
> **✅ This is the PREFERRED pattern** - even for single-player games like Card Sorting, because:
|
||||
> - Enables spectator mode automatically
|
||||
> - Creates social experience ("watch me solve this!")
|
||||
> - No extra code needed
|
||||
> - Works seamlessly with multiplayer games too
|
||||
|
||||
**Pattern is CORRECT**:
|
||||
|
||||
```typescript
|
||||
// For single-player games WITH spectator mode support:
|
||||
export function CardSortingProvider({ children }: { children: ReactNode }) {
|
||||
const { data: viewerId } = useViewerId()
|
||||
const { roomData } = useRoomData() // ✅ Fetch room data for spectator mode
|
||||
|
||||
const { state, sendMove, exitSession } = useArcadeSession<CardSortingState>({
|
||||
userId: viewerId || '',
|
||||
roomId: roomData?.id, // ✅ Enable spectator mode - room members can watch
|
||||
initialState: mergedInitialState,
|
||||
applyMove: applyMoveOptimistically,
|
||||
})
|
||||
|
||||
// Actions check for localPlayerId - spectators won't have one
|
||||
const startGame = useCallback(() => {
|
||||
if (!localPlayerId) {
|
||||
console.warn('[CardSorting] No local player - spectating only')
|
||||
return // ✅ Spectators blocked from starting game
|
||||
}
|
||||
// ... send move
|
||||
}, [localPlayerId, sendMove])
|
||||
}
|
||||
```
|
||||
|
||||
**Why This Pattern is Used**:
|
||||
This enables spectator mode as a first-class user experience. Room members can:
|
||||
- Watch other players solve puzzles
|
||||
- Learn strategies by observation
|
||||
- Cheer and coach
|
||||
- Take turns (finish watching, then play yourself)
|
||||
|
||||
**Status**: ✅ CORRECT IMPLEMENTATION
|
||||
**Priority**: N/A - No changes needed
|
||||
|
||||
---
|
||||
|
||||
## Scope of Spectator Mode
|
||||
|
||||
This same room-based sync pattern exists in **ALL** arcade games currently:
|
||||
|
||||
```bash
|
||||
$ grep -A 2 "useRoomData" /path/to/arcade-games/*/Provider.tsx
|
||||
|
||||
card-sorting/Provider.tsx: const { roomData } = useRoomData()
|
||||
complement-race/Provider.tsx: const { roomData } = useRoomData()
|
||||
matching/Provider.tsx: const { roomData } = useRoomData()
|
||||
memory-quiz/Provider.tsx: const { roomData } = useRoomData()
|
||||
```
|
||||
|
||||
All providers pass `roomId: roomData?.id` to `useArcadeSession`. This means:
|
||||
- ✅ **All games** support spectator mode automatically
|
||||
- ✅ **Single-player games** (card-sorting) enable "watch me play" experience
|
||||
- ✅ **Multiplayer games** (matching, memory-quiz, complement-race) support both players and spectators
|
||||
|
||||
**Status**: This is the recommended pattern for social/family gaming experiences.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Correct Implementations
|
||||
|
||||
### 1. Active Players Handling (CORRECT)
|
||||
|
||||
**Location**: `/src/arcade-games/card-sorting/Provider.tsx` lines 287, 294-299
|
||||
|
||||
The provider correctly uses `useGameMode()` to access active players:
|
||||
|
||||
```typescript
|
||||
const { activePlayers, players } = useGameMode()
|
||||
|
||||
const localPlayerId = useMemo(() => {
|
||||
return Array.from(activePlayers).find((id) => {
|
||||
const player = players.get(id)
|
||||
return player?.isLocal !== false
|
||||
})
|
||||
}, [activePlayers, players])
|
||||
```
|
||||
|
||||
✅ Only includes players with `isActive = true`
|
||||
✅ Finds the first local player for this single-player game
|
||||
✅ Follows architecture pattern correctly
|
||||
|
||||
---
|
||||
|
||||
### 2. Player ID vs User ID (CORRECT)
|
||||
|
||||
**Location**: Provider.tsx lines 383-491 (all move creators)
|
||||
|
||||
All moves correctly use:
|
||||
- `playerId: localPlayerId` (PLAYER makes the move)
|
||||
- `userId: viewerId || ''` (USER owns the session)
|
||||
|
||||
```typescript
|
||||
// Example from startGame (lines 383-391)
|
||||
sendMove({
|
||||
type: 'START_GAME',
|
||||
playerId: localPlayerId, // ✅ PLAYER ID
|
||||
userId: viewerId || '', // ✅ USER ID
|
||||
data: { playerMetadata, selectedCards },
|
||||
})
|
||||
```
|
||||
|
||||
✅ Follows USER/PLAYER distinction correctly
|
||||
✅ Server can validate PLAYER ownership
|
||||
✅ Matches architecture requirements
|
||||
|
||||
---
|
||||
|
||||
### 3. Validator Implementation (CORRECT)
|
||||
|
||||
**Location**: `/src/arcade-games/card-sorting/Validator.ts`
|
||||
|
||||
The validator correctly implements all required methods:
|
||||
|
||||
```typescript
|
||||
export class CardSortingValidator implements GameValidator<CardSortingState, CardSortingMove> {
|
||||
validateMove(state, move, context): ValidationResult { ... }
|
||||
isGameComplete(state): boolean { ... }
|
||||
getInitialState(config: CardSortingConfig): CardSortingState { ... }
|
||||
}
|
||||
```
|
||||
|
||||
✅ All move types have validation
|
||||
✅ `getInitialState()` accepts full config
|
||||
✅ Proper error messages
|
||||
✅ Server-side score calculation
|
||||
✅ State transitions validated
|
||||
|
||||
---
|
||||
|
||||
### 4. Game Registration (CORRECT)
|
||||
|
||||
**Location**: `/src/arcade-games/card-sorting/index.ts`
|
||||
|
||||
Uses the modular game system correctly:
|
||||
|
||||
```typescript
|
||||
export const cardSortingGame = defineGame<CardSortingConfig, CardSortingState, CardSortingMove>({
|
||||
manifest,
|
||||
Provider: CardSortingProvider,
|
||||
GameComponent,
|
||||
validator: cardSortingValidator,
|
||||
defaultConfig,
|
||||
validateConfig: validateCardSortingConfig,
|
||||
})
|
||||
```
|
||||
|
||||
✅ Proper TypeScript generics
|
||||
✅ Manifest includes all required fields
|
||||
✅ Config validation function provided
|
||||
✅ Uses `getGameTheme()` for consistent styling
|
||||
|
||||
---
|
||||
|
||||
### 5. Type Definitions (CORRECT)
|
||||
|
||||
**Location**: `/src/arcade-games/card-sorting/types.ts`
|
||||
|
||||
State and move types properly extend base types:
|
||||
|
||||
```typescript
|
||||
export interface CardSortingState extends GameState { ... }
|
||||
export interface CardSortingConfig extends GameConfig { ... }
|
||||
export type CardSortingMove =
|
||||
| { type: 'START_GAME', playerId: string, userId: string, ... }
|
||||
| { type: 'PLACE_CARD', playerId: string, userId: string, ... }
|
||||
...
|
||||
```
|
||||
|
||||
✅ All moves include `playerId` and `userId`
|
||||
✅ Extends SDK base types
|
||||
✅ Proper TypeScript structure
|
||||
|
||||
---
|
||||
|
||||
## Recommendations
|
||||
|
||||
### 1. Add Spectator UI Indicators (Enhancement)
|
||||
|
||||
The current implementation correctly enables spectator mode, but could be enhanced with better UI/UX:
|
||||
|
||||
**Action**: Add spectator indicators to `GameComponent.tsx`:
|
||||
|
||||
```typescript
|
||||
export function GameComponent() {
|
||||
const { state, localPlayerId } = useCardSorting()
|
||||
|
||||
return (
|
||||
<>
|
||||
{!localPlayerId && state.gamePhase === 'playing' && (
|
||||
<div className={spectatorBannerStyles}>
|
||||
👀 Spectating {state.playerMetadata?.name || 'player'}'s game
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Disable controls when spectating */}
|
||||
<button
|
||||
onClick={placeCard}
|
||||
disabled={!localPlayerId}
|
||||
className={css({
|
||||
opacity: !localPlayerId ? 0.5 : 1,
|
||||
cursor: !localPlayerId ? 'not-allowed' : 'pointer',
|
||||
})}
|
||||
>
|
||||
Place Card
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Also Consider**:
|
||||
- Show "Join Game" prompt during setup phase for spectators
|
||||
- Display spectator count ("2 people watching")
|
||||
- Add smooth real-time animations for spectators
|
||||
|
||||
---
|
||||
|
||||
### 2. Document Other Games
|
||||
|
||||
All arcade games currently support spectator mode. Consider documenting this in each game's README:
|
||||
|
||||
**Games with Spectator Mode**:
|
||||
- ✅ `card-sorting` - Single-player puzzle with spectators
|
||||
- ✅ `matching` - Multiplayer battle with spectators
|
||||
- ✅ `memory-quiz` - Cooperative with spectators
|
||||
- ✅ `complement-race` - Competitive with spectators
|
||||
|
||||
**Documentation to Add**:
|
||||
- How spectator mode works in each game
|
||||
- Example scenarios (family game night, classroom)
|
||||
- Best practices for spectator experience
|
||||
|
||||
---
|
||||
|
||||
### 3. Add Spectator Mode Tests
|
||||
|
||||
Following ARCADE_ARCHITECTURE.md Spectator Mode section, add tests:
|
||||
|
||||
```typescript
|
||||
describe('Card Sorting - Spectator Mode', () => {
|
||||
it('should sync state to spectators when USER plays in a room', async () => {
|
||||
// Setup: USER A and USER B in same room
|
||||
// Action: USER A plays Card Sorting
|
||||
// Assert: USER B (spectator) sees card placements in real-time
|
||||
// Assert: USER B cannot place cards (no localPlayerId)
|
||||
})
|
||||
|
||||
it('should prevent spectators from making moves', () => {
|
||||
// Setup: USER A playing, USER B spectating
|
||||
// Action: USER B attempts to place card
|
||||
// Assert: Action blocked (localPlayerId check)
|
||||
// Assert: Server rejects if somehow sent
|
||||
})
|
||||
|
||||
it('should allow spectator to play after current player finishes', () => {
|
||||
// Setup: USER A playing, USER B spectating
|
||||
// Action: USER A finishes, USER B starts new game
|
||||
// Assert: USER B becomes player
|
||||
// Assert: USER A becomes spectator
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Architecture Documentation
|
||||
|
||||
**✅ COMPLETED**: ARCADE_ARCHITECTURE.md has been updated with comprehensive spectator mode documentation:
|
||||
- Added "SPECTATOR" to core terminology
|
||||
- Documented three synchronization modes (Local, Room-Based with Spectator, Pure Multiplayer)
|
||||
- Complete "Spectator Mode" section with:
|
||||
- Implementation patterns
|
||||
- UI/UX considerations
|
||||
- Example scenarios (Family Game Night, Classroom)
|
||||
- Server-side validation
|
||||
- Testing requirements
|
||||
- Migration path
|
||||
|
||||
**No further documentation needed** - Card Sorting follows the recommended pattern
|
||||
|
||||
---
|
||||
|
||||
## Compliance Checklist
|
||||
|
||||
Based on ARCADE_ARCHITECTURE.md Spectator Mode Pattern:
|
||||
|
||||
- [x] ✅ **Provider uses room-based sync to enable spectator mode**
|
||||
- Calls `useRoomData()` and passes `roomId: roomData?.id`
|
||||
- [x] ✅ Provider uses `useGameMode()` to get active players
|
||||
- [x] ✅ Provider finds `localPlayerId` to distinguish player vs spectator
|
||||
- [x] ✅ Game components correctly use PLAYER IDs (not USER IDs) for moves
|
||||
- [x] ✅ Move actions check `localPlayerId` before sending
|
||||
- Spectators without `localPlayerId` cannot make moves
|
||||
- [x] ✅ Game supports multiple active PLAYERS from same USER
|
||||
- Implementation allows it (finds first local player)
|
||||
- [x] ✅ Inactive PLAYERS are never included in game sessions
|
||||
- Uses `activePlayers` which filters to `isActive = true`
|
||||
- [ ] ⚠️ **UI shows spectator indicator**
|
||||
- Could be enhanced (see Recommendations #1)
|
||||
- [ ] ⚠️ **UI disables controls for spectators**
|
||||
- Could be enhanced (see Recommendations #1)
|
||||
- [ ] ⚠️ Tests verify spectator mode
|
||||
- No tests found (see Recommendations #3)
|
||||
- [ ] ⚠️ Tests verify PLAYER ownership validation
|
||||
- No tests found
|
||||
- [x] ✅ Validator implements all required methods
|
||||
- [x] ✅ Game registered with modular system
|
||||
|
||||
**Overall Compliance**: 9/13 ✅ (Core features complete, enhancements recommended)
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
The Card Sorting Challenge game is **correctly implemented** with:
|
||||
- ✅ Active players (only `isActive = true` players participate)
|
||||
- ✅ Player ID vs User ID distinction
|
||||
- ✅ Validator pattern
|
||||
- ✅ Game registration
|
||||
- ✅ Type safety
|
||||
- ✅ **Spectator mode enabled** (room-based sync pattern)
|
||||
|
||||
**Architecture Pattern**: Room-Based with Spectator Mode (RECOMMENDED)
|
||||
|
||||
✅ **CORRECT**: Room sync enables spectator mode as a first-class feature
|
||||
|
||||
The `roomId: roomData?.id` pattern is **intentional and correct**:
|
||||
1. ✅ Enables spectator mode automatically
|
||||
2. ✅ Room members can watch games in real-time
|
||||
3. ✅ Creates social/collaborative experience
|
||||
4. ✅ Spectators blocked from making moves (via `localPlayerId` check)
|
||||
5. ✅ Follows ARCADE_ARCHITECTURE.md recommended pattern
|
||||
|
||||
**Recommended Enhancements** (not critical):
|
||||
1. Add spectator UI indicators ("👀 Spectating...")
|
||||
2. Disable controls visually for spectators
|
||||
3. Add spectator mode tests
|
||||
|
||||
**Priority**: LOW (enhancements only - core implementation is correct)
|
||||
|
||||
---
|
||||
|
||||
## Next Steps (Optional Enhancements)
|
||||
|
||||
1. ✅ **Architecture documentation** - COMPLETED (ARCADE_ARCHITECTURE.md updated with spectator mode)
|
||||
2. Add spectator UI indicators to GameComponent (banner, disabled controls)
|
||||
3. Add spectator mode tests
|
||||
4. Document spectator mode in other arcade games
|
||||
5. Consider adding spectator count display ("2 watching")
|
||||
|
||||
**Note**: Card Sorting is production-ready as-is. Enhancements are for improved UX only.
|
||||
1206
apps/web/.claude/CARD_SORTING_SPECTATOR_UX.md
Normal file
1206
apps/web/.claude/CARD_SORTING_SPECTATOR_UX.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -90,6 +90,93 @@ npm run check # Biome check (format + lint + organize imports)
|
||||
|
||||
**Remember: Always run `npm run pre-commit` before creating commits.**
|
||||
|
||||
## Styling Framework
|
||||
|
||||
**CRITICAL: This project uses Panda CSS, NOT Tailwind CSS.**
|
||||
|
||||
- All styling is done with Panda CSS (`@pandacss/dev`)
|
||||
- Configuration: `/panda.config.ts`
|
||||
- Generated system: `/styled-system/`
|
||||
- Import styles using: `import { css } from '../../styled-system/css'`
|
||||
- Token syntax: `color: 'blue.200'`, `borderColor: 'gray.300'`, etc.
|
||||
|
||||
**Common Mistakes to Avoid:**
|
||||
- ❌ Don't reference "Tailwind" in code, comments, or documentation
|
||||
- ❌ Don't use Tailwind utility classes (e.g., `className="bg-blue-500"`)
|
||||
- ✅ Use Panda CSS `css()` function for all styling
|
||||
- ✅ Use Panda's token system (defined in `panda.config.ts`)
|
||||
|
||||
**Color Tokens:**
|
||||
```typescript
|
||||
// Correct (Panda CSS)
|
||||
css({
|
||||
bg: 'blue.200',
|
||||
borderColor: 'gray.300',
|
||||
color: 'brand.600'
|
||||
})
|
||||
|
||||
// Incorrect (Tailwind)
|
||||
className="bg-blue-200 border-gray-300 text-brand-600"
|
||||
```
|
||||
|
||||
See `.claude/GAME_THEMES.md` for standardized color theme usage in arcade games.
|
||||
|
||||
## Abacus Visualizations
|
||||
|
||||
**CRITICAL: This project uses @soroban/abacus-react for all abacus visualizations.**
|
||||
|
||||
- All abacus displays MUST use components from `@soroban/abacus-react`
|
||||
- Package location: `packages/abacus-react`
|
||||
- Main components: `AbacusReact`, `useAbacusConfig`, `useAbacusDisplay`
|
||||
- DO NOT create custom abacus visualizations
|
||||
- DO NOT manually draw abacus columns, beads, or bars
|
||||
|
||||
**Common Mistakes to Avoid:**
|
||||
- ❌ Don't create custom abacus components or SVGs
|
||||
- ❌ Don't manually render abacus beads or columns
|
||||
- ✅ Always use `AbacusReact` from `@soroban/abacus-react`
|
||||
- ✅ Use `useAbacusConfig` for abacus configuration
|
||||
- ✅ Use `useAbacusDisplay` for reading abacus state
|
||||
|
||||
**MANDATORY: Read the Docs Before Customizing**
|
||||
|
||||
**ALWAYS read the full README documentation before customizing or styling AbacusReact:**
|
||||
- Location: `packages/abacus-react/README.md`
|
||||
- Check homepage implementation: `src/app/page.tsx` (MiniAbacus component)
|
||||
- Check storybook examples: `src/stories/AbacusReact.*.stories.tsx`
|
||||
|
||||
**Key Documentation Points:**
|
||||
1. **Custom Styles**: Use `fill` (not just `stroke`) for columnPosts and reckoningBar
|
||||
2. **Props**: Use direct props like `value`, `columns`, `scaleFactor` (not config objects)
|
||||
3. **Example from Homepage:**
|
||||
```typescript
|
||||
const darkStyles = {
|
||||
columnPosts: {
|
||||
fill: 'rgba(255, 255, 255, 0.3)',
|
||||
stroke: 'rgba(255, 255, 255, 0.2)',
|
||||
strokeWidth: 2,
|
||||
},
|
||||
reckoningBar: {
|
||||
fill: 'rgba(255, 255, 255, 0.4)',
|
||||
stroke: 'rgba(255, 255, 255, 0.25)',
|
||||
strokeWidth: 3,
|
||||
},
|
||||
}
|
||||
|
||||
<AbacusReact
|
||||
value={123}
|
||||
columns={3}
|
||||
customStyles={darkStyles}
|
||||
/>
|
||||
```
|
||||
|
||||
**Example Usage:**
|
||||
```typescript
|
||||
import { AbacusReact } from '@soroban/abacus-react'
|
||||
|
||||
<AbacusReact value={123} columns={5} scaleFactor={1.5} showNumbers={true} />
|
||||
```
|
||||
|
||||
## Known Issues
|
||||
|
||||
### @soroban/abacus-react TypeScript Module Resolution
|
||||
|
||||
935
apps/web/.claude/EDUCATION_ROADMAP.md
Normal file
935
apps/web/.claude/EDUCATION_ROADMAP.md
Normal file
@@ -0,0 +1,935 @@
|
||||
# Soroban Abacus Education Platform - Comprehensive Roadmap
|
||||
|
||||
## Vision Statement
|
||||
|
||||
**Mission:** Fill the gap in the USA school system by providing a complete, self-directed abacus curriculum that trains students from beginner to mastery using the Japanese kyu/dan ranking system.
|
||||
|
||||
**Target Users:**
|
||||
- Primary: Elementary school students (ages 6-12)
|
||||
- Secondary: Middle school students and adult learners
|
||||
- Teachers/Parents: Dashboard for monitoring progress
|
||||
|
||||
**Core Experience Principles:**
|
||||
1. **Integrated Learning Loop:** Tutorial → Practice → Play → Assessment → Progress
|
||||
2. **Self-Directed:** Simple enough for kids to fire up and start learning independently
|
||||
3. **Gamified Progression:** Games reinforce lessons, feel like play but teach skills
|
||||
4. **Physical + Virtual:** Support both physical abacus and AbacusReact component
|
||||
5. **Mastery-Based:** Students advance through clear skill levels with certification
|
||||
|
||||
---
|
||||
|
||||
## Current State Assessment
|
||||
|
||||
### ✅ What We Have (Well-Built)
|
||||
|
||||
**1. Interactive Abacus Component (AbacusReact)**
|
||||
- Highly polished, production-ready
|
||||
- Excellent pedagogical features (bead highlighting, direction arrows, tooltips)
|
||||
- Multiple color schemes and accessibility options
|
||||
- Interactive and display-only modes
|
||||
- **Rating: 95% Complete**
|
||||
|
||||
**2. Game System (4 Games)**
|
||||
- Memory Lightning (memorization skills)
|
||||
- Matching Pairs Battle (pattern recognition, complements)
|
||||
- Card Sorting (visual literacy, ordering)
|
||||
- Complement Race (speed calculation, friends-of-5/10)
|
||||
- Type-safe architecture with game SDK
|
||||
- Multiplayer and spectator modes
|
||||
- **Rating: 80% Complete** (games exist but need curriculum integration)
|
||||
|
||||
**3. Tutorial Infrastructure**
|
||||
- Tutorial player with step-based guidance
|
||||
- Tutorial editor for content creation
|
||||
- Bead highlighting system for instruction
|
||||
- Event tracking and progress monitoring
|
||||
- **Rating: 70% Complete** (infrastructure exists but lacks content)
|
||||
|
||||
**4. Real-time Multiplayer**
|
||||
- Socket.IO integration
|
||||
- Room-based architecture
|
||||
- State synchronization
|
||||
- **Rating: 90% Complete**
|
||||
|
||||
**5. Flashcard Generator**
|
||||
- PDF/PNG/SVG export
|
||||
- Customizable layouts and themes
|
||||
- **Rating: 100% Complete**
|
||||
|
||||
### ⚠️ What We Have (Partially Built)
|
||||
|
||||
**1. Progress Tracking**
|
||||
- Basic user stats (games played, wins, accuracy)
|
||||
- No skill-level tracking
|
||||
- No tutorial completion tracking
|
||||
- No assessment history
|
||||
- **Rating: 30% Complete**
|
||||
|
||||
**2. Tutorial Content**
|
||||
- One example tutorial (GuidedAdditionTutorial)
|
||||
- Type system for tutorials defined
|
||||
- No comprehensive curriculum
|
||||
- **Rating: 15% Complete**
|
||||
|
||||
**3. Assessment System**
|
||||
- Per-game scoring exists
|
||||
- Achievement system exists
|
||||
- No formal tests or certification
|
||||
- No placement tests
|
||||
- **Rating: 25% Complete**
|
||||
|
||||
### ❌ What We're Missing (Critical Gaps)
|
||||
|
||||
**1. Kyu/Dan Ranking System** - 0% Complete
|
||||
**2. Structured Curriculum** - 5% Complete
|
||||
**3. Adaptive Learning** - 0% Complete
|
||||
**4. Student Dashboard** - 0% Complete
|
||||
**5. Teacher/Parent Dashboard** - 0% Complete
|
||||
**6. Formal Assessment/Testing** - 0% Complete
|
||||
**7. Learning Path Sequencing** - 0% Complete
|
||||
**8. Content Library** - 10% Complete
|
||||
|
||||
---
|
||||
|
||||
## Kyu/Dan Level System (Japanese Abacus Standard)
|
||||
|
||||
### Beginner Levels (Kyu)
|
||||
|
||||
**10 Kyu - "First Steps"**
|
||||
- Age: 6-7 years
|
||||
- Skills: Basic bead manipulation, numbers 1-10
|
||||
- Curriculum: Recognize and set numbers on abacus, understand place value
|
||||
- Assessment: Set numbers 1-99 correctly, basic addition single digits
|
||||
- Games: Card Sorting (visual recognition), Memory Lightning (basic)
|
||||
|
||||
**9 Kyu - "Number Explorer"**
|
||||
- Skills: Addition/subtraction with no carry (1-9)
|
||||
- Curriculum: Friends of 5 concept introduction
|
||||
- Assessment: 20 problems, 2-digit addition/subtraction, no carry, 80% accuracy
|
||||
- Games: Complement Race (practice mode), Matching Pairs (numerals)
|
||||
|
||||
**8 Kyu - "Complement Apprentice"**
|
||||
- Skills: Friends of 5 mastery, introduction to friends of 10
|
||||
- Curriculum: All combinations that make 5, carry concepts
|
||||
- Assessment: 30 problems including carries using friends of 5, 85% accuracy
|
||||
- Games: Complement Race (friends-5 sprint), Matching Pairs (complement pairs)
|
||||
|
||||
**7 Kyu - "Addition Warrior"**
|
||||
- Skills: Friends of 10 mastery, 2-digit addition/subtraction with carries
|
||||
- Curriculum: All combinations that make 10, mixed complement strategies
|
||||
- Assessment: 40 problems, 2-3 digit calculations, mixed operations, 85% accuracy
|
||||
- Games: Complement Race (friends-10 sprint), All games at medium difficulty
|
||||
|
||||
**6 Kyu - "Speed Calculator"**
|
||||
- Skills: Multi-digit addition/subtraction (3-4 digits), speed emphasis
|
||||
- Curriculum: Chain calculations, mental imagery beginning
|
||||
- Assessment: 50 problems, 3-4 digits, 3 minutes time limit, 90% accuracy
|
||||
- Games: Complement Race (survival mode), Memory Lightning (medium)
|
||||
|
||||
**5 Kyu - "Multiplication Initiate"**
|
||||
- Skills: Single-digit multiplication (1-5)
|
||||
- Curriculum: Multiplication tables 1-5, abacus multiplication method
|
||||
- Assessment: 30 multiplication problems, 40 add/subtract problems, 90% accuracy
|
||||
- Games: All games at hard difficulty
|
||||
|
||||
**4 Kyu - "Multiplication Master"**
|
||||
- Skills: Full multiplication tables (1-9), 2-digit × 1-digit
|
||||
- Curriculum: All multiplication patterns, division introduction
|
||||
- Assessment: 40 multiplication, 20 division, 40 add/subtract, 90% accuracy
|
||||
|
||||
**3 Kyu - "Division Explorer"**
|
||||
- Skills: Division mastery (2-digit ÷ 1-digit), mixed operations
|
||||
- Curriculum: Division algorithm, remainders, mixed problem solving
|
||||
- Assessment: 100 mixed problems in 10 minutes, 92% accuracy
|
||||
|
||||
**2 Kyu - "Advanced Operator"**
|
||||
- Skills: Multi-digit multiplication/division, decimals introduction
|
||||
- Curriculum: 3-digit × 2-digit, decimals, percentages
|
||||
- Assessment: 120 mixed problems including decimals, 10 minutes, 93% accuracy
|
||||
|
||||
**1 Kyu - "Pre-Mastery"**
|
||||
- Skills: Decimal operations, fractions, complex multi-step problems
|
||||
- Curriculum: Real-world applications, word problems
|
||||
- Assessment: 150 mixed problems, 10 minutes, 95% accuracy
|
||||
- Mental calculation ability without physical abacus
|
||||
|
||||
### Master Levels (Dan)
|
||||
|
||||
**1 Dan - "Shodan" (First Degree)**
|
||||
- Skills: Mental imagery without abacus, complex calculations
|
||||
- Assessment: 200 mixed problems, 10 minutes, 96% accuracy
|
||||
- Mental arithmetic certification
|
||||
|
||||
**2 Dan - "Nidan"**
|
||||
- Skills: Advanced mental calculation, speed competitions
|
||||
- Assessment: 250 problems, 10 minutes, 97% accuracy
|
||||
|
||||
**3 Dan - "Sandan"**
|
||||
- Skills: Championship-level speed and accuracy
|
||||
- Assessment: 300 problems, 10 minutes, 98% accuracy
|
||||
|
||||
**4-10 Dan** - Expert/Master levels with increasing complexity
|
||||
|
||||
---
|
||||
|
||||
## Integrated Learning Experience Design
|
||||
|
||||
### The Core Loop (Per Skill/Concept)
|
||||
|
||||
```
|
||||
1. ASSESS → Placement test determines current level
|
||||
2. LEARN → Tutorial teaches new concept
|
||||
3. PRACTICE → Guided exercises with immediate feedback
|
||||
4. PLAY → Games reinforce the skill in fun context
|
||||
5. TEST → Formal assessment for mastery certification
|
||||
6. ADVANCE → Unlock next level, update progress
|
||||
```
|
||||
|
||||
### Example: Teaching "Friends of 5"
|
||||
|
||||
**1. Assessment (Placement)**
|
||||
- Quick quiz: "Can you add 3 + 4 using the abacus?"
|
||||
- Result: Student struggles → Assign Friends of 5 tutorial
|
||||
|
||||
**2. Learn (Tutorial)**
|
||||
- Interactive tutorial: "Friends of 5"
|
||||
- Steps:
|
||||
1. Show that 5 = 1+4, 2+3, 3+2, 4+1
|
||||
2. Demonstrate on abacus: setting 3, adding 2 to make 5
|
||||
3. Explain heaven bead (top) = 5, earth beads (bottom) = 1 each
|
||||
4. Interactive: Student sets 3, adds 2 using heaven bead
|
||||
5. Practice all combinations
|
||||
|
||||
**3. Practice (Structured Exercises)**
|
||||
- 20 problems: Set number, add its friend
|
||||
- Real-time feedback on bead movements
|
||||
- Hints available: "Use the heaven bead!"
|
||||
- Must achieve 90% accuracy to proceed
|
||||
|
||||
**4. Play (Game Reinforcement)**
|
||||
- Complement Race: Friends-5 mode
|
||||
- Matching Pairs: Match numbers that make 5
|
||||
- Makes practice feel like play
|
||||
|
||||
**5. Test (Formal Assessment)**
|
||||
- 30 problems mixing friends-5 with previous skills
|
||||
- Timed: 5 minutes
|
||||
- Must achieve 85% to certify skill
|
||||
- Can retake after reviewing mistakes
|
||||
|
||||
**6. Advance (Progress Update)**
|
||||
- Friends of 5 skill marked as "Mastered"
|
||||
- Unlock: Friends of 10 tutorial
|
||||
- Update skill matrix
|
||||
- Award achievement badge
|
||||
|
||||
---
|
||||
|
||||
## Detailed Curriculum Structure
|
||||
|
||||
### Curriculum Database Schema
|
||||
|
||||
```typescript
|
||||
// Skill taxonomy
|
||||
enum SkillCategory {
|
||||
NUMBER_SENSE = 'number-sense',
|
||||
ADDITION = 'addition',
|
||||
SUBTRACTION = 'subtraction',
|
||||
MULTIPLICATION = 'multiplication',
|
||||
DIVISION = 'division',
|
||||
MENTAL_CALC = 'mental-calculation',
|
||||
COMPLEMENTS = 'complements',
|
||||
SPEED = 'speed',
|
||||
ACCURACY = 'accuracy'
|
||||
}
|
||||
|
||||
// Individual skill (atomic unit)
|
||||
interface Skill {
|
||||
id: string
|
||||
name: string
|
||||
category: SkillCategory
|
||||
kyuLevel: number // Which kyu level this skill belongs to
|
||||
prerequisiteSkills: string[] // Must master these first
|
||||
description: string
|
||||
estimatedPracticeTime: number // minutes
|
||||
}
|
||||
|
||||
// Learning module (collection of related skills)
|
||||
interface Module {
|
||||
id: string
|
||||
title: string
|
||||
kyuLevel: number
|
||||
description: string
|
||||
skills: string[] // Skill IDs
|
||||
estimatedCompletionTime: number // hours
|
||||
sequence: number // Order within kyu level
|
||||
}
|
||||
|
||||
// Tutorial (teaches one or more skills)
|
||||
interface Tutorial {
|
||||
id: string
|
||||
skillIds: string[]
|
||||
moduleId: string
|
||||
type: 'interactive' | 'video' | 'reading'
|
||||
content: TutorialStep[]
|
||||
estimatedDuration: number
|
||||
}
|
||||
|
||||
// Practice set (reinforces skills)
|
||||
interface PracticeSet {
|
||||
id: string
|
||||
skillIds: string[]
|
||||
problemCount: number
|
||||
timeLimit?: number
|
||||
passingAccuracy: number
|
||||
difficulty: 'easy' | 'medium' | 'hard'
|
||||
}
|
||||
|
||||
// Game mapping (which games teach which skills)
|
||||
interface GameSkillMapping {
|
||||
gameId: string
|
||||
skillIds: string[]
|
||||
difficulty: string
|
||||
recommendedKyuRange: [number, number]
|
||||
}
|
||||
|
||||
// Assessment (formal test)
|
||||
interface Assessment {
|
||||
id: string
|
||||
type: 'placement' | 'skill-check' | 'kyu-certification'
|
||||
kyuLevel?: number
|
||||
skillIds: string[]
|
||||
problemCount: number
|
||||
timeLimit: number
|
||||
passingAccuracy: number
|
||||
}
|
||||
```
|
||||
|
||||
### Sample Curriculum Map
|
||||
|
||||
**10 Kyu Module Sequence:**
|
||||
|
||||
1. **Module 1: "Introduction to Abacus" (Week 1)**
|
||||
- Skill: Understand abacus structure
|
||||
- Skill: Recognize place values (ones, tens, hundreds)
|
||||
- Tutorial: "What is an Abacus?"
|
||||
- Tutorial: "Parts of the Abacus"
|
||||
- Practice: Set numbers 1-10
|
||||
- Game: Card Sorting (visual recognition)
|
||||
|
||||
2. **Module 2: "Setting Numbers" (Week 2)**
|
||||
- Skill: Set single-digit numbers (1-9)
|
||||
- Skill: Set two-digit numbers (10-99)
|
||||
- Tutorial: "Setting Numbers on Abacus"
|
||||
- Practice: 50 number-setting exercises
|
||||
- Game: Memory Lightning (set and remember)
|
||||
|
||||
3. **Module 3: "Basic Addition" (Week 3-4)**
|
||||
- Skill: Add single digits without carry (1+1 through 4+4)
|
||||
- Tutorial: "Simple Addition"
|
||||
- Practice: 100 addition problems
|
||||
- Game: Complement Race (practice mode)
|
||||
- Assessment: 10 Kyu Certification Test
|
||||
|
||||
**9 Kyu Module Sequence:**
|
||||
|
||||
1. **Module 4: "Friends of 5 - Introduction" (Week 5)**
|
||||
- Skill: Recognize pairs that make 5
|
||||
- Skill: Add using heaven bead (5 bead)
|
||||
- Tutorial: "Friends of 5 - Part 1"
|
||||
- Practice: Pattern recognition exercises
|
||||
- Game: Matching Pairs (complement mode)
|
||||
|
||||
2. **Module 5: "Friends of 5 - Application" (Week 6-7)**
|
||||
- Skill: Add crossing 5 (e.g., 3+4, 2+5)
|
||||
- Tutorial: "Friends of 5 - Part 2"
|
||||
- Practice: 200 problems with friends of 5
|
||||
- Game: Complement Race (friends-5 mode)
|
||||
- Assessment: 9 Kyu Certification Test
|
||||
|
||||
... (Continue through all kyu levels)
|
||||
|
||||
---
|
||||
|
||||
## Implementation Roadmap
|
||||
|
||||
### Phase 1: Foundation (Months 1-3) - "MVP for 10-9 Kyu"
|
||||
|
||||
**Goal:** Students can learn and certify 10 Kyu and 9 Kyu levels
|
||||
|
||||
**Database Schema Updates:**
|
||||
- [ ] Create `skills` table
|
||||
- [ ] Create `modules` table
|
||||
- [ ] Create `curriculum_tutorials` table (links tutorials to skills)
|
||||
- [ ] Create `curriculum_practice_sets` table
|
||||
- [ ] Create `curriculum_assessments` table
|
||||
- [ ] Create `user_progress` table
|
||||
- Fields: userId, skillId, status (not_started, in_progress, mastered), attempts, bestScore, lastAttemptAt
|
||||
- [ ] Create `user_skill_history` table (track all practice attempts)
|
||||
- [ ] Create `user_assessments` table (formal test results)
|
||||
- [ ] Create `user_kyu_levels` table
|
||||
- Fields: userId, currentKyu, currentDan, certifiedAt, expiresAt
|
||||
- [ ] Extend `user_stats` table: add `currentKyuLevel`, `currentDanLevel`, `skillsMastered`
|
||||
|
||||
**Tutorial Content Creation:**
|
||||
- [ ] 10 Kyu tutorials (5 tutorials):
|
||||
1. Introduction to Abacus
|
||||
2. Understanding Place Value
|
||||
3. Setting Numbers 1-99
|
||||
4. Basic Addition (single digit, no carry)
|
||||
5. Basic Subtraction (single digit, no borrow)
|
||||
- [ ] 9 Kyu tutorials (3 tutorials):
|
||||
1. Friends of 5 - Concept
|
||||
2. Friends of 5 - Addition
|
||||
3. Friends of 5 - Subtraction
|
||||
|
||||
**Practice Sets:**
|
||||
- [ ] Build practice set generator for each skill
|
||||
- [ ] Implement immediate feedback system
|
||||
- [ ] Add hint system for common mistakes
|
||||
- [ ] Track accuracy and time per problem
|
||||
|
||||
**Assessment System:**
|
||||
- [ ] Build placement test component (determines starting level)
|
||||
- [ ] Build skill-check test component (practice test before certification)
|
||||
- [ ] Build kyu certification test component (formal test)
|
||||
- [ ] Implement grading engine
|
||||
- [ ] Create detailed results/feedback page
|
||||
- [ ] Allow test retakes with review of mistakes
|
||||
|
||||
**Game Integration:**
|
||||
- [ ] Map existing games to skills
|
||||
- Memory Lightning → Number recognition, memory
|
||||
- Card Sorting → Visual pattern recognition, ordering
|
||||
- Matching Pairs → Complements, pattern matching
|
||||
- Complement Race → Friends-5, Friends-10, speed
|
||||
- [ ] Add skill-based game recommendations
|
||||
- [ ] Track game performance per skill
|
||||
|
||||
**Student Dashboard:**
|
||||
- [ ] Create dashboard showing:
|
||||
- Current kyu level
|
||||
- Skills mastered / in progress / locked
|
||||
- Next recommended activity
|
||||
- Recent achievements
|
||||
- Progress bar toward next kyu level
|
||||
- [ ] Implement simple, kid-friendly UI
|
||||
- [ ] Add celebratory animations for milestones
|
||||
|
||||
**Core User Flow:**
|
||||
- [ ] Onboarding: Placement test → Assign kyu level
|
||||
- [ ] Home: Dashboard shows next recommended activity
|
||||
- [ ] Click "Start Learning" → Next tutorial
|
||||
- [ ] Complete tutorial → Practice exercises
|
||||
- [ ] Complete practice → Game suggestion
|
||||
- [ ] Master all module skills → Unlock certification test
|
||||
- [ ] Pass certification → Advance to next kyu level
|
||||
- [ ] Celebration and badge award
|
||||
|
||||
**Deliverables:**
|
||||
- Students can complete 10 Kyu and 9 Kyu
|
||||
- ~8 tutorials
|
||||
- ~10 skills defined
|
||||
- Placement test + 2 certification tests
|
||||
- Student dashboard
|
||||
- Progress tracking fully functional
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Core Curriculum (Months 4-8) - "8 Kyu through 5 Kyu"
|
||||
|
||||
**Goal:** Complete beginner curriculum through multiplication introduction
|
||||
|
||||
**Content Creation:**
|
||||
- [ ] 8 Kyu: Friends of 10 tutorials and practice (4 weeks)
|
||||
- [ ] 7 Kyu: Mixed complements, 2-digit operations (4 weeks)
|
||||
- [ ] 6 Kyu: Multi-digit, speed training (6 weeks)
|
||||
- [ ] 5 Kyu: Multiplication introduction, tables 1-5 (8 weeks)
|
||||
- Total: ~40 tutorials, ~30 skills
|
||||
|
||||
**Enhanced Features:**
|
||||
- [ ] Adaptive difficulty in practice sets (adjusts based on performance)
|
||||
- [ ] Spaced repetition system (review mastered skills periodically)
|
||||
- [ ] Daily recommended practice (10-15 min sessions)
|
||||
- [ ] Streaks and habit formation
|
||||
- [ ] Peer comparison (anonymous, optional)
|
||||
|
||||
**New Games:**
|
||||
- [ ] Multiplication tables game
|
||||
- [ ] Speed drill game (flash calculation)
|
||||
- [ ] Mental math game (visualization without physical abacus)
|
||||
|
||||
**Parent/Teacher Dashboard:**
|
||||
- [ ] View student progress
|
||||
- [ ] See time spent learning
|
||||
- [ ] Review test results
|
||||
- [ ] Assign specific modules or skills
|
||||
- [ ] Generate progress reports
|
||||
|
||||
**Gamification Enhancements:**
|
||||
- [ ] Achievement badges for milestones
|
||||
- [ ] Experience points (XP) system
|
||||
- [ ] Level-up animations
|
||||
- [ ] Customizable avatars (unlocked via achievements)
|
||||
- [ ] Virtual rewards (stickers, themes)
|
||||
|
||||
**Deliverables:**
|
||||
- Complete 8-5 Kyu curriculum
|
||||
- ~50 total tutorials (cumulative)
|
||||
- ~40 total skills (cumulative)
|
||||
- Parent/teacher dashboard
|
||||
- 2-3 new games
|
||||
- Enhanced gamification
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Advanced Skills (Months 9-14) - "4 Kyu through 1 Kyu"
|
||||
|
||||
**Goal:** Advanced operations, real-world applications, mental calculation
|
||||
|
||||
**Content Creation:**
|
||||
- [ ] 4 Kyu: Full multiplication, division introduction (8 weeks)
|
||||
- [ ] 3 Kyu: Division mastery, mixed operations (8 weeks)
|
||||
- [ ] 2 Kyu: Decimals, percentages (10 weeks)
|
||||
- [ ] 1 Kyu: Fractions, word problems, mental calculation (12 weeks)
|
||||
- Total: ~60 additional tutorials, ~40 additional skills
|
||||
|
||||
**Mental Calculation Training:**
|
||||
- [ ] Visualization exercises (see abacus in mind)
|
||||
- [ ] Flash anzan (rapid mental calculation)
|
||||
- [ ] Mental calculation games
|
||||
- [ ] Transition from physical to mental abacus
|
||||
|
||||
**Real-World Applications:**
|
||||
- [ ] Shopping math (money, change, discounts)
|
||||
- [ ] Measurement conversions
|
||||
- [ ] Time calculations
|
||||
- [ ] Real-world word problems
|
||||
|
||||
**Competition Features:**
|
||||
- [ ] Speed competitions (leaderboards)
|
||||
- [ ] Accuracy challenges
|
||||
- [ ] Weekly tournaments
|
||||
- [ ] Regional/global rankings (optional)
|
||||
|
||||
**AI Tutor Assistant:**
|
||||
- [ ] Smart hints during practice
|
||||
- [ ] Personalized learning paths
|
||||
- [ ] Concept explanations on demand
|
||||
- [ ] Answer specific questions ("Why do I use friends of 5 here?")
|
||||
|
||||
**Deliverables:**
|
||||
- Complete 4-1 Kyu curriculum
|
||||
- ~110 total tutorials (cumulative)
|
||||
- ~80 total skills (cumulative)
|
||||
- Mental calculation training
|
||||
- AI assistant
|
||||
- Competition system
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: Mastery Levels (Months 15-18) - "Dan Levels"
|
||||
|
||||
**Goal:** Championship-level speed and accuracy, mental calculation mastery
|
||||
|
||||
**Content Creation:**
|
||||
- [ ] Dan level certification tests
|
||||
- [ ] Advanced mental calculation curriculum
|
||||
- [ ] Championship preparation materials
|
||||
- [ ] Expert-level problem sets
|
||||
|
||||
**Advanced Features:**
|
||||
- [ ] Customized training plans for dan levels
|
||||
- [ ] Video lessons from expert abacus users
|
||||
- [ ] Community forum for advanced learners
|
||||
- [ ] Virtual competitions
|
||||
- [ ] Certification/diploma generation (printable)
|
||||
|
||||
**Integration with Standards:**
|
||||
- [ ] Align with League of Soroban of Americas standards
|
||||
- [ ] Japan Abacus Committee certification mapping
|
||||
- [ ] International competition preparation
|
||||
|
||||
**Deliverables:**
|
||||
- 1-10 Dan curriculum
|
||||
- Certification system
|
||||
- Community features
|
||||
- Championship training
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: Ecosystem (Months 18+) - "Complete Platform"
|
||||
|
||||
**Content Management System:**
|
||||
- [ ] Tutorial builder UI (create without code)
|
||||
- [ ] Content versioning
|
||||
- [ ] Community-contributed content (vetted)
|
||||
- [ ] Multilingual support (Spanish, Japanese, Hindi)
|
||||
|
||||
**Classroom Features:**
|
||||
- [ ] Teacher creates classes
|
||||
- [ ] Bulk student enrollment
|
||||
- [ ] Class-wide assignments
|
||||
- [ ] Class leaderboards
|
||||
- [ ] Live teaching mode (project for class)
|
||||
|
||||
**Analytics & Insights:**
|
||||
- [ ] Student learning velocity
|
||||
- [ ] Skill gap analysis
|
||||
- [ ] Predictive success modeling
|
||||
- [ ] Recommendations engine
|
||||
- [ ] Export data for research
|
||||
|
||||
**Mobile App:**
|
||||
- [ ] iOS and React Native apps
|
||||
- [ ] Offline mode
|
||||
- [ ] Sync across devices
|
||||
|
||||
**Integrations:**
|
||||
- [ ] Google Classroom
|
||||
- [ ] Canvas LMS
|
||||
- [ ] Schoology
|
||||
- [ ] Export to SIS systems
|
||||
|
||||
**Advanced Gamification:**
|
||||
- [ ] Story mode (learning quest)
|
||||
- [ ] Cooperative challenges
|
||||
- [ ] Guild/team system
|
||||
- [ ] Seasonal events
|
||||
|
||||
---
|
||||
|
||||
## Success Metrics
|
||||
|
||||
### Student Engagement
|
||||
- **Daily Active Users (DAU):** Target 40% of registered students
|
||||
- **Weekly Active Users (WAU):** Target 70% of registered students
|
||||
- **Average session time:** 20-30 minutes
|
||||
- **Completion rate per module:** >80%
|
||||
- **Retention (30-day):** >60%
|
||||
- **Streak length:** Average 7+ days
|
||||
|
||||
### Learning Outcomes
|
||||
- **Certification pass rate:** >70% on first attempt per kyu level
|
||||
- **Skill mastery rate:** >85% accuracy on mastered skills after 30 days
|
||||
- **Time to mastery:** Track average time per kyu level
|
||||
- **Progression velocity:** Students advance 1 kyu level per 4-8 weeks (varies by level)
|
||||
|
||||
### Content Quality
|
||||
- **Tutorial completion rate:** >90%
|
||||
- **Practice set completion rate:** >85%
|
||||
- **Game play rate:** >60% of students play games weekly
|
||||
- **Assessment completion rate:** >75%
|
||||
|
||||
### Platform Health
|
||||
- **System uptime:** >99.5%
|
||||
- **Load time:** <2 seconds
|
||||
- **Error rate:** <0.1%
|
||||
|
||||
### Business/Growth
|
||||
- **Monthly signups:** Track growth month-over-month
|
||||
- **Paid conversion** (if applicable): Target 10-20%
|
||||
- **Teacher/school adoption:** Track institutional users
|
||||
- **Net Promoter Score (NPS):** Target >50
|
||||
|
||||
---
|
||||
|
||||
## Technical Architecture Changes
|
||||
|
||||
### Database Changes Priority
|
||||
|
||||
**Immediate (Phase 1):**
|
||||
```sql
|
||||
-- Skills and curriculum structure
|
||||
CREATE TABLE skills (...)
|
||||
CREATE TABLE modules (...)
|
||||
CREATE TABLE skill_prerequisites (...)
|
||||
|
||||
-- Tutorial and practice content
|
||||
CREATE TABLE tutorial_content (...)
|
||||
CREATE TABLE practice_sets (...)
|
||||
CREATE TABLE assessments (...)
|
||||
|
||||
-- User progress tracking
|
||||
CREATE TABLE user_progress (...)
|
||||
CREATE TABLE user_skill_history (...)
|
||||
CREATE TABLE user_assessments (...)
|
||||
CREATE TABLE user_kyu_levels (...)
|
||||
|
||||
-- Game-skill mapping
|
||||
CREATE TABLE game_skill_mappings (...)
|
||||
```
|
||||
|
||||
**Phase 2:**
|
||||
- Add spaced repetition tables
|
||||
- Achievement tracking enhancements
|
||||
- Peer comparison data
|
||||
|
||||
**Phase 3:**
|
||||
- Mental calculation tracking
|
||||
- Competition results
|
||||
- AI tutor interaction logs
|
||||
|
||||
### API Endpoints Needed
|
||||
|
||||
**Progress & Skills:**
|
||||
- `GET /api/student/progress` - Current kyu level, skills, next steps
|
||||
- `GET /api/student/skills/:skillId` - Skill details and progress
|
||||
- `POST /api/student/skills/:skillId/practice` - Record practice attempt
|
||||
- `GET /api/student/dashboard` - Dashboard data
|
||||
|
||||
**Curriculum:**
|
||||
- `GET /api/curriculum/kyu/:level` - All modules for kyu level
|
||||
- `GET /api/curriculum/modules/:moduleId` - Module details
|
||||
- `GET /api/curriculum/tutorials/:tutorialId` - Tutorial content
|
||||
- `GET /api/curriculum/next` - Next recommended activity
|
||||
|
||||
**Assessments:**
|
||||
- `POST /api/assessments/placement` - Take placement test
|
||||
- `POST /api/assessments/skill-check/:skillId` - Practice test
|
||||
- `POST /api/assessments/certification/:kyuLevel` - Certification test
|
||||
- `POST /api/assessments/:assessmentId/submit` - Submit answers
|
||||
- `GET /api/assessments/:assessmentId/results` - Get results
|
||||
|
||||
**Games:**
|
||||
- `GET /api/games/recommended` - Games for current skills
|
||||
- `POST /api/games/:gameId/result` - Log game completion
|
||||
- `GET /api/games/:gameId/skills` - Which skills this game teaches
|
||||
|
||||
**Teacher/Parent:**
|
||||
- `GET /api/teacher/students` - List of students
|
||||
- `GET /api/teacher/students/:studentId/progress` - Student progress
|
||||
- `POST /api/teacher/assignments` - Create assignment
|
||||
|
||||
### Component Architecture
|
||||
|
||||
**New Components Needed:**
|
||||
|
||||
```
|
||||
/src/components/curriculum/
|
||||
- SkillCard.tsx - Display skill with progress
|
||||
- ModuleCard.tsx - Module overview with skills
|
||||
- CurriculumMap.tsx - Visual map of curriculum
|
||||
- SkillTree.tsx - Dependency graph visualization
|
||||
|
||||
/src/components/practice/
|
||||
- PracticeSession.tsx - Practice exercise UI
|
||||
- ProblemDisplay.tsx - Show problem to solve
|
||||
- AnswerInput.tsx - Accept answer (with abacus)
|
||||
- FeedbackDisplay.tsx - Show correctness and hints
|
||||
|
||||
/src/components/assessment/
|
||||
- PlacementTest.tsx - Initial assessment
|
||||
- SkillCheckTest.tsx - Practice test
|
||||
- CertificationTest.tsx - Formal kyu test
|
||||
- TestResults.tsx - Detailed results page
|
||||
|
||||
/src/components/dashboard/
|
||||
- StudentDashboard.tsx - Main dashboard
|
||||
- ProgressOverview.tsx - Current level and progress
|
||||
- NextActivity.tsx - Recommended next step
|
||||
- AchievementShowcase.tsx - Badges and milestones
|
||||
- ActivityFeed.tsx - Recent activity
|
||||
|
||||
/src/components/teacher/
|
||||
- TeacherDashboard.tsx
|
||||
- StudentRoster.tsx
|
||||
- StudentDetail.tsx
|
||||
- AssignmentCreator.tsx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Content Creation Process
|
||||
|
||||
### Tutorial Creation Workflow
|
||||
|
||||
1. **Define Skill:** What specific skill does this teach?
|
||||
2. **Outline Steps:** Break down into 5-10 learning steps
|
||||
3. **Create Interactive Elements:**
|
||||
- Which beads to highlight
|
||||
- What movements to demonstrate
|
||||
- Example problems
|
||||
4. **Add Explanations:** Clear, kid-friendly language
|
||||
5. **Test with Students:** Iterate based on confusion points
|
||||
6. **Publish:** Add to curriculum map
|
||||
|
||||
### Tutorial Template
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: "friends-of-5-intro",
|
||||
title: "Friends of 5 - Introduction",
|
||||
skillIds: ["friends-5-recognition"],
|
||||
kyuLevel: 9,
|
||||
estimatedDuration: 15,
|
||||
steps: [
|
||||
{
|
||||
instruction: "Let's learn about friends of 5! These are pairs of numbers that add up to 5.",
|
||||
problem: null,
|
||||
highlighting: [],
|
||||
explanation: "When you add friends together, they always make 5!"
|
||||
},
|
||||
{
|
||||
instruction: "1 and 4 are friends! See how 1 + 4 = 5?",
|
||||
problem: { operation: 'add', terms: [1, 4] },
|
||||
highlighting: [
|
||||
{ column: 0, value: 1, direction: 'activate' },
|
||||
{ column: 0, value: 4, direction: 'up', step: 2 }
|
||||
],
|
||||
explanation: "We set 1 earth bead, then add 4 more by using the heaven bead (5) and removing 1."
|
||||
},
|
||||
// ... more steps
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Practice Set Template
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: "friends-5-practice-1",
|
||||
skillIds: ["friends-5-recognition", "friends-5-addition"],
|
||||
problemCount: 20,
|
||||
timeLimit: 300, // 5 minutes
|
||||
passingAccuracy: 0.85,
|
||||
problemGenerator: {
|
||||
type: 'addition',
|
||||
numberRange: [1, 9],
|
||||
requiresFriends5: true,
|
||||
maxTerms: 2
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Structure for Curriculum
|
||||
|
||||
```
|
||||
/apps/web/src/curriculum/
|
||||
/schema/
|
||||
- skills.ts (skill definitions)
|
||||
- modules.ts (module definitions)
|
||||
- assessments.ts (test definitions)
|
||||
|
||||
/content/
|
||||
/10-kyu/
|
||||
- module-1-intro.ts
|
||||
- module-2-setting-numbers.ts
|
||||
- module-3-basic-addition.ts
|
||||
/9-kyu/
|
||||
- module-4-friends-5-intro.ts
|
||||
- module-5-friends-5-application.ts
|
||||
/8-kyu/
|
||||
... and so on
|
||||
|
||||
/tutorials/
|
||||
/10-kyu/
|
||||
- intro-to-abacus.ts
|
||||
- place-value.ts
|
||||
- setting-numbers.ts
|
||||
- basic-addition.ts
|
||||
- basic-subtraction.ts
|
||||
/9-kyu/
|
||||
- friends-5-concept.ts
|
||||
- friends-5-addition.ts
|
||||
- friends-5-subtraction.ts
|
||||
... and so on
|
||||
|
||||
/practice/
|
||||
/10-kyu/
|
||||
- number-setting-practice.ts
|
||||
- basic-addition-practice.ts
|
||||
/9-kyu/
|
||||
- friends-5-practice.ts
|
||||
... and so on
|
||||
|
||||
/assessments/
|
||||
- placement-test.ts
|
||||
- 10-kyu-certification.ts
|
||||
- 9-kyu-certification.ts
|
||||
... and so on
|
||||
|
||||
- curriculum-map.ts (master curriculum definition)
|
||||
- game-skill-mappings.ts (which games teach which skills)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Next Immediate Steps
|
||||
|
||||
### Week 1: Database Schema Design
|
||||
- [ ] Design complete schema for Phase 1
|
||||
- [ ] Write migration scripts
|
||||
- [ ] Document schema decisions
|
||||
- [ ] Review with stakeholders
|
||||
|
||||
### Week 2-3: Content Planning
|
||||
- [ ] Write detailed 10 Kyu curriculum outline
|
||||
- [ ] Write detailed 9 Kyu curriculum outline
|
||||
- [ ] Define all skills for 10-9 Kyu
|
||||
- [ ] Map skills to existing games
|
||||
|
||||
### Week 4-5: Tutorial Content Creation
|
||||
- [ ] Write 5 tutorials for 10 Kyu
|
||||
- [ ] Write 3 tutorials for 9 Kyu
|
||||
- [ ] Create interactive steps with highlighting
|
||||
- [ ] Add kid-friendly explanations
|
||||
|
||||
### Week 6-7: Assessment System Build
|
||||
- [ ] Build assessment component UI
|
||||
- [ ] Implement grading engine
|
||||
- [ ] Create placement test (20 problems)
|
||||
- [ ] Create 10 Kyu certification test (30 problems)
|
||||
- [ ] Create 9 Kyu certification test (40 problems)
|
||||
|
||||
### Week 8-9: Practice System
|
||||
- [ ] Build practice session component
|
||||
- [ ] Implement problem generator for each skill
|
||||
- [ ] Add immediate feedback system
|
||||
- [ ] Create hint system
|
||||
|
||||
### Week 10-11: Student Dashboard
|
||||
- [ ] Design dashboard UI (kid-friendly)
|
||||
- [ ] Build progress visualization
|
||||
- [ ] Implement "next recommended activity" logic
|
||||
- [ ] Add achievement display
|
||||
|
||||
### Week 12: Integration & Testing
|
||||
- [ ] Connect all pieces: tutorials → practice → games → assessment
|
||||
- [ ] Test complete user flow
|
||||
- [ ] User testing with kids
|
||||
- [ ] Iterate based on feedback
|
||||
|
||||
---
|
||||
|
||||
## Questions to Resolve
|
||||
|
||||
1. **Certification Validity:** Should kyu certifications expire? (Traditional abacus schools: no expiration)
|
||||
2. **Retake Policy:** How many times can student retake certification test? (Suggest: unlimited, but must wait 24 hours)
|
||||
3. **Grading Standards:** Strict adherence to Japanese standards or adjust for USA context?
|
||||
4. **Physical Abacus:** Should we require physical abacus for certain levels? (Recommend: optional but encouraged)
|
||||
5. **Age Restrictions:** Any minimum age? (Suggest: 6+ with parent/teacher supervision)
|
||||
6. **Teacher Accounts:** Free for teachers? (Recommend: yes, free for teachers)
|
||||
7. **Pricing Model:** Free tier + premium? School licensing? (TBD)
|
||||
8. **Content Licensing:** Will curriculum be open source or proprietary? (Recommend: proprietary but allow teacher customization)
|
||||
9. **Accessibility:** WCAG compliance level? (Recommend: AA minimum)
|
||||
10. **Data Privacy:** COPPA compliance for users under 13? (Required: yes, must be compliant)
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
This roadmap provides a clear path from current state (scattered features) to target state (complete educational platform). The phased approach allows incremental delivery while maintaining focus on core learning experience.
|
||||
|
||||
**Estimated Timeline:**
|
||||
- Phase 1 (10-9 Kyu MVP): 3 months
|
||||
- Phase 2 (8-5 Kyu): 5 months
|
||||
- Phase 3 (4-1 Kyu): 6 months
|
||||
- Phase 4 (Dan levels): 3 months
|
||||
- Phase 5 (Ecosystem): Ongoing
|
||||
|
||||
**Total to Complete Platform:** ~17 months for core curriculum, then continuous improvement
|
||||
|
||||
**Priority:** Start with Phase 1 to prove the concept, get student feedback, and validate the learning loop before building the full system.
|
||||
154
apps/web/.claude/GAME_THEMES.md
Normal file
154
apps/web/.claude/GAME_THEMES.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# Game Theme Standardization
|
||||
|
||||
## Problem
|
||||
|
||||
Previously, each game manually specified `color`, `gradient`, and `borderColor` in their manifest. This led to:
|
||||
- Inconsistent appearance across game cards
|
||||
- No guidance on what colors/gradients to use
|
||||
- Easy to choose saturated colors that don't match the pastel style
|
||||
- Duplication and maintenance burden
|
||||
|
||||
## Solution
|
||||
|
||||
**Standard theme presets** in `/src/lib/arcade/game-themes.ts`
|
||||
|
||||
All games now use predefined color themes that ensure consistent, professional appearance.
|
||||
|
||||
## Usage
|
||||
|
||||
### 1. Import from the Game SDK
|
||||
|
||||
```typescript
|
||||
import { defineGame, getGameTheme } from '@/lib/arcade/game-sdk'
|
||||
import type { GameManifest } from '@/lib/arcade/game-sdk'
|
||||
```
|
||||
|
||||
### 2. Use the Theme Spread Operator
|
||||
|
||||
```typescript
|
||||
const manifest: GameManifest = {
|
||||
name: 'my-game',
|
||||
displayName: 'My Awesome Game',
|
||||
icon: '🎮',
|
||||
description: 'A fun game',
|
||||
longDescription: 'More details...',
|
||||
maxPlayers: 4,
|
||||
difficulty: 'Intermediate',
|
||||
chips: ['🎯 Feature 1', '⚡ Feature 2'],
|
||||
...getGameTheme('blue'), // ← Just add this!
|
||||
available: true,
|
||||
}
|
||||
```
|
||||
|
||||
That's it! The theme automatically provides:
|
||||
- `color: 'blue'`
|
||||
- `gradient: 'linear-gradient(135deg, #dbeafe, #bfdbfe)'`
|
||||
- `borderColor: 'blue.200'`
|
||||
|
||||
## Available Themes
|
||||
|
||||
All themes use Panda CSS's 100-200 color range for soft pastel appearance:
|
||||
|
||||
| Theme | Color Range | Use Case |
|
||||
|-------|-------------|----------|
|
||||
| `blue` | blue-100 to blue-200 | Memory, puzzle games |
|
||||
| `purple` | purple-100 to purple-200 | Strategic, battle games |
|
||||
| `green` | green-100 to green-200 | Growth, achievement games |
|
||||
| `teal` | teal-100 to teal-200 | Creative, sorting games |
|
||||
| `indigo` | indigo-100 to indigo-200 | Deep thinking games |
|
||||
| `pink` | pink-100 to pink-200 | Fun, casual games |
|
||||
| `orange` | orange-100 to orange-200 | Speed, energy games |
|
||||
| `yellow` | yellow-100 to yellow-200 | Bright, happy games |
|
||||
| `red` | red-100 to red-200 | Competition, challenge |
|
||||
| `gray` | gray-100 to gray-200 | Neutral games |
|
||||
|
||||
## Examples
|
||||
|
||||
### Current Games
|
||||
|
||||
```typescript
|
||||
// Memory Lightning - blue theme
|
||||
...getGameTheme('blue')
|
||||
|
||||
// Matching Pairs Battle - purple theme
|
||||
...getGameTheme('purple')
|
||||
|
||||
// Card Sorting Challenge - teal theme
|
||||
...getGameTheme('teal')
|
||||
|
||||
// Speed Complement Race - blue theme
|
||||
...getGameTheme('blue')
|
||||
```
|
||||
|
||||
## Benefits
|
||||
|
||||
✅ **Consistency** - All games have the same professional pastel look
|
||||
✅ **Simple** - One line instead of three properties
|
||||
✅ **Maintainable** - Update all games by changing the theme definition
|
||||
✅ **Discoverable** - TypeScript autocomplete shows available themes
|
||||
✅ **No mistakes** - Can't accidentally use wrong color values
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
If you need to inspect or customize a theme:
|
||||
|
||||
```typescript
|
||||
import { GAME_THEMES } from '@/lib/arcade/game-sdk'
|
||||
import type { GameTheme } from '@/lib/arcade/game-sdk'
|
||||
|
||||
// Access a specific theme
|
||||
const blueTheme: GameTheme = GAME_THEMES.blue
|
||||
|
||||
// Use it
|
||||
const manifest: GameManifest = {
|
||||
// ... other fields
|
||||
...blueTheme,
|
||||
// Or customize:
|
||||
color: blueTheme.color,
|
||||
gradient: 'linear-gradient(135deg, #custom, #values)', // override
|
||||
borderColor: blueTheme.borderColor,
|
||||
}
|
||||
```
|
||||
|
||||
## Adding New Themes
|
||||
|
||||
To add a new theme, edit `/src/lib/arcade/game-themes.ts`:
|
||||
|
||||
```typescript
|
||||
export const GAME_THEMES = {
|
||||
// ... existing themes
|
||||
mycolor: {
|
||||
color: 'mycolor',
|
||||
gradient: 'linear-gradient(135deg, #lighter, #darker)', // Use Panda CSS 100-200 range
|
||||
borderColor: 'mycolor.200',
|
||||
},
|
||||
} as const satisfies Record<string, GameTheme>
|
||||
```
|
||||
|
||||
Then update the TypeScript type:
|
||||
```typescript
|
||||
export type GameThemeName = keyof typeof GAME_THEMES
|
||||
```
|
||||
|
||||
## Migration Checklist
|
||||
|
||||
When creating a new game:
|
||||
|
||||
- [x] Import `getGameTheme` from `@/lib/arcade/game-sdk`
|
||||
- [x] Use `...getGameTheme('theme-name')` in manifest
|
||||
- [x] Remove manual `color`, `gradient`, `borderColor` properties
|
||||
- [x] Choose a theme that matches your game's vibe
|
||||
|
||||
## Summary
|
||||
|
||||
**Old way** (error-prone, inconsistent):
|
||||
```typescript
|
||||
color: 'teal',
|
||||
gradient: 'linear-gradient(135deg, #99f6e4, #5eead4)', // Too saturated!
|
||||
borderColor: 'teal.200',
|
||||
```
|
||||
|
||||
**New way** (simple, consistent):
|
||||
```typescript
|
||||
...getGameTheme('teal')
|
||||
```
|
||||
76
apps/web/.claude/PANDA_CSS_DYNAMIC_TOKENS.md
Normal file
76
apps/web/.claude/PANDA_CSS_DYNAMIC_TOKENS.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# Panda CSS Dynamic Token Usage
|
||||
|
||||
## Problem: Dynamic Color Tokens Not Working
|
||||
|
||||
When using Panda CSS, color tokens like `blue.400`, `purple.400`, etc. don't work when used dynamically through variables in the `css()` function.
|
||||
|
||||
## Root Cause
|
||||
|
||||
Panda CSS's `css()` function requires **static values at build time**. It cannot process dynamic token references like:
|
||||
|
||||
```typescript
|
||||
// ❌ This doesn't work
|
||||
const color = 'blue.400'
|
||||
css({ color: color }) // Panda can't resolve this at build time
|
||||
```
|
||||
|
||||
The `css()` function performs static analysis during the build process to generate CSS classes. It cannot handle runtime-dynamic token paths.
|
||||
|
||||
## Solution: Use the `token()` Function
|
||||
|
||||
Panda CSS provides a `token()` function specifically for resolving token paths to their actual values at runtime:
|
||||
|
||||
```typescript
|
||||
import { token } from '../../styled-system/tokens'
|
||||
|
||||
// ✅ This works
|
||||
const stages = [
|
||||
{ level: '10 Kyu', label: 'Beginner', color: 'colors.green.400' },
|
||||
{ level: '5 Kyu', label: 'Intermediate', color: 'colors.blue.400' },
|
||||
{ level: '1 Kyu', label: 'Advanced', color: 'colors.violet.400' },
|
||||
{ level: 'Dan', label: 'Master', color: 'colors.amber.400' },
|
||||
] as const
|
||||
|
||||
// Use with inline styles, not css()
|
||||
<div style={{ color: token(stage.color) }}>
|
||||
```
|
||||
|
||||
## Important Notes
|
||||
|
||||
1. **Use `as const`**: TypeScript needs the array marked as `const` so the token strings are treated as literal types, not generic strings. The `token()` function expects the `Token` literal type.
|
||||
|
||||
2. **Use inline styles**: When using `token()`, apply colors via the `style` prop, not through the `css()` function:
|
||||
```typescript
|
||||
// ✅ Correct
|
||||
<div style={{ color: token(stage.color) }}>
|
||||
|
||||
// ❌ Won't work
|
||||
<div className={css({ color: token(stage.color) })}>
|
||||
```
|
||||
|
||||
3. **Static tokens in css()**: For static usage, you CAN use tokens directly in `css()`:
|
||||
```typescript
|
||||
// ✅ This works because it's static
|
||||
css({ color: 'blue.400' })
|
||||
```
|
||||
|
||||
## How token() Works
|
||||
|
||||
The `token()` function:
|
||||
- Takes a token path like `"colors.blue.400"`
|
||||
- Looks it up in the generated token registry (`styled-system/tokens/index.mjs`)
|
||||
- Returns the actual CSS value (e.g., `"#60a5fa"`)
|
||||
- Happens at runtime, not build time
|
||||
|
||||
## Token Type Definition
|
||||
|
||||
The `Token` type is a union of all valid token paths:
|
||||
```typescript
|
||||
type Token = "colors.blue.400" | "colors.green.400" | "colors.violet.400" | ...
|
||||
```
|
||||
|
||||
This is defined in `styled-system/tokens/tokens.d.ts`.
|
||||
|
||||
## Reference Implementation
|
||||
|
||||
See `src/app/page.tsx` lines 404-434 for a working example of dynamic token usage in the "Your Journey" section.
|
||||
222
apps/web/.claude/TUTORIAL_SYSTEM.md
Normal file
222
apps/web/.claude/TUTORIAL_SYSTEM.md
Normal file
@@ -0,0 +1,222 @@
|
||||
# Tutorial System Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
The tutorial system is a sophisticated interactive learning platform for teaching soroban abacus concepts. It features step-by-step guidance, bead highlighting, pedagogical decomposition, and progress tracking.
|
||||
|
||||
## Key Components
|
||||
|
||||
### 1. TutorialPlayer (`/src/components/tutorial/TutorialPlayer.tsx`)
|
||||
The main tutorial playback component that:
|
||||
- Displays tutorial steps progressively
|
||||
- Highlights specific beads users should interact with
|
||||
- Provides real-time feedback and tooltips
|
||||
- Shows step-by-step instructions for multi-step operations
|
||||
- Tracks user progress through the tutorial
|
||||
- Auto-advances to next step on correct completion
|
||||
|
||||
**Key Features:**
|
||||
- **Bead Highlighting**: Visual indicators showing which beads to manipulate
|
||||
- **Step Progress**: Shows current step out of total steps
|
||||
- **Error Feedback**: Provides hints when user makes mistakes
|
||||
- **Multi-Step Support**: Breaks complex operations into sequential sub-steps
|
||||
- **Pedagogical Decomposition**: Explains the "why" behind each operation
|
||||
|
||||
### 2. TutorialEditor (`/src/components/tutorial/TutorialEditor.tsx`)
|
||||
A full-featured editor for creating and editing tutorials:
|
||||
- Visual step editor
|
||||
- Bead highlight configuration
|
||||
- Multi-step instruction editor
|
||||
- Live preview
|
||||
- Import/export functionality
|
||||
- Access control
|
||||
|
||||
**Editor URL:** `/tutorial-editor`
|
||||
|
||||
### 3. Tutorial Data Structure (`/src/types/tutorial.ts`)
|
||||
|
||||
```typescript
|
||||
interface Tutorial {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
category: string
|
||||
difficulty: 'beginner' | 'intermediate' | 'advanced'
|
||||
estimatedDuration: number // minutes
|
||||
steps: TutorialStep[]
|
||||
tags: string[]
|
||||
author: string
|
||||
version: string
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
isPublished: boolean
|
||||
}
|
||||
|
||||
interface TutorialStep {
|
||||
id: string
|
||||
title: string
|
||||
problem: string // e.g. "2 + 3"
|
||||
description: string // User-facing explanation
|
||||
startValue: number // Initial abacus value
|
||||
targetValue: number // Goal value
|
||||
expectedAction: 'add' | 'remove' | 'multi-step'
|
||||
actionDescription: string
|
||||
|
||||
// Bead highlighting
|
||||
highlightBeads?: Array<{
|
||||
placeValue: number // 0=ones, 1=tens, etc.
|
||||
beadType: 'heaven' | 'earth'
|
||||
position?: number // For earth beads: 0-3
|
||||
}>
|
||||
|
||||
// Progressive step highlighting
|
||||
stepBeadHighlights?: Array<{
|
||||
placeValue: number
|
||||
beadType: 'heaven' | 'earth'
|
||||
position?: number
|
||||
stepIndex: number // Which instruction step
|
||||
direction: 'up' | 'down' | 'activate' | 'deactivate'
|
||||
order?: number // Order within step
|
||||
}>
|
||||
|
||||
totalSteps?: number // For multi-step operations
|
||||
multiStepInstructions?: string[] // Sequential instructions
|
||||
|
||||
// Tooltips and guidance
|
||||
tooltip: {
|
||||
content: string // Short title
|
||||
explanation: string // Detailed explanation
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Tutorial Converter (`/src/utils/tutorialConverter.ts`)
|
||||
|
||||
Utility that converts the original `GuidedAdditionTutorial` data into the new tutorial format:
|
||||
- `guidedAdditionSteps`: Array of tutorial steps from basic addition to complements
|
||||
- `convertGuidedAdditionTutorial()`: Converts to Tutorial object
|
||||
- `getTutorialForEditor()`: Main export used in the app
|
||||
|
||||
**Current Tutorial Steps:**
|
||||
1. Basic Addition (0+1, 1+1, 2+1, 3+1)
|
||||
2. Heaven Bead Introduction (0+5, 5+1)
|
||||
3. Five Complements (3+4, 2+3 using 5-complement method)
|
||||
4. Complex Operations (6+2, 7+4 with carrying)
|
||||
|
||||
### 5. Supporting Utilities
|
||||
|
||||
**`/src/utils/abacusInstructionGenerator.ts`**
|
||||
- Automatically generates step-by-step instructions from start/target values
|
||||
- Creates bead highlight data
|
||||
- Determines movement directions
|
||||
|
||||
**`/src/utils/beadDiff.ts`**
|
||||
- Calculates differences between abacus states
|
||||
- Generates visual feedback tooltips
|
||||
- Explains what changed and why
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Usage in a Page
|
||||
|
||||
```typescript
|
||||
import { TutorialPlayer } from '@/components/tutorial/TutorialPlayer'
|
||||
import { getTutorialForEditor } from '@/utils/tutorialConverter'
|
||||
|
||||
export function MyPage() {
|
||||
return (
|
||||
<TutorialPlayer
|
||||
tutorial={getTutorialForEditor()}
|
||||
isDebugMode={false}
|
||||
showDebugPanel={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Using a Subset of Steps
|
||||
|
||||
```typescript
|
||||
import { getTutorialForEditor } from '@/utils/tutorialConverter'
|
||||
|
||||
const fullTutorial = getTutorialForEditor()
|
||||
|
||||
// Extract specific steps (e.g., just "Friends of 5")
|
||||
const friendsOf5Tutorial = {
|
||||
...fullTutorial,
|
||||
id: 'friends-of-5-demo',
|
||||
title: 'Friends of 5',
|
||||
steps: fullTutorial.steps.filter(step =>
|
||||
step.id === 'complement-2' // The 2+3=5 step
|
||||
)
|
||||
}
|
||||
|
||||
return <TutorialPlayer tutorial={friendsOf5Tutorial} />
|
||||
```
|
||||
|
||||
### Creating a Custom Tutorial
|
||||
|
||||
```typescript
|
||||
const customTutorial: Tutorial = {
|
||||
id: 'my-tutorial',
|
||||
title: 'My Custom Tutorial',
|
||||
description: 'Learning something new',
|
||||
category: 'Custom',
|
||||
difficulty: 'beginner',
|
||||
estimatedDuration: 5,
|
||||
steps: [
|
||||
{
|
||||
id: 'step-1',
|
||||
title: 'Add 2',
|
||||
problem: '0 + 2',
|
||||
description: 'Move two earth beads up',
|
||||
startValue: 0,
|
||||
targetValue: 2,
|
||||
expectedAction: 'add',
|
||||
actionDescription: 'Add two earth beads',
|
||||
highlightBeads: [
|
||||
{ placeValue: 0, beadType: 'earth', position: 0 },
|
||||
{ placeValue: 0, beadType: 'earth', position: 1 }
|
||||
],
|
||||
tooltip: {
|
||||
content: 'Adding 2',
|
||||
explanation: 'Push two earth beads up to represent 2'
|
||||
}
|
||||
}
|
||||
],
|
||||
tags: ['custom'],
|
||||
author: 'Me',
|
||||
version: '1.0.0',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
isPublished: true
|
||||
}
|
||||
```
|
||||
|
||||
## Current Implementation Locations
|
||||
|
||||
**Live Tutorials:**
|
||||
- `/guide` - Second tab "Arithmetic Operations" contains the full guided addition tutorial
|
||||
|
||||
**Editor:**
|
||||
- `/tutorial-editor` - Full tutorial editing interface
|
||||
|
||||
**Storybook:**
|
||||
- Multiple tutorial stories in `/src/components/tutorial/*.stories.tsx`
|
||||
|
||||
## Key Design Principles
|
||||
|
||||
1. **Progressive Disclosure**: Users see one step at a time
|
||||
2. **Immediate Feedback**: Real-time validation and hints
|
||||
3. **Visual Guidance**: Bead highlighting shows exactly what to do
|
||||
4. **Pedagogical Decomposition**: Multi-step operations broken into atomic actions
|
||||
5. **Auto-Advancement**: Successful completion automatically moves to next step
|
||||
6. **Error Recovery**: Helpful hints when user makes mistakes
|
||||
|
||||
## Notes
|
||||
|
||||
- The tutorial system uses the existing `AbacusReact` component
|
||||
- Tutorials can be created/edited through the TutorialEditor
|
||||
- Tutorial data can be exported/imported as JSON
|
||||
- The system supports both single-step and multi-step operations
|
||||
- Bead highlighting uses place value indexing (0=ones, 1=tens, etc.)
|
||||
94
apps/web/.claude/UI_STYLE_GUIDE.md
Normal file
94
apps/web/.claude/UI_STYLE_GUIDE.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# UI Style Guide
|
||||
|
||||
## Confirmations and Dialogs
|
||||
|
||||
**NEVER use native browser dialogs:**
|
||||
- ❌ `alert()`
|
||||
- ❌ `confirm()`
|
||||
- ❌ `prompt()`
|
||||
|
||||
**ALWAYS use inline React-based confirmations:**
|
||||
- Show confirmation UI in-place using React state
|
||||
- Provide Cancel and Confirm buttons
|
||||
- Use descriptive warning messages with appropriate emoji (⚠️)
|
||||
- Follow the Panda CSS styling system
|
||||
- Match the visual style of the surrounding UI
|
||||
|
||||
### Pattern: Inline Confirmation
|
||||
|
||||
```typescript
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
|
||||
{!confirming ? (
|
||||
<button onClick={() => setConfirming(true)}>
|
||||
Delete Item
|
||||
</button>
|
||||
) : (
|
||||
<div>
|
||||
<div style={{ /* warning styling */ }}>
|
||||
⚠️ Are you sure you want to delete this item?
|
||||
</div>
|
||||
<div style={{ /* description styling */ }}>
|
||||
This action cannot be undone.
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button onClick={() => setConfirming(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button onClick={handleDelete}>
|
||||
Confirm Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
### Real Examples
|
||||
|
||||
See `/src/components/nav/ModerationPanel.tsx` for production examples:
|
||||
- Transfer ownership confirmation (lines 1793-1929)
|
||||
- Unban user confirmation (shows inline warning with Cancel/Confirm)
|
||||
|
||||
### Why This Pattern?
|
||||
|
||||
1. **Consistency**: Native dialogs look different across browsers and platforms
|
||||
2. **Control**: We can style, position, and enhance confirmations to match our design
|
||||
3. **Accessibility**: We can add proper ARIA attributes and keyboard navigation
|
||||
4. **UX**: Users stay in context rather than being interrupted by modal dialogs
|
||||
5. **Testing**: Inline confirmations are easier to test than native browser dialogs
|
||||
|
||||
### Migration Checklist
|
||||
|
||||
When replacing native dialogs:
|
||||
- [ ] Add state variable for confirmation (e.g., `const [confirming, setConfirming] = useState(false)`)
|
||||
- [ ] Remove the `confirm()` or `alert()` call from the handler
|
||||
- [ ] Replace the original UI with conditional rendering
|
||||
- [ ] Show initial state with primary action button
|
||||
- [ ] Show confirmation state with warning message + Cancel/Confirm buttons
|
||||
- [ ] Ensure Cancel button resets state: `onClick={() => setConfirming(false)}`
|
||||
- [ ] Ensure Confirm button performs action and resets state
|
||||
- [ ] Add loading states if the action is async
|
||||
- [ ] Style to match surrounding UI using Panda CSS
|
||||
|
||||
## Styling System
|
||||
|
||||
This project uses **Panda CSS**, not Tailwind CSS.
|
||||
|
||||
- ❌ Never use Tailwind utility classes (e.g., `className="bg-blue-500"`)
|
||||
- ✅ Always use Panda CSS `css()` function
|
||||
- ✅ Use Panda's token system (defined in `panda.config.ts`)
|
||||
|
||||
See `.claude/CLAUDE.md` for complete Panda CSS documentation.
|
||||
|
||||
## Emoji Usage
|
||||
|
||||
Emojis are used liberally throughout the UI for visual communication:
|
||||
- 👑 Host/owner status
|
||||
- ⏳ Waiting states
|
||||
- ⚠️ Warnings and confirmations
|
||||
- ✅ Success states
|
||||
- ❌ Error states
|
||||
- 👀 Spectating mode
|
||||
- 🎮 Gaming context
|
||||
|
||||
Use emojis to enhance clarity, not replace text.
|
||||
@@ -92,7 +92,18 @@
|
||||
"Bash(ls:*)",
|
||||
"Bash(do if [ -f \"$file\" ])",
|
||||
"Bash(! echo \"$file\")",
|
||||
"Bash(then sed -i '' \"s|from ''''../context/ComplementRaceContext''''|from ''''@/arcade-games/complement-race/Provider''''|g\" \"$file\" sed -i '' \"s|from ''''../../context/ComplementRaceContext''''|from ''''@/arcade-games/complement-race/Provider''''|g\" \"$file\" fi done)"
|
||||
"Bash(then sed -i '' \"s|from ''''../context/ComplementRaceContext''''|from ''''@/arcade-games/complement-race/Provider''''|g\" \"$file\" sed -i '' \"s|from ''''../../context/ComplementRaceContext''''|from ''''@/arcade-games/complement-race/Provider''''|g\" \"$file\" fi done)",
|
||||
"Bash(pnpm install)",
|
||||
"Bash(pnpm exec turbo build --filter=@soroban/web)",
|
||||
"Bash(do gh run list --limit 1 --json conclusion,status,name,databaseId --jq '.[0] | \"\"\\(.status) - \\(.conclusion // \"\"running\"\")\"\"')",
|
||||
"Bash(do gh run list --limit 1 --json conclusion,status,name --jq '.[0] | \"\"\\(.status) - \\(.conclusion // \"\"running\"\") - \\(.name)\"\"')",
|
||||
"Bash(do gh run list --limit 1 --workflow=\"Build and Deploy\" --json conclusion,status --jq '.[0] | \"\"\\(.status) - \\(.conclusion // \"\"running\"\")\"\"')",
|
||||
"WebFetch(domain:abaci.one)",
|
||||
"Bash(do gh run list --limit 1 --workflow=\"Build and Deploy\" --json conclusion,status,databaseId --jq '.[0] | \"\"\\(.status) - \\(.conclusion // \"\"running\"\") - Run ID: \\(.databaseId)\"\"')",
|
||||
"Bash(node -e:*)",
|
||||
"Bash(do gh run list --limit 1 --workflow=\"Build and Deploy\" --json conclusion,status,databaseId --jq '.[0] | \"\"\\(.status) - \\(.conclusion // \"\"running\"\") - Run \\(.databaseId)\"\"')",
|
||||
"Bash(do ssh nas.home.network '/usr/local/bin/docker inspect soroban-abacus-flashcards --format=\"\"{{index .Config.Labels \\\"\"org.opencontainers.image.revision\\\"\"}}\"\"')",
|
||||
"Bash(git rev-parse HEAD)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
1761
apps/web/CARD_SORTING_PORT_PLAN.md
Normal file
1761
apps/web/CARD_SORTING_PORT_PLAN.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -48,7 +48,6 @@
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@react-spring/web": "^10.0.2",
|
||||
"@soroban/abacus-react": "workspace:*",
|
||||
"@soroban/client": "workspace:*",
|
||||
"@soroban/core": "workspace:*",
|
||||
"@soroban/templates": "workspace:*",
|
||||
"@tanstack/react-form": "^0.19.0",
|
||||
|
||||
@@ -18,11 +18,14 @@ function exec(command) {
|
||||
}
|
||||
|
||||
function getBuildInfo() {
|
||||
const gitCommit = exec('git rev-parse HEAD')
|
||||
const gitCommitShort = exec('git rev-parse --short HEAD')
|
||||
const gitBranch = exec('git rev-parse --abbrev-ref HEAD')
|
||||
const gitTag = exec('git describe --tags --exact-match 2>/dev/null')
|
||||
const gitDirty = exec('git diff --quiet || echo "dirty"') === 'dirty'
|
||||
// Try to get git info from environment variables first (for Docker builds)
|
||||
// Fall back to git commands (for local development)
|
||||
const gitCommit = process.env.GIT_COMMIT || exec('git rev-parse HEAD')
|
||||
const gitCommitShort = process.env.GIT_COMMIT_SHORT || exec('git rev-parse --short HEAD')
|
||||
const gitBranch = process.env.GIT_BRANCH || exec('git rev-parse --abbrev-ref HEAD')
|
||||
const gitTag = process.env.GIT_TAG || exec('git describe --tags --exact-match 2>/dev/null')
|
||||
const gitDirty =
|
||||
process.env.GIT_DIRTY === 'true' || exec('git diff --quiet || echo "dirty"') === 'dirty'
|
||||
|
||||
const packageJson = require('../package.json')
|
||||
|
||||
|
||||
@@ -158,7 +158,7 @@ export default function RoomDetailPage() {
|
||||
const startGame = () => {
|
||||
if (!room) return
|
||||
// Navigate to the room game page
|
||||
router.push('/arcade/room')
|
||||
router.push('/arcade')
|
||||
}
|
||||
|
||||
const joinRoom = async () => {
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useComplementRace } from '@/arcade-games/complement-race/Provider'
|
||||
import { useAdaptiveDifficulty } from '../hooks/useAdaptiveDifficulty'
|
||||
import { useAIRacers } from '../hooks/useAIRacers'
|
||||
import { useSoundEffects } from '../hooks/useSoundEffects'
|
||||
import { useSteamJourney } from '../hooks/useSteamJourney'
|
||||
import { generatePassengers } from '../lib/passengerGenerator'
|
||||
import { AbacusTarget } from './AbacusTarget'
|
||||
import { CircularTrack } from './RaceTrack/CircularTrack'
|
||||
@@ -16,10 +15,9 @@ import { RouteCelebration } from './RouteCelebration'
|
||||
type FeedbackAnimation = 'correct' | 'incorrect' | null
|
||||
|
||||
export function GameDisplay() {
|
||||
const { state, dispatch } = useComplementRace()
|
||||
const { state, dispatch, boostMomentum } = useComplementRace()
|
||||
useAIRacers() // Activate AI racer updates (not used in sprint mode)
|
||||
const { trackPerformance, getAdaptiveFeedbackMessage } = useAdaptiveDifficulty()
|
||||
const { boostMomentum } = useSteamJourney()
|
||||
const { playSound } = useSoundEffects()
|
||||
const [feedbackAnimation, setFeedbackAnimation] = useState<FeedbackAnimation>(null)
|
||||
|
||||
@@ -109,7 +107,7 @@ export function GameDisplay() {
|
||||
|
||||
// Boost momentum for sprint mode
|
||||
if (state.style === 'sprint') {
|
||||
boostMomentum()
|
||||
boostMomentum(true)
|
||||
|
||||
// Play train whistle for milestones in sprint mode (line 13222-13235)
|
||||
if (newStreak >= 5 && newStreak % 3 === 0) {
|
||||
@@ -144,6 +142,11 @@ export function GameDisplay() {
|
||||
// Play incorrect sound (from web_generator.py line 11589)
|
||||
playSound('incorrect')
|
||||
|
||||
// Reduce momentum for sprint mode
|
||||
if (state.style === 'sprint') {
|
||||
boostMomentum(false)
|
||||
}
|
||||
|
||||
// Show adaptive feedback
|
||||
const feedback = getAdaptiveFeedbackMessage(pairKey, false, responseTime)
|
||||
if (feedback) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import type { Passenger, Station } from '../lib/gameTypes'
|
||||
import type { Passenger, Station } from '@/arcade-games/complement-race/types'
|
||||
|
||||
interface PassengerCardProps {
|
||||
passenger: Passenger
|
||||
@@ -17,24 +17,27 @@ export const PassengerCard = memo(function PassengerCard({
|
||||
if (!destinationStation || !originStation) return null
|
||||
|
||||
// Vintage train station colors
|
||||
const bgColor = passenger.isDelivered
|
||||
// Arcade room multiplayer uses claimedBy/deliveredBy instead of isBoarded/isDelivered
|
||||
const isBoarded = passenger.claimedBy !== null
|
||||
const isDelivered = passenger.deliveredBy !== null
|
||||
|
||||
const bgColor = isDelivered
|
||||
? '#1a3a1a' // Dark green for delivered
|
||||
: !passenger.isBoarded
|
||||
: !isBoarded
|
||||
? '#2a2419' // Dark brown/sepia for waiting
|
||||
: passenger.isUrgent
|
||||
? '#3a2419' // Dark red-brown for urgent
|
||||
: '#1a2a3a' // Dark blue for aboard
|
||||
|
||||
const accentColor = passenger.isDelivered
|
||||
const accentColor = isDelivered
|
||||
? '#4ade80' // Green
|
||||
: !passenger.isBoarded
|
||||
: !isBoarded
|
||||
? '#d4af37' // Gold for waiting
|
||||
: passenger.isUrgent
|
||||
? '#ff6b35' // Orange-red for urgent
|
||||
: '#60a5fa' // Blue for aboard
|
||||
|
||||
const borderColor =
|
||||
passenger.isUrgent && passenger.isBoarded && !passenger.isDelivered ? '#ff6b35' : '#d4af37'
|
||||
const borderColor = passenger.isUrgent && isBoarded && !isDelivered ? '#ff6b35' : '#d4af37'
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -46,13 +49,13 @@ export const PassengerCard = memo(function PassengerCard({
|
||||
minWidth: '220px',
|
||||
maxWidth: '280px',
|
||||
boxShadow:
|
||||
passenger.isUrgent && !passenger.isDelivered && passenger.isBoarded
|
||||
passenger.isUrgent && !isDelivered && isBoarded
|
||||
? '0 0 16px rgba(255, 107, 53, 0.5)'
|
||||
: '0 4px 12px rgba(0, 0, 0, 0.4)',
|
||||
position: 'relative',
|
||||
fontFamily: '"Courier New", Courier, monospace',
|
||||
animation:
|
||||
passenger.isUrgent && !passenger.isDelivered && passenger.isBoarded
|
||||
passenger.isUrgent && !isDelivered && isBoarded
|
||||
? 'urgentFlicker 1.5s ease-in-out infinite'
|
||||
: 'none',
|
||||
transition: 'all 0.3s ease',
|
||||
@@ -79,7 +82,7 @@ export const PassengerCard = memo(function PassengerCard({
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '20px', lineHeight: '1' }}>
|
||||
{passenger.isDelivered ? '✅' : passenger.avatar}
|
||||
{isDelivered ? '✅' : passenger.avatar}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
@@ -109,7 +112,7 @@ export const PassengerCard = memo(function PassengerCard({
|
||||
marginTop: '0',
|
||||
}}
|
||||
>
|
||||
{passenger.isDelivered ? 'DLVRD' : passenger.isBoarded ? 'BOARD' : 'WAIT'}
|
||||
{isDelivered ? 'DLVRD' : isBoarded ? 'BOARD' : 'WAIT'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -187,7 +190,7 @@ export const PassengerCard = memo(function PassengerCard({
|
||||
</div>
|
||||
|
||||
{/* Points badge */}
|
||||
{!passenger.isDelivered && (
|
||||
{!isDelivered && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
@@ -208,7 +211,7 @@ export const PassengerCard = memo(function PassengerCard({
|
||||
)}
|
||||
|
||||
{/* Urgent indicator */}
|
||||
{passenger.isUrgent && !passenger.isDelivered && passenger.isBoarded && (
|
||||
{passenger.isUrgent && !isDelivered && isBoarded && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
|
||||
@@ -17,15 +17,16 @@ interface CircularTrackProps {
|
||||
|
||||
export function CircularTrack({ playerProgress, playerLap, aiRacers, aiLaps }: CircularTrackProps) {
|
||||
const { state, dispatch } = useComplementRace()
|
||||
const { players } = useGameMode()
|
||||
const { players, activePlayers } = useGameMode()
|
||||
const { profile: _profile } = useUserProfile()
|
||||
const { playSound } = useSoundEffects()
|
||||
const [celebrationCooldown, setCelebrationCooldown] = useState<Set<string>>(new Set())
|
||||
|
||||
// Get the first active player's emoji
|
||||
const activePlayers = Array.from(players.values()).filter((p) => p.id)
|
||||
const firstActivePlayer = activePlayers[0]
|
||||
const playerEmoji = firstActivePlayer?.emoji ?? '👤'
|
||||
// Get the current user's active local players (consistent with navbar pattern)
|
||||
const activeLocalPlayers = Array.from(activePlayers)
|
||||
.map((id) => players.get(id))
|
||||
.filter((p): p is NonNullable<typeof p> => p !== undefined && p.isLocal !== false)
|
||||
const playerEmoji = activeLocalPlayers[0]?.emoji ?? '👤'
|
||||
const [dimensions, setDimensions] = useState({ width: 600, height: 400 })
|
||||
|
||||
// Update dimensions on mount and resize
|
||||
@@ -400,7 +401,11 @@ export function CircularTrack({ playerProgress, playerLap, aiRacers, aiLaps }: C
|
||||
{activeBubble && (
|
||||
<div
|
||||
style={{
|
||||
transform: `rotate(${-aiPos.angle}deg)`, // Counter-rotate bubble
|
||||
position: 'absolute',
|
||||
bottom: '100%', // Position above the AI racer
|
||||
left: '50%',
|
||||
transform: `translate(-50%, -15px) rotate(${-aiPos.angle}deg)`, // Offset 15px above, counter-rotate bubble
|
||||
zIndex: 20, // Above player (10) and AI racers (5)
|
||||
}}
|
||||
>
|
||||
<SpeechBubble
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import type { ComplementQuestion, Passenger, Station } from '../../lib/gameTypes'
|
||||
import type { ComplementQuestion } from '../../lib/gameTypes'
|
||||
import type { Passenger, Station } from '@/arcade-games/complement-race/types'
|
||||
import { AbacusTarget } from '../AbacusTarget'
|
||||
import { PassengerCard } from '../PassengerCard'
|
||||
import { PressureGauge } from '../PressureGauge'
|
||||
|
||||
@@ -20,13 +20,14 @@ export function LinearTrack({
|
||||
showFinishLine = true,
|
||||
}: LinearTrackProps) {
|
||||
const { state, dispatch } = useComplementRace()
|
||||
const { players } = useGameMode()
|
||||
const { players, activePlayers } = useGameMode()
|
||||
const { profile: _profile } = useUserProfile()
|
||||
|
||||
// Get the first active player's emoji
|
||||
const activePlayers = Array.from(players.values()).filter((p) => p.id)
|
||||
const firstActivePlayer = activePlayers[0]
|
||||
const playerEmoji = firstActivePlayer?.emoji ?? '👤'
|
||||
// Get the current user's active local players (consistent with navbar pattern)
|
||||
const activeLocalPlayers = Array.from(activePlayers)
|
||||
.map((id) => players.get(id))
|
||||
.filter((p): p is NonNullable<typeof p> => p !== undefined && p.isLocal !== false)
|
||||
const playerEmoji = activeLocalPlayers[0]?.emoji ?? '👤'
|
||||
|
||||
// Position calculation: leftPercent = Math.min(98, (progress / raceGoal) * 96 + 2)
|
||||
// 2% minimum (start), 98% maximum (near finish), 96% range for race
|
||||
@@ -110,7 +111,7 @@ export function LinearTrack({
|
||||
position: 'absolute',
|
||||
left: `${playerPosition}%`,
|
||||
top: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
transform: 'translate(-50%, -50%) scaleX(-1)',
|
||||
fontSize: '32px',
|
||||
transition: 'left 0.3s ease-out',
|
||||
filter: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2))',
|
||||
@@ -132,7 +133,7 @@ export function LinearTrack({
|
||||
position: 'absolute',
|
||||
left: `${aiPosition}%`,
|
||||
top: `${35 + index * 15}%`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
transform: 'translate(-50%, -50%) scaleX(-1)',
|
||||
fontSize: '28px',
|
||||
transition: 'left 0.2s linear',
|
||||
filter: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2))',
|
||||
@@ -141,10 +142,20 @@ export function LinearTrack({
|
||||
>
|
||||
{racer.icon}
|
||||
{activeBubble && (
|
||||
<SpeechBubble
|
||||
message={activeBubble}
|
||||
onHide={() => dispatch({ type: 'CLEAR_AI_COMMENT', racerId: racer.id })}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '100%', // Position above the AI racer
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -15px) scaleX(-1)', // Offset 15px above, counter-flip bubble
|
||||
zIndex: 20, // Above player (10) and AI racers (5)
|
||||
}}
|
||||
>
|
||||
<SpeechBubble
|
||||
message={activeBubble}
|
||||
onHide={() => dispatch({ type: 'CLEAR_AI_COMMENT', racerId: racer.id })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import type { Passenger, Station } from '../../lib/gameTypes'
|
||||
import type { Passenger, Station } from '@/arcade-games/complement-race/types'
|
||||
import type { Landmark } from '../../lib/landmarks'
|
||||
|
||||
interface RailroadTrackPathProps {
|
||||
@@ -100,18 +100,19 @@ export const RailroadTrackPath = memo(
|
||||
{stationPositions.map((pos, index) => {
|
||||
const station = stations[index]
|
||||
// Find passengers waiting at this station (exclude currently boarding)
|
||||
// Arcade room multiplayer uses claimedBy/deliveredBy instead of isBoarded/isDelivered
|
||||
const waitingPassengers = passengers.filter(
|
||||
(p) =>
|
||||
p.originStationId === station?.id &&
|
||||
!p.isBoarded &&
|
||||
!p.isDelivered &&
|
||||
p.claimedBy === null &&
|
||||
p.deliveredBy === null &&
|
||||
!boardingAnimations.has(p.id)
|
||||
)
|
||||
// Find passengers delivered at this station (exclude currently disembarking)
|
||||
const deliveredPassengers = passengers.filter(
|
||||
(p) =>
|
||||
p.destinationStationId === station?.id &&
|
||||
p.isDelivered &&
|
||||
p.deliveredBy !== null &&
|
||||
!disembarkingAnimations.has(p.id)
|
||||
)
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ import type { ComplementQuestion } from '../../lib/gameTypes'
|
||||
import { useSteamJourney } from '../../hooks/useSteamJourney'
|
||||
import { useTrackManagement } from '../../hooks/useTrackManagement'
|
||||
import { useTrainTransforms } from '../../hooks/useTrainTransforms'
|
||||
import { calculateMaxConcurrentPassengers } from '../../lib/passengerGenerator'
|
||||
import { RailroadTrackGenerator } from '../../lib/RailroadTrackGenerator'
|
||||
import { getRouteTheme } from '../../lib/routeThemes'
|
||||
import { GameHUD } from './GameHUD'
|
||||
@@ -94,9 +93,6 @@ export function SteamTrainJourney({
|
||||
currentInput,
|
||||
}: SteamTrainJourneyProps) {
|
||||
const { state } = useComplementRace()
|
||||
console.log(
|
||||
`🚂 Train: mom=${momentum} pos=${trainPosition} stations=${state.stations.length} passengers=${state.passengers.length}`
|
||||
)
|
||||
|
||||
const { getSkyGradient, getTimeOfDayPeriod } = useSteamJourney()
|
||||
const _skyGradient = getSkyGradient()
|
||||
@@ -113,12 +109,9 @@ export function SteamTrainJourney({
|
||||
const pathRef = useRef<SVGPathElement>(null)
|
||||
const [trackGenerator] = useState(() => new RailroadTrackGenerator(800, 600))
|
||||
|
||||
// Calculate the number of train cars dynamically based on max concurrent passengers
|
||||
const maxCars = useMemo(() => {
|
||||
const maxPassengers = calculateMaxConcurrentPassengers(state.passengers, state.stations)
|
||||
// Ensure at least 1 car, even if no passengers
|
||||
return Math.max(1, maxPassengers)
|
||||
}, [state.passengers, state.stations])
|
||||
// Use server's authoritative maxConcurrentPassengers calculation
|
||||
// This ensures visual display matches game logic and console logs
|
||||
const maxCars = Math.max(1, state.maxConcurrentPassengers || 3)
|
||||
|
||||
const carSpacing = 7 // Distance between cars (in % of track)
|
||||
|
||||
@@ -170,13 +163,14 @@ export function SteamTrainJourney({
|
||||
const routeTheme = getRouteTheme(state.currentRoute)
|
||||
|
||||
// Memoize filtered passenger lists to avoid recalculating on every render
|
||||
// Arcade room multiplayer uses claimedBy/deliveredBy instead of isBoarded/isDelivered
|
||||
const boardedPassengers = useMemo(
|
||||
() => displayPassengers.filter((p) => p.isBoarded && !p.isDelivered),
|
||||
() => displayPassengers.filter((p) => p.claimedBy !== null && p.deliveredBy === null),
|
||||
[displayPassengers]
|
||||
)
|
||||
|
||||
const nonDeliveredPassengers = useMemo(
|
||||
() => displayPassengers.filter((p) => !p.isDelivered),
|
||||
() => displayPassengers.filter((p) => p.deliveredBy === null),
|
||||
[displayPassengers]
|
||||
)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { memo } from 'react'
|
||||
import type { BoardingAnimation, DisembarkingAnimation } from '../../hooks/usePassengerAnimations'
|
||||
import type { Passenger } from '../../lib/gameTypes'
|
||||
import type { Passenger } from '@/arcade-games/complement-race/types'
|
||||
|
||||
interface TrainCarTransform {
|
||||
x: number
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
import type { Passenger, Station } from '../../../lib/gameTypes'
|
||||
import type { Passenger, Station } from '@/arcade-games/complement-race/types'
|
||||
import { GameHUD } from '../GameHUD'
|
||||
|
||||
// Mock child components
|
||||
@@ -33,9 +33,11 @@ describe('GameHUD', () => {
|
||||
avatar: '👨',
|
||||
originStationId: 'station-1',
|
||||
destinationStationId: 'station-2',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
claimedBy: null,
|
||||
deliveredBy: null,
|
||||
carIndex: null,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
import type { Passenger, Station } from '../../lib/gameTypes'
|
||||
import type { Passenger, Station } from '@/arcade-games/complement-race/types'
|
||||
import type { RailroadTrackGenerator } from '../../lib/RailroadTrackGenerator'
|
||||
import { useTrackManagement } from '../useTrackManagement'
|
||||
|
||||
@@ -47,7 +47,7 @@ describe('useTrackManagement - Passenger Display', () => {
|
||||
{ id: 'station3', name: 'Station 3', icon: '🏪', emoji: '🏪', position: 80 },
|
||||
]
|
||||
|
||||
// Mock passengers - initial set
|
||||
// Mock passengers - initial set (multiplayer format)
|
||||
mockPassengers = [
|
||||
{
|
||||
id: 'p1',
|
||||
@@ -55,9 +55,11 @@ describe('useTrackManagement - Passenger Display', () => {
|
||||
avatar: '👩',
|
||||
originStationId: 'station1',
|
||||
destinationStationId: 'station2',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
claimedBy: null,
|
||||
deliveredBy: null,
|
||||
carIndex: null,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{
|
||||
id: 'p2',
|
||||
@@ -65,9 +67,11 @@ describe('useTrackManagement - Passenger Display', () => {
|
||||
avatar: '👨',
|
||||
originStationId: 'station2',
|
||||
destinationStationId: 'station3',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
claimedBy: null,
|
||||
deliveredBy: null,
|
||||
carIndex: null,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -111,18 +115,18 @@ describe('useTrackManagement - Passenger Display', () => {
|
||||
|
||||
// Initially 2 passengers
|
||||
expect(result.current.displayPassengers).toHaveLength(2)
|
||||
expect(result.current.displayPassengers[0].isBoarded).toBe(false)
|
||||
expect(result.current.displayPassengers[0].claimedBy).toBe(null)
|
||||
|
||||
// Board first passenger
|
||||
const boardedPassengers = mockPassengers.map((p) =>
|
||||
p.id === 'p1' ? { ...p, isBoarded: true } : p
|
||||
p.id === 'p1' ? { ...p, claimedBy: 'player1', carIndex: 0 } : p
|
||||
)
|
||||
|
||||
rerender({ passengers: boardedPassengers, position: 25 })
|
||||
|
||||
// Should show updated passengers
|
||||
expect(result.current.displayPassengers).toHaveLength(2)
|
||||
expect(result.current.displayPassengers[0].isBoarded).toBe(true)
|
||||
expect(result.current.displayPassengers[0].claimedBy).toBe('player1')
|
||||
})
|
||||
|
||||
test('passengers do NOT update during route transition (train moving)', () => {
|
||||
@@ -153,9 +157,11 @@ describe('useTrackManagement - Passenger Display', () => {
|
||||
avatar: '👴',
|
||||
originStationId: 'station1',
|
||||
destinationStationId: 'station3',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
claimedBy: null,
|
||||
deliveredBy: null,
|
||||
carIndex: null,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -196,9 +202,11 @@ describe('useTrackManagement - Passenger Display', () => {
|
||||
avatar: '👴',
|
||||
originStationId: 'station1',
|
||||
destinationStationId: 'station3',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
claimedBy: null,
|
||||
deliveredBy: null,
|
||||
carIndex: null,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -239,9 +247,11 @@ describe('useTrackManagement - Passenger Display', () => {
|
||||
avatar: '👴',
|
||||
originStationId: 'station1',
|
||||
destinationStationId: 'station3',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
claimedBy: null,
|
||||
deliveredBy: null,
|
||||
carIndex: null,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -316,18 +326,18 @@ describe('useTrackManagement - Passenger Display', () => {
|
||||
|
||||
// Initially 2 passengers, neither delivered
|
||||
expect(result.current.displayPassengers).toHaveLength(2)
|
||||
expect(result.current.displayPassengers[0].isDelivered).toBe(false)
|
||||
expect(result.current.displayPassengers[0].deliveredBy).toBe(null)
|
||||
|
||||
// Deliver first passenger
|
||||
const deliveredPassengers = mockPassengers.map((p) =>
|
||||
p.id === 'p1' ? { ...p, isBoarded: true, isDelivered: true } : p
|
||||
p.id === 'p1' ? { ...p, claimedBy: 'player1', carIndex: 0, deliveredBy: 'player1' } : p
|
||||
)
|
||||
|
||||
rerender({ passengers: deliveredPassengers, position: 55 })
|
||||
|
||||
// Should show updated passengers immediately
|
||||
expect(result.current.displayPassengers).toHaveLength(2)
|
||||
expect(result.current.displayPassengers[0].isDelivered).toBe(true)
|
||||
expect(result.current.displayPassengers[0].deliveredBy).toBe('player1')
|
||||
})
|
||||
|
||||
test('multiple rapid passenger updates during same route', () => {
|
||||
@@ -350,25 +360,27 @@ describe('useTrackManagement - Passenger Display', () => {
|
||||
expect(result.current.displayPassengers).toHaveLength(2)
|
||||
|
||||
// Board p1
|
||||
let updated = mockPassengers.map((p) => (p.id === 'p1' ? { ...p, isBoarded: true } : p))
|
||||
let updated = mockPassengers.map((p) =>
|
||||
p.id === 'p1' ? { ...p, claimedBy: 'player1', carIndex: 0 } : p
|
||||
)
|
||||
rerender({ passengers: updated, position: 26 })
|
||||
expect(result.current.displayPassengers[0].isBoarded).toBe(true)
|
||||
expect(result.current.displayPassengers[0].claimedBy).toBe('player1')
|
||||
|
||||
// Board p2
|
||||
updated = updated.map((p) => (p.id === 'p2' ? { ...p, isBoarded: true } : p))
|
||||
updated = updated.map((p) => (p.id === 'p2' ? { ...p, claimedBy: 'player1', carIndex: 1 } : p))
|
||||
rerender({ passengers: updated, position: 52 })
|
||||
expect(result.current.displayPassengers[1].isBoarded).toBe(true)
|
||||
expect(result.current.displayPassengers[1].claimedBy).toBe('player1')
|
||||
|
||||
// Deliver p1
|
||||
updated = updated.map((p) => (p.id === 'p1' ? { ...p, isDelivered: true } : p))
|
||||
updated = updated.map((p) => (p.id === 'p1' ? { ...p, deliveredBy: 'player1' } : p))
|
||||
rerender({ passengers: updated, position: 53 })
|
||||
expect(result.current.displayPassengers[0].isDelivered).toBe(true)
|
||||
expect(result.current.displayPassengers[0].deliveredBy).toBe('player1')
|
||||
|
||||
// All updates should have been reflected
|
||||
expect(result.current.displayPassengers[0].isBoarded).toBe(true)
|
||||
expect(result.current.displayPassengers[0].isDelivered).toBe(true)
|
||||
expect(result.current.displayPassengers[1].isBoarded).toBe(true)
|
||||
expect(result.current.displayPassengers[1].isDelivered).toBe(false)
|
||||
expect(result.current.displayPassengers[0].claimedBy).toBe('player1')
|
||||
expect(result.current.displayPassengers[0].deliveredBy).toBe('player1')
|
||||
expect(result.current.displayPassengers[1].claimedBy).toBe('player1')
|
||||
expect(result.current.displayPassengers[1].deliveredBy).toBe(null)
|
||||
})
|
||||
|
||||
test('EDGE CASE: new passengers at position 0 with old route', () => {
|
||||
@@ -402,9 +414,11 @@ describe('useTrackManagement - Passenger Display', () => {
|
||||
avatar: '👴',
|
||||
originStationId: 'station1',
|
||||
destinationStationId: 'station3',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
claimedBy: null,
|
||||
deliveredBy: null,
|
||||
carIndex: null,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -445,9 +459,11 @@ describe('useTrackManagement - Passenger Display', () => {
|
||||
avatar: '👴',
|
||||
originStationId: 'station1',
|
||||
destinationStationId: 'station3',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
claimedBy: null,
|
||||
deliveredBy: null,
|
||||
carIndex: null,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -483,9 +499,11 @@ describe('useTrackManagement - Passenger Display', () => {
|
||||
avatar: '👴',
|
||||
originStationId: 'station1',
|
||||
destinationStationId: 'station3',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
claimedBy: null,
|
||||
deliveredBy: null,
|
||||
carIndex: null,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
import type { Passenger, Station } from '../../lib/gameTypes'
|
||||
import type { Passenger, Station } from '@/arcade-games/complement-race/types'
|
||||
import type { RailroadTrackGenerator } from '../../lib/RailroadTrackGenerator'
|
||||
import { useTrackManagement } from '../useTrackManagement'
|
||||
|
||||
@@ -60,9 +60,11 @@ describe('useTrackManagement', () => {
|
||||
avatar: '👨',
|
||||
originStationId: 'station-1',
|
||||
destinationStationId: 'station-2',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
claimedBy: null,
|
||||
deliveredBy: null,
|
||||
carIndex: null,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -155,6 +157,8 @@ describe('useTrackManagement', () => {
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers: mockPassengers,
|
||||
maxCars: 3,
|
||||
carSpacing: 7,
|
||||
})
|
||||
)
|
||||
|
||||
@@ -174,6 +178,8 @@ describe('useTrackManagement', () => {
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers: mockPassengers,
|
||||
maxCars: 3,
|
||||
carSpacing: 7,
|
||||
}),
|
||||
{
|
||||
initialProps: { route: 1, position: 0 },
|
||||
@@ -200,6 +206,8 @@ describe('useTrackManagement', () => {
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers: mockPassengers,
|
||||
maxCars: 3,
|
||||
carSpacing: 7,
|
||||
}),
|
||||
{
|
||||
initialProps: { route: 1, position: 0 },
|
||||
@@ -227,6 +235,8 @@ describe('useTrackManagement', () => {
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers: mockPassengers,
|
||||
maxCars: 3,
|
||||
carSpacing: 7,
|
||||
}),
|
||||
{
|
||||
initialProps: { route: 1, position: -5 },
|
||||
@@ -250,9 +260,11 @@ describe('useTrackManagement', () => {
|
||||
avatar: '👩',
|
||||
originStationId: 'station-1',
|
||||
destinationStationId: 'station-2',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
claimedBy: null,
|
||||
deliveredBy: null,
|
||||
carIndex: null,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -287,12 +299,15 @@ describe('useTrackManagement', () => {
|
||||
const newPassengers: Passenger[] = [
|
||||
{
|
||||
id: 'passenger-2',
|
||||
name: 'Passenger 2',
|
||||
avatar: '👩',
|
||||
originStationId: 'station-1',
|
||||
destinationStationId: 'station-2',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
claimedBy: null,
|
||||
deliveredBy: null,
|
||||
carIndex: null,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -328,7 +343,9 @@ describe('useTrackManagement', () => {
|
||||
})
|
||||
|
||||
test('updates passengers immediately during same route', () => {
|
||||
const updatedPassengers: Passenger[] = [{ ...mockPassengers[0], isBoarded: true }]
|
||||
const updatedPassengers: Passenger[] = [
|
||||
{ ...mockPassengers[0], claimedBy: 'player1', carIndex: 0 },
|
||||
]
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ passengers, position }) =>
|
||||
@@ -368,6 +385,8 @@ describe('useTrackManagement', () => {
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers: mockPassengers,
|
||||
maxCars: 3,
|
||||
carSpacing: 7,
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useComplementRace } from '@/arcade-games/complement-race/Provider'
|
||||
import { calculateMaxConcurrentPassengers, generatePassengers } from '../lib/passengerGenerator'
|
||||
import { useSoundEffects } from './useSoundEffects'
|
||||
|
||||
/**
|
||||
@@ -44,26 +43,44 @@ export function useSteamJourney() {
|
||||
const gameStartTimeRef = useRef<number>(0)
|
||||
const lastUpdateRef = useRef<number>(0)
|
||||
const routeExitThresholdRef = useRef<number>(107) // Default for 1 car: 100 + 7
|
||||
const missedPassengersRef = useRef<Set<string>>(new Set()) // Track which passengers have been logged as missed
|
||||
const pendingBoardingRef = useRef<Set<string>>(new Set()) // Track passengers with pending boarding requests across frames
|
||||
const previousTrainPositionRef = useRef<number>(0) // Track previous position to detect threshold crossings
|
||||
|
||||
// Initialize game start time and generate initial passengers
|
||||
// Initialize game start time
|
||||
useEffect(() => {
|
||||
if (state.isGameActive && state.style === 'sprint' && gameStartTimeRef.current === 0) {
|
||||
gameStartTimeRef.current = Date.now()
|
||||
lastUpdateRef.current = Date.now()
|
||||
|
||||
// Generate initial passengers if none exist
|
||||
if (state.passengers.length === 0) {
|
||||
const newPassengers = generatePassengers(state.stations)
|
||||
dispatch({ type: 'GENERATE_PASSENGERS', passengers: newPassengers })
|
||||
|
||||
// Calculate and store exit threshold for this route
|
||||
const CAR_SPACING = 7
|
||||
const maxPassengers = calculateMaxConcurrentPassengers(newPassengers, state.stations)
|
||||
const maxCars = Math.max(1, maxPassengers)
|
||||
routeExitThresholdRef.current = 100 + maxCars * CAR_SPACING
|
||||
}
|
||||
}
|
||||
}, [state.isGameActive, state.style, state.stations, state.passengers.length, dispatch])
|
||||
}, [state.isGameActive, state.style, state.stations, state.passengers])
|
||||
|
||||
// Calculate exit threshold when route changes or config updates
|
||||
useEffect(() => {
|
||||
if (state.passengers.length > 0 && state.stations.length > 0) {
|
||||
const CAR_SPACING = 7
|
||||
// Use server-calculated maxConcurrentPassengers
|
||||
const maxCars = Math.max(1, state.maxConcurrentPassengers || 3)
|
||||
routeExitThresholdRef.current = 100 + maxCars * CAR_SPACING
|
||||
}
|
||||
}, [state.currentRoute, state.passengers, state.stations, state.maxConcurrentPassengers])
|
||||
|
||||
// Clean up pendingBoardingRef when passengers are claimed/delivered or route changes
|
||||
useEffect(() => {
|
||||
// Remove passengers from pending set if they've been claimed or delivered
|
||||
state.passengers.forEach((passenger) => {
|
||||
if (passenger.claimedBy !== null || passenger.deliveredBy !== null) {
|
||||
pendingBoardingRef.current.delete(passenger.id)
|
||||
}
|
||||
})
|
||||
}, [state.passengers])
|
||||
|
||||
// Clear all pending boarding requests when route changes
|
||||
useEffect(() => {
|
||||
pendingBoardingRef.current.clear()
|
||||
missedPassengersRef.current.clear()
|
||||
previousTrainPositionRef.current = 0 // Reset previous position for new route
|
||||
}, [state.currentRoute])
|
||||
|
||||
// Momentum decay and position update loop
|
||||
useEffect(() => {
|
||||
@@ -77,116 +94,48 @@ export function useSteamJourney() {
|
||||
|
||||
// Steam Sprint is infinite - no time limit
|
||||
|
||||
// Get decay rate based on timeout setting (skill level)
|
||||
const decayRate =
|
||||
MOMENTUM_DECAY_RATES[state.timeoutSetting as keyof typeof MOMENTUM_DECAY_RATES] ||
|
||||
MOMENTUM_DECAY_RATES.normal
|
||||
|
||||
// Calculate momentum decay for this frame
|
||||
const momentumLoss = (decayRate * deltaTime) / 1000
|
||||
|
||||
// Update momentum (don't go below 0)
|
||||
const newMomentum = Math.max(0, state.momentum - momentumLoss)
|
||||
|
||||
// Calculate speed from momentum (% per second)
|
||||
const speed = newMomentum * SPEED_MULTIPLIER
|
||||
|
||||
// Update train position (accumulate, never go backward)
|
||||
// Allow position to go past 100% so entire train (including cars) can exit tunnel
|
||||
const positionDelta = (speed * deltaTime) / 1000
|
||||
const trainPosition = state.trainPosition + positionDelta
|
||||
|
||||
// Calculate pressure (0-150 PSI) - based on momentum as percentage of max
|
||||
const maxMomentum = 100 // Theoretical max momentum
|
||||
const pressure = Math.min(150, (newMomentum / maxMomentum) * 150)
|
||||
|
||||
// Update state
|
||||
dispatch({
|
||||
type: 'UPDATE_STEAM_JOURNEY',
|
||||
momentum: newMomentum,
|
||||
trainPosition,
|
||||
pressure,
|
||||
elapsedTime: elapsed,
|
||||
})
|
||||
// Train position, momentum, and pressure are all managed by the Provider's game loop
|
||||
// This hook only reads those values and handles game logic (boarding, delivery, route completion)
|
||||
const trainPosition = state.trainPosition
|
||||
|
||||
// Check for passengers that should board
|
||||
// Passengers board when an EMPTY car reaches their station
|
||||
const CAR_SPACING = 7 // Must match SteamTrainJourney component
|
||||
const maxPassengers = calculateMaxConcurrentPassengers(state.passengers, state.stations)
|
||||
const maxCars = Math.max(1, maxPassengers)
|
||||
const currentBoardedPassengers = state.passengers.filter((p) => p.isBoarded && !p.isDelivered)
|
||||
// Use server-calculated maxConcurrentPassengers (updates per route based on passenger layout)
|
||||
const maxCars = Math.max(1, state.maxConcurrentPassengers || 3)
|
||||
|
||||
// Debug logging flag - enable when debugging passenger boarding issues
|
||||
// TO ENABLE: Change this to true, save, and the logs will appear in the browser console
|
||||
// When you see passengers getting left behind, copy the entire console log and paste into Claude Code
|
||||
const DEBUG_PASSENGER_BOARDING = false
|
||||
|
||||
if (DEBUG_PASSENGER_BOARDING) {
|
||||
console.log('\n'.repeat(3))
|
||||
console.log('='.repeat(80))
|
||||
console.log('🚂 PASSENGER BOARDING DEBUG LOG')
|
||||
console.log('='.repeat(80))
|
||||
console.log('ISSUE: Passengers are getting left behind at stations')
|
||||
console.log('PURPOSE: This log captures all state during boarding/delivery logic')
|
||||
console.log('USAGE: Copy this entire log and paste into Claude Code for debugging')
|
||||
console.log('='.repeat(80))
|
||||
console.log('\n📊 CURRENT FRAME STATE:')
|
||||
console.log(` Train Position: ${trainPosition.toFixed(2)}`)
|
||||
console.log(` Speed: ${speed.toFixed(2)}% per second`)
|
||||
console.log(` Momentum: ${newMomentum.toFixed(2)}`)
|
||||
console.log(` Max Cars: ${maxCars}`)
|
||||
console.log(` Car Spacing: ${CAR_SPACING}`)
|
||||
console.log(` Distance Tolerance: 5`)
|
||||
|
||||
console.log('\n🚉 STATIONS:')
|
||||
state.stations.forEach((station) => {
|
||||
console.log(` ${station.emoji} ${station.name} (ID: ${station.id})`)
|
||||
console.log(` Position: ${station.position}`)
|
||||
})
|
||||
|
||||
console.log('\n👥 ALL PASSENGERS:')
|
||||
state.passengers.forEach((p, idx) => {
|
||||
const origin = state.stations.find((s) => s.id === p.originStationId)
|
||||
const dest = state.stations.find((s) => s.id === p.destinationStationId)
|
||||
console.log(` [${idx}] ${p.name} (ID: ${p.id})`)
|
||||
// Debug: Log train configuration at start (only once per route)
|
||||
if (trainPosition < 1 && state.passengers.length > 0) {
|
||||
const lastLoggedRoute = (window as any).__lastLoggedRoute || 0
|
||||
if (lastLoggedRoute !== state.currentRoute) {
|
||||
console.log(
|
||||
` Status: ${p.isDelivered ? 'DELIVERED' : p.isBoarded ? 'BOARDED' : 'WAITING'}`
|
||||
`\n🚆 ROUTE ${state.currentRoute} START - Train has ${maxCars} cars (server maxConcurrentPassengers: ${state.maxConcurrentPassengers}) for ${state.passengers.length} passengers`
|
||||
)
|
||||
console.log(
|
||||
` Route: ${origin?.emoji} ${origin?.name} (pos ${origin?.position}) → ${dest?.emoji} ${dest?.name} (pos ${dest?.position})`
|
||||
)
|
||||
console.log(` Urgent: ${p.isUrgent}`)
|
||||
})
|
||||
|
||||
console.log('\n🚃 CAR POSITIONS:')
|
||||
for (let i = 0; i < maxCars; i++) {
|
||||
const carPos = Math.max(0, trainPosition - (i + 1) * CAR_SPACING)
|
||||
console.log(` Car ${i}: position ${carPos.toFixed(2)}`)
|
||||
state.passengers.forEach((p) => {
|
||||
const origin = state.stations.find((s) => s.id === p.originStationId)
|
||||
const dest = state.stations.find((s) => s.id === p.destinationStationId)
|
||||
console.log(
|
||||
` 📍 ${p.name}: ${origin?.emoji} ${origin?.name} (${origin?.position}) → ${dest?.emoji} ${dest?.name} (${dest?.position}) ${p.isUrgent ? '⚡' : ''}`
|
||||
)
|
||||
})
|
||||
console.log('') // Blank line for readability
|
||||
;(window as any).__lastLoggedRoute = state.currentRoute
|
||||
}
|
||||
|
||||
console.log('\n🔍 CURRENTLY BOARDED PASSENGERS:')
|
||||
currentBoardedPassengers.forEach((p, carIndex) => {
|
||||
const carPos = Math.max(0, trainPosition - (carIndex + 1) * CAR_SPACING)
|
||||
const dest = state.stations.find((s) => s.id === p.destinationStationId)
|
||||
const distToDest = Math.abs(carPos - (dest?.position || 0))
|
||||
console.log(` Car ${carIndex}: ${p.name}`)
|
||||
console.log(` Car position: ${carPos.toFixed(2)}`)
|
||||
console.log(` Destination: ${dest?.emoji} ${dest?.name} (pos ${dest?.position})`)
|
||||
console.log(` Distance to dest: ${distToDest.toFixed(2)}`)
|
||||
console.log(` Will deliver: ${distToDest < 5 ? 'YES' : 'NO'}`)
|
||||
})
|
||||
}
|
||||
const currentBoardedPassengers = state.passengers.filter(
|
||||
(p) => p.claimedBy !== null && p.deliveredBy === null
|
||||
)
|
||||
|
||||
// FIRST: Identify which passengers will be delivered in this frame
|
||||
const passengersToDeliver = new Set<string>()
|
||||
currentBoardedPassengers.forEach((passenger, carIndex) => {
|
||||
if (!passenger || passenger.isDelivered) return
|
||||
currentBoardedPassengers.forEach((passenger) => {
|
||||
if (!passenger || passenger.deliveredBy !== null || passenger.carIndex === null) return
|
||||
|
||||
const station = state.stations.find((s) => s.id === passenger.destinationStationId)
|
||||
if (!station) return
|
||||
|
||||
// Calculate this passenger's car position
|
||||
const carPosition = Math.max(0, trainPosition - (carIndex + 1) * CAR_SPACING)
|
||||
// Calculate this passenger's car position using PHYSICAL carIndex
|
||||
const carPosition = Math.max(0, trainPosition - (passenger.carIndex + 1) * CAR_SPACING)
|
||||
const distance = Math.abs(carPosition - station.position)
|
||||
|
||||
// If this car is at the destination station (within 5% tolerance), mark for delivery
|
||||
@@ -195,159 +144,161 @@ export function useSteamJourney() {
|
||||
}
|
||||
})
|
||||
|
||||
// Build a map of which cars are occupied (excluding passengers being delivered this frame)
|
||||
// Build a map of which cars are occupied (using PHYSICAL car index, not array index!)
|
||||
// This is critical: passenger.carIndex stores the physical car (0-N) they're seated in
|
||||
const occupiedCars = new Map<number, (typeof currentBoardedPassengers)[0]>()
|
||||
currentBoardedPassengers.forEach((passenger, arrayIndex) => {
|
||||
currentBoardedPassengers.forEach((passenger) => {
|
||||
// Don't count a car as occupied if its passenger is being delivered this frame
|
||||
if (!passengersToDeliver.has(passenger.id)) {
|
||||
occupiedCars.set(arrayIndex, passenger)
|
||||
if (!passengersToDeliver.has(passenger.id) && passenger.carIndex !== null) {
|
||||
occupiedCars.set(passenger.carIndex, passenger) // Use physical carIndex, NOT array index!
|
||||
}
|
||||
})
|
||||
|
||||
if (DEBUG_PASSENGER_BOARDING) {
|
||||
console.log('\n📦 PASSENGERS TO DELIVER THIS FRAME:')
|
||||
if (passengersToDeliver.size === 0) {
|
||||
console.log(' None')
|
||||
} else {
|
||||
passengersToDeliver.forEach((id) => {
|
||||
const p = state.passengers.find((passenger) => passenger.id === id)
|
||||
console.log(` - ${p?.name} (ID: ${id})`)
|
||||
})
|
||||
}
|
||||
|
||||
console.log('\n🚗 OCCUPIED CARS (after excluding deliveries):')
|
||||
if (occupiedCars.size === 0) {
|
||||
console.log(' All cars are empty')
|
||||
} else {
|
||||
occupiedCars.forEach((passenger, carIndex) => {
|
||||
console.log(` Car ${carIndex}: ${passenger.name}`)
|
||||
})
|
||||
}
|
||||
|
||||
console.log('\n🔄 BOARDING ATTEMPTS:')
|
||||
}
|
||||
|
||||
// Track which cars are assigned in THIS frame to prevent double-boarding
|
||||
const carsAssignedThisFrame = new Set<number>()
|
||||
|
||||
// Find waiting passengers whose origin station has an empty car nearby
|
||||
state.passengers.forEach((passenger) => {
|
||||
if (passenger.isBoarded || passenger.isDelivered) return
|
||||
|
||||
const station = state.stations.find((s) => s.id === passenger.originStationId)
|
||||
if (!station) return
|
||||
|
||||
if (DEBUG_PASSENGER_BOARDING) {
|
||||
console.log(
|
||||
`\n Passenger: ${passenger.name} waiting at ${station.emoji} ${station.name} (pos ${station.position})`
|
||||
)
|
||||
}
|
||||
|
||||
// Check if any empty car is at this station
|
||||
// Cars are at positions: trainPosition - 7, trainPosition - 14, etc.
|
||||
let boarded = false
|
||||
for (let carIndex = 0; carIndex < maxCars; carIndex++) {
|
||||
const carPosition = Math.max(0, trainPosition - (carIndex + 1) * CAR_SPACING)
|
||||
const distance = Math.abs(carPosition - station.position)
|
||||
|
||||
if (DEBUG_PASSENGER_BOARDING) {
|
||||
const isOccupied = occupiedCars.has(carIndex)
|
||||
const isAssigned = carsAssignedThisFrame.has(carIndex)
|
||||
const inRange = distance < 5
|
||||
const occupant = occupiedCars.get(carIndex)
|
||||
|
||||
console.log(` Car ${carIndex} @ pos ${carPosition.toFixed(2)}:`)
|
||||
console.log(` Distance to station: ${distance.toFixed(2)}`)
|
||||
console.log(` In range (<5): ${inRange}`)
|
||||
console.log(
|
||||
` Occupied: ${isOccupied}${isOccupied ? ` (by ${occupant?.name})` : ''}`
|
||||
)
|
||||
console.log(` Assigned this frame: ${isAssigned}`)
|
||||
console.log(` Can board: ${!isOccupied && !isAssigned && inRange}`)
|
||||
}
|
||||
|
||||
// Skip if this car already has a passenger OR was assigned this frame
|
||||
if (occupiedCars.has(carIndex) || carsAssignedThisFrame.has(carIndex)) continue
|
||||
|
||||
const distance2 = Math.abs(carPosition - station.position)
|
||||
|
||||
// If car is at or near station (within 5% tolerance for fast trains), board this passenger
|
||||
// Increased tolerance to ensure fast-moving trains don't miss passengers
|
||||
if (distance2 < 5) {
|
||||
if (DEBUG_PASSENGER_BOARDING) {
|
||||
console.log(` ✅ BOARDING ${passenger.name} onto Car ${carIndex}`)
|
||||
}
|
||||
dispatch({
|
||||
type: 'BOARD_PASSENGER',
|
||||
passengerId: passenger.id,
|
||||
})
|
||||
// Mark this car as assigned in this frame
|
||||
carsAssignedThisFrame.add(carIndex)
|
||||
boarded = true
|
||||
return // Board this passenger and move on
|
||||
}
|
||||
}
|
||||
|
||||
if (DEBUG_PASSENGER_BOARDING && !boarded) {
|
||||
console.log(` ❌ ${passenger.name} NOT BOARDED - no suitable car found`)
|
||||
}
|
||||
})
|
||||
|
||||
if (DEBUG_PASSENGER_BOARDING) {
|
||||
console.log('\n🎯 DELIVERY ATTEMPTS:')
|
||||
}
|
||||
|
||||
// Check for deliverable passengers
|
||||
// Passengers disembark when THEIR car reaches their destination
|
||||
currentBoardedPassengers.forEach((passenger, carIndex) => {
|
||||
if (!passenger || passenger.isDelivered) return
|
||||
// PRIORITY 1: Process deliveries FIRST (dispatch DELIVER moves before BOARD moves)
|
||||
// This ensures the server frees up cars before processing new boarding requests
|
||||
currentBoardedPassengers.forEach((passenger) => {
|
||||
if (!passenger || passenger.deliveredBy !== null || passenger.carIndex === null) return
|
||||
|
||||
const station = state.stations.find((s) => s.id === passenger.destinationStationId)
|
||||
if (!station) return
|
||||
|
||||
// Calculate this passenger's car position
|
||||
const carPosition = Math.max(0, trainPosition - (carIndex + 1) * CAR_SPACING)
|
||||
// Calculate this passenger's car position using PHYSICAL carIndex
|
||||
const carPosition = Math.max(0, trainPosition - (passenger.carIndex + 1) * CAR_SPACING)
|
||||
const distance = Math.abs(carPosition - station.position)
|
||||
|
||||
// If this car is at the destination station (within 5% tolerance), deliver
|
||||
if (distance < 5) {
|
||||
if (DEBUG_PASSENGER_BOARDING) {
|
||||
console.log(
|
||||
` ✅ DELIVERING ${passenger.name} from Car ${carIndex} to ${station.emoji} ${station.name}`
|
||||
)
|
||||
console.log(
|
||||
` Car position: ${carPosition.toFixed(2)}, Station: ${station.position}, Distance: ${distance.toFixed(2)}`
|
||||
)
|
||||
}
|
||||
const points = passenger.isUrgent ? 20 : 10
|
||||
console.log(
|
||||
`🎯 DELIVERY: ${passenger.name} delivered from Car ${passenger.carIndex} to ${station.emoji} ${station.name} (+${points} pts) (trainPos=${trainPosition.toFixed(1)}, carPos=${carPosition.toFixed(1)}, stationPos=${station.position})`
|
||||
)
|
||||
dispatch({
|
||||
type: 'DELIVER_PASSENGER',
|
||||
passengerId: passenger.id,
|
||||
points,
|
||||
})
|
||||
} else if (DEBUG_PASSENGER_BOARDING) {
|
||||
}
|
||||
})
|
||||
|
||||
// Debug: Log car states periodically at stations
|
||||
const isAtStation = state.stations.some((s) => Math.abs(trainPosition - s.position) < 3)
|
||||
if (isAtStation && Math.floor(trainPosition) !== Math.floor(state.trainPosition)) {
|
||||
const nearStation = state.stations.find((s) => Math.abs(trainPosition - s.position) < 3)
|
||||
console.log(
|
||||
`\n🚃 Train arriving at ${nearStation?.emoji} ${nearStation?.name} (trainPos=${trainPosition.toFixed(1)}) - ${maxCars} cars total:`
|
||||
)
|
||||
for (let i = 0; i < maxCars; i++) {
|
||||
const carPos = Math.max(0, trainPosition - (i + 1) * CAR_SPACING)
|
||||
const occupant = occupiedCars.get(i)
|
||||
if (occupant) {
|
||||
const dest = state.stations.find((s) => s.id === occupant.destinationStationId)
|
||||
console.log(
|
||||
` Car ${i}: @ ${carPos.toFixed(1)}% - ${occupant.name} → ${dest?.emoji} ${dest?.name}`
|
||||
)
|
||||
} else {
|
||||
console.log(` Car ${i}: @ ${carPos.toFixed(1)}% - EMPTY`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Track which cars are assigned in THIS frame to prevent double-boarding
|
||||
const carsAssignedThisFrame = new Set<number>()
|
||||
// Track which passengers are assigned in THIS frame to prevent same passenger boarding multiple cars
|
||||
const passengersAssignedThisFrame = new Set<string>()
|
||||
|
||||
// PRIORITY 2: Process boardings AFTER deliveries
|
||||
|
||||
// Find waiting passengers whose origin station has an empty car nearby
|
||||
state.passengers.forEach((passenger) => {
|
||||
// Skip if already claimed or delivered (optimistic update marks immediately)
|
||||
if (passenger.claimedBy !== null || passenger.deliveredBy !== null) return
|
||||
|
||||
// Skip if already assigned in this frame OR has a pending boarding request from previous frames
|
||||
if (
|
||||
passengersAssignedThisFrame.has(passenger.id) ||
|
||||
pendingBoardingRef.current.has(passenger.id)
|
||||
)
|
||||
return
|
||||
|
||||
const station = state.stations.find((s) => s.id === passenger.originStationId)
|
||||
if (!station) return
|
||||
|
||||
// Don't allow boarding if locomotive has passed too far beyond this station
|
||||
// Station stays open until the LAST car has passed (accounting for train length)
|
||||
const STATION_CLOSURE_BUFFER = 10 // Extra buffer beyond the last car
|
||||
const lastCarOffset = maxCars * CAR_SPACING // Distance from locomotive to last car
|
||||
const stationClosureThreshold = lastCarOffset + STATION_CLOSURE_BUFFER
|
||||
|
||||
if (trainPosition > station.position + stationClosureThreshold) {
|
||||
console.log(
|
||||
` ⏳ ${passenger.name} in Car ${carIndex} heading to ${station.emoji} ${station.name}`
|
||||
`❌ MISSED: ${passenger.name} at ${station.emoji} ${station.name} - train too far past (trainPos=${trainPosition.toFixed(1)}, station=${station.position}, threshold=${stationClosureThreshold})`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if any empty car is at this station
|
||||
// Cars are at positions: trainPosition - 7, trainPosition - 14, etc.
|
||||
let closestCarDistance = 999
|
||||
let closestCarReason = ''
|
||||
|
||||
for (let carIndex = 0; carIndex < maxCars; carIndex++) {
|
||||
const carPosition = Math.max(0, trainPosition - (carIndex + 1) * CAR_SPACING)
|
||||
const distance = Math.abs(carPosition - station.position)
|
||||
|
||||
if (distance < closestCarDistance) {
|
||||
closestCarDistance = distance
|
||||
if (occupiedCars.has(carIndex)) {
|
||||
const occupant = occupiedCars.get(carIndex)
|
||||
closestCarReason = `Car ${carIndex} occupied by ${occupant?.name}`
|
||||
} else if (carsAssignedThisFrame.has(carIndex)) {
|
||||
closestCarReason = `Car ${carIndex} just assigned`
|
||||
} else if (distance >= 5) {
|
||||
closestCarReason = `Car ${carIndex} too far (dist=${distance.toFixed(1)})`
|
||||
} else {
|
||||
closestCarReason = 'available'
|
||||
}
|
||||
}
|
||||
|
||||
// Skip if this car already has a passenger OR was assigned this frame
|
||||
if (occupiedCars.has(carIndex) || carsAssignedThisFrame.has(carIndex)) continue
|
||||
|
||||
// If car is at or near station (within 5% tolerance for fast trains), board this passenger
|
||||
if (distance < 5) {
|
||||
console.log(
|
||||
`🚂 BOARDING: ${passenger.name} boarding Car ${carIndex} at ${station.emoji} ${station.name} (trainPos=${trainPosition.toFixed(1)}, carPos=${carPosition.toFixed(1)}, stationPos=${station.position})`
|
||||
)
|
||||
|
||||
// Mark as pending BEFORE dispatch to prevent duplicate boarding attempts across frames
|
||||
pendingBoardingRef.current.add(passenger.id)
|
||||
|
||||
dispatch({
|
||||
type: 'BOARD_PASSENGER',
|
||||
passengerId: passenger.id,
|
||||
carIndex, // Pass physical car index to server
|
||||
})
|
||||
// Mark this car and passenger as assigned in this frame
|
||||
carsAssignedThisFrame.add(carIndex)
|
||||
passengersAssignedThisFrame.add(passenger.id)
|
||||
return // Board this passenger and move on
|
||||
}
|
||||
}
|
||||
|
||||
// If we get here, passenger wasn't boarded - log why
|
||||
if (closestCarDistance < 10) {
|
||||
// Only log if train is somewhat near
|
||||
console.log(
|
||||
` Car position: ${carPosition.toFixed(2)}, Station: ${station.position}, Distance: ${distance.toFixed(2)}`
|
||||
`⏸️ WAITING: ${passenger.name} at ${station.emoji} ${station.name} - ${closestCarReason} (trainPos=${trainPosition.toFixed(1)}, maxCars=${maxCars})`
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
if (DEBUG_PASSENGER_BOARDING) {
|
||||
console.log(`\n${'='.repeat(80)}`)
|
||||
console.log('END OF DEBUG LOG')
|
||||
console.log('='.repeat(80))
|
||||
}
|
||||
|
||||
// Check for route completion (entire train exits tunnel)
|
||||
// Use stored threshold (stable for entire route)
|
||||
const ENTIRE_TRAIN_EXIT_THRESHOLD = routeExitThresholdRef.current
|
||||
const previousPosition = previousTrainPositionRef.current
|
||||
|
||||
if (
|
||||
trainPosition >= ENTIRE_TRAIN_EXIT_THRESHOLD &&
|
||||
state.trainPosition < ENTIRE_TRAIN_EXIT_THRESHOLD
|
||||
previousPosition < ENTIRE_TRAIN_EXIT_THRESHOLD
|
||||
) {
|
||||
// Play celebration whistle
|
||||
playSound('train_whistle', 0.6)
|
||||
@@ -357,52 +308,24 @@ export function useSteamJourney() {
|
||||
|
||||
// Auto-advance to next route
|
||||
const nextRoute = state.currentRoute + 1
|
||||
console.log(
|
||||
`🏁 ROUTE COMPLETE: Train crossed exit threshold (${trainPosition.toFixed(1)} >= ${ENTIRE_TRAIN_EXIT_THRESHOLD}). Advancing to Route ${nextRoute}`
|
||||
)
|
||||
dispatch({
|
||||
type: 'START_NEW_ROUTE',
|
||||
routeNumber: nextRoute,
|
||||
stations: state.stations,
|
||||
})
|
||||
|
||||
// Generate new passengers
|
||||
const newPassengers = generatePassengers(state.stations)
|
||||
dispatch({ type: 'GENERATE_PASSENGERS', passengers: newPassengers })
|
||||
|
||||
// Calculate and store new exit threshold for next route
|
||||
const newMaxPassengers = calculateMaxConcurrentPassengers(newPassengers, state.stations)
|
||||
const newMaxCars = Math.max(1, newMaxPassengers)
|
||||
routeExitThresholdRef.current = 100 + newMaxCars * CAR_SPACING
|
||||
// Note: New passengers will be generated by the server when it handles START_NEW_ROUTE
|
||||
}
|
||||
|
||||
// Update previous position for next frame
|
||||
previousTrainPositionRef.current = trainPosition
|
||||
}, UPDATE_INTERVAL)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [
|
||||
state.isGameActive,
|
||||
state.style,
|
||||
state.momentum,
|
||||
state.trainPosition,
|
||||
state.timeoutSetting,
|
||||
state.passengers,
|
||||
state.stations,
|
||||
state.currentRoute,
|
||||
dispatch,
|
||||
playSound,
|
||||
])
|
||||
|
||||
// Auto-regenerate passengers when all are delivered
|
||||
useEffect(() => {
|
||||
if (!state.isGameActive || state.style !== 'sprint') return
|
||||
|
||||
// Check if all passengers are delivered
|
||||
const allDelivered = state.passengers.length > 0 && state.passengers.every((p) => p.isDelivered)
|
||||
|
||||
if (allDelivered) {
|
||||
// Generate new passengers after a short delay
|
||||
setTimeout(() => {
|
||||
const newPassengers = generatePassengers(state.stations)
|
||||
dispatch({ type: 'GENERATE_PASSENGERS', passengers: newPassengers })
|
||||
}, 1000)
|
||||
}
|
||||
}, [state.isGameActive, state.style, state.passengers, state.stations, dispatch])
|
||||
}, [state.isGameActive, state.style, state.timeoutSetting, dispatch, playSound])
|
||||
|
||||
// Add momentum on correct answer
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { Passenger, Station } from '../lib/gameTypes'
|
||||
import type { Passenger, Station } from '@/arcade-games/complement-race/types'
|
||||
import { generateLandmarks, type Landmark } from '../lib/landmarks'
|
||||
import type { RailroadTrackGenerator } from '../lib/RailroadTrackGenerator'
|
||||
|
||||
@@ -67,7 +67,7 @@ export function useTrackManagement({
|
||||
|
||||
// Apply pending track when train resets to beginning
|
||||
useEffect(() => {
|
||||
if (pendingTrackData && trainPosition < 0) {
|
||||
if (pendingTrackData && trainPosition <= 0) {
|
||||
setTrackData(pendingTrackData)
|
||||
previousRouteRef.current = currentRoute
|
||||
setPendingTrackData(null)
|
||||
@@ -77,22 +77,34 @@ export function useTrackManagement({
|
||||
// Manage passenger display during route transitions
|
||||
useEffect(() => {
|
||||
// Only switch to new passengers when:
|
||||
// 1. Train has reset to start position (< 0) - track has changed, OR
|
||||
// 2. Same route AND train is in middle of track (10-90%) - gameplay updates like boarding/delivering
|
||||
const trainReset = trainPosition < 0
|
||||
// 1. Train has reset to start position (<= 0) - track has changed, OR
|
||||
// 2. Same route AND (in middle of track OR passengers have changed state)
|
||||
const trainReset = trainPosition <= 0
|
||||
const sameRoute = currentRoute === displayRouteRef.current
|
||||
const inMiddleOfTrack = trainPosition >= 10 && trainPosition < 90 // Avoid start/end transition zones
|
||||
|
||||
// Detect if passenger states have changed (boarding or delivery)
|
||||
// This allows updates even when train is past 90% threshold
|
||||
const passengerStatesChanged =
|
||||
sameRoute &&
|
||||
passengers.some((p) => {
|
||||
const oldPassenger = displayPassengers.find((dp) => dp.id === p.id)
|
||||
return (
|
||||
oldPassenger &&
|
||||
(oldPassenger.claimedBy !== p.claimedBy || oldPassenger.deliveredBy !== p.deliveredBy)
|
||||
)
|
||||
})
|
||||
|
||||
if (trainReset) {
|
||||
// Train reset - update to new route's passengers
|
||||
setDisplayPassengers(passengers)
|
||||
displayRouteRef.current = currentRoute
|
||||
} else if (sameRoute && inMiddleOfTrack) {
|
||||
// Same route and train in middle of track - update passengers for gameplay changes (boarding/delivery)
|
||||
} else if (sameRoute && (inMiddleOfTrack || passengerStatesChanged)) {
|
||||
// Same route and either in middle of track OR passenger states changed - update for gameplay
|
||||
setDisplayPassengers(passengers)
|
||||
}
|
||||
// Otherwise, keep displaying old passengers until train resets
|
||||
}, [passengers, trainPosition, currentRoute])
|
||||
}, [passengers, displayPassengers, trainPosition, currentRoute])
|
||||
|
||||
// Generate ties and rails when path is ready
|
||||
useEffect(() => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { ComplementRaceGame } from './components/ComplementRaceGame'
|
||||
import { ComplementRaceProvider } from './context/ComplementRaceContext'
|
||||
import { ComplementRaceProvider } from '@/arcade-games/complement-race/Provider'
|
||||
|
||||
export default function ComplementRacePage() {
|
||||
return (
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { ComplementRaceGame } from '../components/ComplementRaceGame'
|
||||
import { ComplementRaceProvider } from '../context/ComplementRaceContext'
|
||||
import { ComplementRaceProvider } from '@/arcade-games/complement-race/Provider'
|
||||
|
||||
export default function PracticeModePage() {
|
||||
return (
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { ComplementRaceGame } from '../components/ComplementRaceGame'
|
||||
import { ComplementRaceProvider } from '../context/ComplementRaceContext'
|
||||
import { ComplementRaceProvider } from '@/arcade-games/complement-race/Provider'
|
||||
|
||||
export default function SprintModePage() {
|
||||
return (
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { ComplementRaceGame } from '../components/ComplementRaceGame'
|
||||
import { ComplementRaceProvider } from '../context/ComplementRaceContext'
|
||||
import { ComplementRaceProvider } from '@/arcade-games/complement-race/Provider'
|
||||
|
||||
export default function SurvivalModePage() {
|
||||
return (
|
||||
|
||||
@@ -1,128 +1,409 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useState } from 'react'
|
||||
import { useRoomData, useSetRoomGame } from '@/hooks/useRoomData'
|
||||
import { useViewerId } from '@/hooks/useViewerId'
|
||||
import { GAMES_CONFIG } from '@/components/GameSelector'
|
||||
import type { GameType } from '@/components/GameSelector'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { css } from '../../../styled-system/css'
|
||||
import { EnhancedChampionArena } from '../../components/EnhancedChampionArena'
|
||||
import { FullscreenProvider, useFullscreen } from '../../contexts/FullscreenContext'
|
||||
import { getAllGames, getGame, hasGame } from '@/lib/arcade/game-registry'
|
||||
|
||||
function ArcadeContent() {
|
||||
const { setFullscreenElement } = useFullscreen()
|
||||
const arcadeRef = useRef<HTMLDivElement>(null)
|
||||
/**
|
||||
* /arcade - Renders the game for the user's current room
|
||||
* Since users can only be in one room at a time, this is a simple singular route
|
||||
*
|
||||
* Shows game selection when no game is set, then shows the game itself once selected.
|
||||
* URL never changes - it's always /arcade regardless of selection, setup, or gameplay.
|
||||
*
|
||||
* Note: We show a friendly message with a link if no room exists to avoid navigation loops.
|
||||
*
|
||||
* Note: ModerationNotifications is handled by PageWithNav inside each game component,
|
||||
* so we don't need to render it here.
|
||||
*/
|
||||
export default function RoomPage() {
|
||||
const router = useRouter()
|
||||
const { roomData, isLoading } = useRoomData()
|
||||
const { data: viewerId } = useViewerId()
|
||||
const { mutate: setRoomGame } = useSetRoomGame()
|
||||
const [permissionError, setPermissionError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Register this component's main div as the fullscreen element
|
||||
if (arcadeRef.current) {
|
||||
setFullscreenElement(arcadeRef.current)
|
||||
}
|
||||
}, [setFullscreenElement])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={arcadeRef}
|
||||
className={css({
|
||||
minHeight: 'calc(100vh - 80px)', // Account for mini nav height
|
||||
background: 'linear-gradient(135deg, #0f0f23 0%, #1a1a3a 50%, #2d1b69 100%)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
py: { base: '4', md: '6' },
|
||||
})}
|
||||
>
|
||||
{/* Animated background elements */}
|
||||
// Show loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundImage: `
|
||||
radial-gradient(circle at 20% 80%, rgba(120, 119, 198, 0.3) 0%, transparent 50%),
|
||||
radial-gradient(circle at 80% 20%, rgba(255, 119, 198, 0.3) 0%, transparent 50%),
|
||||
radial-gradient(circle at 40% 40%, rgba(120, 219, 255, 0.2) 0%, transparent 50%)
|
||||
`,
|
||||
animation: 'arcadeFloat 20s ease-in-out infinite',
|
||||
})}
|
||||
/>
|
||||
|
||||
{/* Main Champion Arena - takes remaining space */}
|
||||
<div
|
||||
className={css({
|
||||
flex: 1,
|
||||
style={{
|
||||
display: 'flex',
|
||||
px: { base: '2', md: '4' },
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
minHeight: 0, // Important for flex children
|
||||
})}
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
fontSize: '18px',
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
<EnhancedChampionArena
|
||||
onConfigurePlayer={() => {}}
|
||||
Loading room...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show error if no room (instead of redirecting)
|
||||
if (!roomData) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
fontSize: '18px',
|
||||
color: '#666',
|
||||
gap: '1rem',
|
||||
}}
|
||||
>
|
||||
<div>No active room found</div>
|
||||
<a
|
||||
href="/arcade"
|
||||
style={{
|
||||
color: '#3b82f6',
|
||||
textDecoration: 'underline',
|
||||
}}
|
||||
>
|
||||
Go to Champion Arena
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show game selection if no game is set
|
||||
if (!roomData.gameName) {
|
||||
// Determine if current user is the host
|
||||
const currentMember = roomData.members.find((m) => m.userId === viewerId)
|
||||
const isHost = currentMember?.isCreator === true
|
||||
const hostMember = roomData.members.find((m) => m.isCreator)
|
||||
|
||||
const handleGameSelect = (gameType: GameType) => {
|
||||
console.log('[RoomPage] handleGameSelect called with gameType:', gameType)
|
||||
|
||||
// Check if user is host before allowing selection
|
||||
if (!isHost) {
|
||||
setPermissionError(
|
||||
`Only the room host can select a game. Ask ${hostMember?.displayName || 'the host'} to choose.`
|
||||
)
|
||||
// Clear error after 5 seconds
|
||||
setTimeout(() => setPermissionError(null), 5000)
|
||||
return
|
||||
}
|
||||
|
||||
// Clear any previous errors
|
||||
setPermissionError(null)
|
||||
|
||||
// All games are now in the registry
|
||||
if (hasGame(gameType)) {
|
||||
const gameDef = getGame(gameType)
|
||||
if (!gameDef?.manifest.available) {
|
||||
console.log('[RoomPage] Registry game not available, blocking selection')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[RoomPage] Selecting registry game:', gameType)
|
||||
setRoomGame(
|
||||
{
|
||||
roomId: roomData.id,
|
||||
gameName: gameType,
|
||||
},
|
||||
{
|
||||
onError: (error: any) => {
|
||||
console.error('[RoomPage] Failed to set game:', error)
|
||||
setPermissionError(
|
||||
error.message || 'Failed to select game. Only the host can change games.'
|
||||
)
|
||||
setTimeout(() => setPermissionError(null), 5000)
|
||||
},
|
||||
}
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[RoomPage] Unknown game type:', gameType)
|
||||
}
|
||||
|
||||
return (
|
||||
<PageWithNav
|
||||
navTitle="Choose Game"
|
||||
navEmoji="🎮"
|
||||
emphasizePlayerSelection={true}
|
||||
onExitSession={() => router.push('/arcade')}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background: 'rgba(255, 255, 255, 0.05)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
boxShadow: '0 20px 40px rgba(0, 0, 0, 0.3)',
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #0f0f23 0%, #1a1a3a 50%, #2d1b69 100%)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '4',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
>
|
||||
<h1
|
||||
className={css({
|
||||
fontSize: { base: '2xl', md: '3xl' },
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
mb: '4',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
Choose a Game
|
||||
</h1>
|
||||
|
||||
function ArcadePageWithRedirect() {
|
||||
return (
|
||||
<PageWithNav navTitle="Champion Arena" navEmoji="🏟️" emphasizePlayerSelection={true}>
|
||||
<ArcadeContent />
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
{/* Host info and permission messaging */}
|
||||
<div
|
||||
className={css({
|
||||
maxWidth: '800px',
|
||||
width: '100%',
|
||||
mb: '6',
|
||||
})}
|
||||
>
|
||||
{isHost ? (
|
||||
<div
|
||||
className={css({
|
||||
background: 'rgba(34, 197, 94, 0.1)',
|
||||
border: '1px solid rgba(34, 197, 94, 0.3)',
|
||||
borderRadius: '8px',
|
||||
padding: '12px 16px',
|
||||
color: '#86efac',
|
||||
fontSize: 'sm',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
👑 You're the room host. Select a game to start playing.
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={css({
|
||||
background: 'rgba(234, 179, 8, 0.1)',
|
||||
border: '1px solid rgba(234, 179, 8, 0.3)',
|
||||
borderRadius: '8px',
|
||||
padding: '12px 16px',
|
||||
color: '#fde047',
|
||||
fontSize: 'sm',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
⏳ Waiting for {hostMember?.displayName || 'the host'} to select a game...
|
||||
</div>
|
||||
)}
|
||||
|
||||
export default function ArcadePage() {
|
||||
return (
|
||||
<FullscreenProvider>
|
||||
<ArcadePageWithRedirect />
|
||||
</FullscreenProvider>
|
||||
)
|
||||
}
|
||||
{/* Permission error message */}
|
||||
{permissionError && (
|
||||
<div
|
||||
className={css({
|
||||
background: 'rgba(239, 68, 68, 0.1)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.3)',
|
||||
borderRadius: '8px',
|
||||
padding: '12px 16px',
|
||||
color: '#fca5a5',
|
||||
fontSize: 'sm',
|
||||
textAlign: 'center',
|
||||
mt: '3',
|
||||
})}
|
||||
>
|
||||
⚠️ {permissionError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
// Arcade-specific animations
|
||||
const arcadeAnimations = `
|
||||
@keyframes arcadeFloat {
|
||||
0%, 100% {
|
||||
transform: translateY(0px) rotate(0deg);
|
||||
opacity: 0.7;
|
||||
<div
|
||||
className={css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { base: '1fr', md: 'repeat(2, 1fr)' },
|
||||
gap: '4',
|
||||
maxWidth: '800px',
|
||||
width: '100%',
|
||||
})}
|
||||
>
|
||||
{/* Legacy games */}
|
||||
{Object.entries(GAMES_CONFIG).map(([gameType, config]: [string, any]) => {
|
||||
const isAvailable = !('available' in config) || config.available !== false
|
||||
const isDisabled = !isHost || !isAvailable
|
||||
return (
|
||||
<button
|
||||
key={gameType}
|
||||
onClick={() => handleGameSelect(gameType as GameType)}
|
||||
disabled={isDisabled}
|
||||
className={css({
|
||||
background: config.gradient,
|
||||
border: '2px solid',
|
||||
borderColor: config.borderColor || 'blue.200',
|
||||
borderRadius: '2xl',
|
||||
padding: '6',
|
||||
cursor: isDisabled ? 'not-allowed' : 'pointer',
|
||||
opacity: isDisabled ? 0.4 : 1,
|
||||
transition: 'all 0.3s ease',
|
||||
_hover: isDisabled
|
||||
? {}
|
||||
: {
|
||||
transform: 'translateY(-4px) scale(1.02)',
|
||||
boxShadow: '0 20px 40px rgba(59, 130, 246, 0.2)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '4xl',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
{config.icon}
|
||||
</div>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.900',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
{config.name}
|
||||
</h3>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'gray.600',
|
||||
})}
|
||||
>
|
||||
{config.description}
|
||||
</p>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Registry games */}
|
||||
{getAllGames().map((gameDef) => {
|
||||
const isAvailable = gameDef.manifest.available
|
||||
const isDisabled = !isHost || !isAvailable
|
||||
return (
|
||||
<button
|
||||
key={gameDef.manifest.name}
|
||||
onClick={() => handleGameSelect(gameDef.manifest.name)}
|
||||
disabled={isDisabled}
|
||||
style={{
|
||||
background: gameDef.manifest.gradient,
|
||||
borderColor: gameDef.manifest.borderColor,
|
||||
}}
|
||||
className={css({
|
||||
border: '2px solid',
|
||||
borderRadius: '2xl',
|
||||
padding: '6',
|
||||
cursor: isDisabled ? 'not-allowed' : 'pointer',
|
||||
opacity: isDisabled ? 0.4 : 1,
|
||||
transition: 'all 0.3s ease',
|
||||
_hover: isDisabled
|
||||
? {}
|
||||
: {
|
||||
transform: 'translateY(-4px) scale(1.02)',
|
||||
boxShadow: '0 20px 40px rgba(59, 130, 246, 0.2)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '4xl',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
{gameDef.manifest.icon}
|
||||
</div>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.900',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
{gameDef.manifest.displayName}
|
||||
</h3>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'gray.600',
|
||||
})}
|
||||
>
|
||||
{gameDef.manifest.description}
|
||||
</p>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
33% {
|
||||
transform: translateY(-20px) rotate(1deg);
|
||||
opacity: 1;
|
||||
|
||||
// Check if this is a registry game first
|
||||
if (hasGame(roomData.gameName)) {
|
||||
const gameDef = getGame(roomData.gameName)
|
||||
if (!gameDef) {
|
||||
return (
|
||||
<PageWithNav
|
||||
navTitle="Game Not Found"
|
||||
navEmoji="⚠️"
|
||||
emphasizePlayerSelection={true}
|
||||
onExitSession={() => router.push('/arcade')}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
fontSize: '18px',
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
Game "{roomData.gameName}" not found in registry
|
||||
</div>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
|
||||
// Render registry game dynamically
|
||||
const { Provider, GameComponent } = gameDef
|
||||
return (
|
||||
<Provider>
|
||||
<GameComponent />
|
||||
</Provider>
|
||||
)
|
||||
}
|
||||
66% {
|
||||
transform: translateY(-10px) rotate(-1deg);
|
||||
opacity: 0.8;
|
||||
|
||||
// Render legacy games based on room's gameName
|
||||
switch (roomData.gameName) {
|
||||
// TODO: Add other legacy games (complement-race, etc.) once migrated
|
||||
default:
|
||||
return (
|
||||
<PageWithNav
|
||||
navTitle="Game Not Available"
|
||||
navEmoji="⚠️"
|
||||
emphasizePlayerSelection={true}
|
||||
onExitSession={() => router.push('/arcade')}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
fontSize: '18px',
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
Game "{roomData.gameName}" not yet supported
|
||||
</div>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes arcadePulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 20px rgba(96, 165, 250, 0.3);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 40px rgba(96, 165, 250, 0.6);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
// Inject arcade animations
|
||||
if (typeof document !== 'undefined' && !document.getElementById('arcade-animations')) {
|
||||
const style = document.createElement('style')
|
||||
style.id = 'arcade-animations'
|
||||
style.textContent = arcadeAnimations
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
|
||||
@@ -1,358 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useRoomData, useSetRoomGame } from '@/hooks/useRoomData'
|
||||
import { GAMES_CONFIG } from '@/components/GameSelector'
|
||||
import type { GameType } from '@/components/GameSelector'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { getAllGames, getGame, hasGame } from '@/lib/arcade/game-registry'
|
||||
|
||||
// Map GameType keys to internal game names
|
||||
// Note: "battle-arena" removed - now handled by game registry as "matching"
|
||||
const GAME_TYPE_TO_NAME: Record<GameType, string> = {
|
||||
'complement-race': 'complement-race',
|
||||
'master-organizer': 'master-organizer',
|
||||
}
|
||||
|
||||
/**
|
||||
* /arcade/room - Renders the game for the user's current room
|
||||
* Since users can only be in one room at a time, this is a simple singular route
|
||||
*
|
||||
* Shows game selection when no game is set, then shows the game itself once selected.
|
||||
* URL never changes - it's always /arcade/room regardless of selection, setup, or gameplay.
|
||||
*
|
||||
* Note: We don't redirect to /arcade if no room exists to avoid navigation loops.
|
||||
* Instead, we show a friendly message with a link back to the Champion Arena.
|
||||
*
|
||||
* Note: ModerationNotifications is handled by PageWithNav inside each game component,
|
||||
* so we don't need to render it here.
|
||||
*/
|
||||
export default function RoomPage() {
|
||||
const router = useRouter()
|
||||
const { roomData, isLoading } = useRoomData()
|
||||
const { mutate: setRoomGame } = useSetRoomGame()
|
||||
|
||||
// Show loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
fontSize: '18px',
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
Loading room...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show error if no room (instead of redirecting)
|
||||
if (!roomData) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
fontSize: '18px',
|
||||
color: '#666',
|
||||
gap: '1rem',
|
||||
}}
|
||||
>
|
||||
<div>No active room found</div>
|
||||
<a
|
||||
href="/arcade"
|
||||
style={{
|
||||
color: '#3b82f6',
|
||||
textDecoration: 'underline',
|
||||
}}
|
||||
>
|
||||
Go to Champion Arena
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show game selection if no game is set
|
||||
if (!roomData.gameName) {
|
||||
const handleGameSelect = (gameType: GameType) => {
|
||||
console.log('[RoomPage] handleGameSelect called with gameType:', gameType)
|
||||
|
||||
// Check if it's a registry game first
|
||||
if (hasGame(gameType)) {
|
||||
const gameDef = getGame(gameType)
|
||||
if (!gameDef?.manifest.available) {
|
||||
console.log('[RoomPage] Registry game not available, blocking selection')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[RoomPage] Selecting registry game:', gameType)
|
||||
setRoomGame({
|
||||
roomId: roomData.id,
|
||||
gameName: gameType, // Use the game name directly for registry games
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Legacy game handling
|
||||
const gameConfig = GAMES_CONFIG[gameType as keyof typeof GAMES_CONFIG]
|
||||
if (!gameConfig) {
|
||||
console.log('[RoomPage] Unknown game type:', gameType)
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[RoomPage] Game config:', {
|
||||
name: gameConfig.name,
|
||||
available: 'available' in gameConfig ? gameConfig.available : true,
|
||||
})
|
||||
|
||||
if ('available' in gameConfig && gameConfig.available === false) {
|
||||
console.log('[RoomPage] Game not available, blocking selection')
|
||||
return // Don't allow selecting unavailable games
|
||||
}
|
||||
|
||||
// Map GameType to internal game name
|
||||
const internalGameName = GAME_TYPE_TO_NAME[gameType]
|
||||
console.log('[RoomPage] Mapping:', {
|
||||
gameType,
|
||||
internalGameName,
|
||||
mappingExists: !!internalGameName,
|
||||
})
|
||||
|
||||
console.log('[RoomPage] Calling setRoomGame with:', {
|
||||
roomId: roomData.id,
|
||||
gameName: internalGameName,
|
||||
preservingGameConfig: true,
|
||||
})
|
||||
|
||||
// Don't pass gameConfig - we want to preserve existing settings for all games
|
||||
setRoomGame({
|
||||
roomId: roomData.id,
|
||||
gameName: internalGameName,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<PageWithNav
|
||||
navTitle="Choose Game"
|
||||
navEmoji="🎮"
|
||||
emphasizePlayerSelection={true}
|
||||
onExitSession={() => router.push('/arcade')}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #0f0f23 0%, #1a1a3a 50%, #2d1b69 100%)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '4',
|
||||
})}
|
||||
>
|
||||
<h1
|
||||
className={css({
|
||||
fontSize: { base: '2xl', md: '3xl' },
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
mb: '8',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
Choose a Game
|
||||
</h1>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { base: '1fr', md: 'repeat(2, 1fr)' },
|
||||
gap: '4',
|
||||
maxWidth: '800px',
|
||||
width: '100%',
|
||||
})}
|
||||
>
|
||||
{/* Legacy games */}
|
||||
{Object.entries(GAMES_CONFIG).map(([gameType, config]) => {
|
||||
const isAvailable = !('available' in config) || config.available !== false
|
||||
return (
|
||||
<button
|
||||
key={gameType}
|
||||
onClick={() => handleGameSelect(gameType as GameType)}
|
||||
disabled={!isAvailable}
|
||||
className={css({
|
||||
background: config.gradient,
|
||||
border: '2px solid',
|
||||
borderColor: config.borderColor || 'blue.200',
|
||||
borderRadius: '2xl',
|
||||
padding: '6',
|
||||
cursor: !isAvailable ? 'not-allowed' : 'pointer',
|
||||
opacity: !isAvailable ? 0.5 : 1,
|
||||
transition: 'all 0.3s ease',
|
||||
_hover: !isAvailable
|
||||
? {}
|
||||
: {
|
||||
transform: 'translateY(-4px) scale(1.02)',
|
||||
boxShadow: '0 20px 40px rgba(59, 130, 246, 0.2)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '4xl',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
{config.icon}
|
||||
</div>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.900',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
{config.name}
|
||||
</h3>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'gray.600',
|
||||
})}
|
||||
>
|
||||
{config.description}
|
||||
</p>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Registry games */}
|
||||
{getAllGames().map((gameDef) => {
|
||||
const isAvailable = gameDef.manifest.available
|
||||
return (
|
||||
<button
|
||||
key={gameDef.manifest.name}
|
||||
onClick={() => handleGameSelect(gameDef.manifest.name)}
|
||||
disabled={!isAvailable}
|
||||
className={css({
|
||||
background: gameDef.manifest.gradient,
|
||||
border: '2px solid',
|
||||
borderColor: gameDef.manifest.borderColor,
|
||||
borderRadius: '2xl',
|
||||
padding: '6',
|
||||
cursor: !isAvailable ? 'not-allowed' : 'pointer',
|
||||
opacity: !isAvailable ? 0.5 : 1,
|
||||
transition: 'all 0.3s ease',
|
||||
_hover: !isAvailable
|
||||
? {}
|
||||
: {
|
||||
transform: 'translateY(-4px) scale(1.02)',
|
||||
boxShadow: '0 20px 40px rgba(59, 130, 246, 0.2)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '4xl',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
{gameDef.manifest.icon}
|
||||
</div>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.900',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
{gameDef.manifest.displayName}
|
||||
</h3>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'gray.600',
|
||||
})}
|
||||
>
|
||||
{gameDef.manifest.description}
|
||||
</p>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
|
||||
// Check if this is a registry game first
|
||||
if (hasGame(roomData.gameName)) {
|
||||
const gameDef = getGame(roomData.gameName)
|
||||
if (!gameDef) {
|
||||
return (
|
||||
<PageWithNav
|
||||
navTitle="Game Not Found"
|
||||
navEmoji="⚠️"
|
||||
emphasizePlayerSelection={true}
|
||||
onExitSession={() => router.push('/arcade')}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
fontSize: '18px',
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
Game "{roomData.gameName}" not found in registry
|
||||
</div>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
|
||||
// Render registry game dynamically
|
||||
const { Provider, GameComponent } = gameDef
|
||||
return (
|
||||
<Provider>
|
||||
<GameComponent />
|
||||
</Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// Render legacy games based on room's gameName
|
||||
switch (roomData.gameName) {
|
||||
// TODO: Add other legacy games (complement-race, etc.) once migrated
|
||||
default:
|
||||
return (
|
||||
<PageWithNav
|
||||
navTitle="Game Not Available"
|
||||
navEmoji="⚠️"
|
||||
emphasizePlayerSelection={true}
|
||||
onExitSession={() => router.push('/arcade')}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
fontSize: '18px',
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
Game "{roomData.gameName}" not yet supported
|
||||
</div>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
interface SpeechBubbleProps {
|
||||
message: string
|
||||
onHide: () => void
|
||||
}
|
||||
|
||||
export function SpeechBubble({ message, onHide }: SpeechBubbleProps) {
|
||||
const [isVisible, setIsVisible] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
// Auto-hide after 3.5s (line 11749-11752)
|
||||
const timer = setTimeout(() => {
|
||||
setIsVisible(false)
|
||||
setTimeout(onHide, 300) // Wait for fade-out animation
|
||||
}, 3500)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [onHide])
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 'calc(100% + 10px)',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
background: 'white',
|
||||
borderRadius: '15px',
|
||||
padding: '10px 15px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.2)',
|
||||
fontSize: '14px',
|
||||
whiteSpace: 'nowrap',
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transition: 'opacity 0.3s ease',
|
||||
zIndex: 10,
|
||||
pointerEvents: 'none',
|
||||
maxWidth: '250px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{message}
|
||||
{/* Tail pointing down */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '-8px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderLeft: '8px solid transparent',
|
||||
borderRight: '8px solid transparent',
|
||||
borderTop: '8px solid white',
|
||||
filter: 'drop-shadow(0 2px 2px rgba(0,0,0,0.1))',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
import type { AIRacer } from '../../lib/gameTypes'
|
||||
|
||||
export type CommentaryContext =
|
||||
| 'ahead'
|
||||
| 'behind'
|
||||
| 'adaptive_struggle'
|
||||
| 'adaptive_mastery'
|
||||
| 'player_passed'
|
||||
| 'ai_passed'
|
||||
| 'lapped'
|
||||
| 'desperate_catchup'
|
||||
|
||||
// Swift AI - Competitive personality (lines 11768-11834)
|
||||
export const swiftAICommentary: Record<CommentaryContext, string[]> = {
|
||||
ahead: [
|
||||
'💨 Eat my dust!',
|
||||
'🔥 Too slow for me!',
|
||||
"⚡ You can't catch me!",
|
||||
"🚀 I'm built for speed!",
|
||||
'🏃♂️ This is way too easy!',
|
||||
],
|
||||
behind: [
|
||||
'😤 Not over yet!',
|
||||
"💪 I'm just getting started!",
|
||||
'🔥 Watch me catch up to you!',
|
||||
"⚡ I'm coming for you!",
|
||||
'🏃♂️ This is my comeback!',
|
||||
],
|
||||
adaptive_struggle: [
|
||||
'😏 You struggling much?',
|
||||
'🤖 Math is easy for me!',
|
||||
'⚡ You need to think faster!',
|
||||
'🔥 Need me to slow down?',
|
||||
],
|
||||
adaptive_mastery: [
|
||||
"😮 You're actually impressive!",
|
||||
"🤔 You're getting faster...",
|
||||
'😤 Time for me to step it up!',
|
||||
'⚡ Not bad for a human!',
|
||||
],
|
||||
player_passed: [
|
||||
'😠 No way you just passed me!',
|
||||
"🔥 This isn't over!",
|
||||
"💨 I'm just getting warmed up!",
|
||||
"😤 Your lucky streak won't last!",
|
||||
"⚡ I'll be back in front of you soon!",
|
||||
],
|
||||
ai_passed: [
|
||||
'💨 See ya later, slowpoke!',
|
||||
'😎 Thanks for the warm-up!',
|
||||
"🔥 This is how it's done!",
|
||||
"⚡ I'll see you at the finish line!",
|
||||
'💪 Try to keep up with me!',
|
||||
],
|
||||
lapped: [
|
||||
'😡 You just lapped me?! No way!',
|
||||
'🤬 This is embarrassing for me!',
|
||||
"😤 I'm not going down without a fight!",
|
||||
'💢 How did you get so far ahead?!',
|
||||
'🔥 Time to show you my real speed!',
|
||||
"😠 You won't stay ahead for long!",
|
||||
],
|
||||
desperate_catchup: [
|
||||
"🚨 TURBO MODE ACTIVATED! I'm coming for you!",
|
||||
'💥 You forced me to unleash my true power!',
|
||||
'🔥 NO MORE MR. NICE AI! Time to go all out!',
|
||||
"⚡ I'm switching to MAXIMUM OVERDRIVE!",
|
||||
"😤 You made me angry - now you'll see what I can do!",
|
||||
"🚀 AFTERBURNERS ENGAGED! This isn't over!",
|
||||
],
|
||||
}
|
||||
|
||||
// Math Bot - Analytical personality (lines 11835-11901)
|
||||
export const mathBotCommentary: Record<CommentaryContext, string[]> = {
|
||||
ahead: [
|
||||
'📊 My performance is optimal!',
|
||||
'🤖 My logic beats your speed!',
|
||||
'📈 I have 87% win probability!',
|
||||
"⚙️ I'm perfectly calibrated!",
|
||||
'🔬 Science prevails over you!',
|
||||
],
|
||||
behind: [
|
||||
'🤔 Recalculating my strategy...',
|
||||
"📊 You're exceeding my projections!",
|
||||
'⚙️ Adjusting my parameters!',
|
||||
"🔬 I'm analyzing your technique!",
|
||||
"📈 You're a statistical anomaly!",
|
||||
],
|
||||
adaptive_struggle: [
|
||||
'📊 I detect inefficiencies in you!',
|
||||
'🔬 You should focus on patterns!',
|
||||
'⚙️ Use that extra time wisely!',
|
||||
'📈 You have room for improvement!',
|
||||
],
|
||||
adaptive_mastery: [
|
||||
'🤖 Your optimization is excellent!',
|
||||
'📊 Your metrics are impressive!',
|
||||
"⚙️ I'm updating my models because of you!",
|
||||
'🔬 You have near-AI efficiency!',
|
||||
],
|
||||
player_passed: [
|
||||
'🤖 Your strategy is fascinating!',
|
||||
"📊 You're an unexpected variable!",
|
||||
"⚙️ I'm adjusting my algorithms...",
|
||||
'🔬 Your execution is impressive!',
|
||||
"📈 I'm recalculating the odds!",
|
||||
],
|
||||
ai_passed: [
|
||||
'🤖 My efficiency is optimized!',
|
||||
'📊 Just as I calculated!',
|
||||
'⚙️ All my systems nominal!',
|
||||
'🔬 My logic prevails over you!',
|
||||
"📈 I'm at 96% confidence level!",
|
||||
],
|
||||
lapped: [
|
||||
'🤖 Error: You have exceeded my projections!',
|
||||
'📊 This outcome has 0.3% probability!',
|
||||
'⚙️ I need to recalibrate my systems!',
|
||||
'🔬 Your performance is... statistically improbable!',
|
||||
'📈 My confidence level just dropped to 12%!',
|
||||
'🤔 I must analyze your methodology!',
|
||||
],
|
||||
desperate_catchup: [
|
||||
'🤖 EMERGENCY PROTOCOL ACTIVATED! Initiating maximum speed!',
|
||||
'🚨 CRITICAL GAP DETECTED! Engaging catchup algorithms!',
|
||||
'⚙️ OVERCLOCKING MY PROCESSORS! Prepare for rapid acceleration!',
|
||||
'📊 PROBABILITY OF FAILURE: UNACCEPTABLE! Switching to turbo mode!',
|
||||
"🔬 HYPOTHESIS: You're about to see my true potential!",
|
||||
'📈 CONFIDENCE LEVEL: RISING! My comeback protocol is online!',
|
||||
],
|
||||
}
|
||||
|
||||
// Get AI commentary message (lines 11636-11657)
|
||||
export function getAICommentary(
|
||||
racer: AIRacer,
|
||||
context: CommentaryContext,
|
||||
_playerProgress: number,
|
||||
_aiProgress: number
|
||||
): string | null {
|
||||
// Check cooldown (line 11759-11761)
|
||||
const now = Date.now()
|
||||
if (now - racer.lastComment < racer.commentCooldown) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Select message set based on personality and context
|
||||
const messages =
|
||||
racer.personality === 'competitive' ? swiftAICommentary[context] : mathBotCommentary[context]
|
||||
|
||||
if (!messages || messages.length === 0) return null
|
||||
|
||||
// Return random message
|
||||
return messages[Math.floor(Math.random() * messages.length)]
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { AbacusReact } from '@soroban/abacus-react'
|
||||
|
||||
interface AbacusTargetProps {
|
||||
number: number // The complement number to display
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a small abacus showing a complement number inline in the equation
|
||||
* Used to help learners recognize the abacus representation of complement numbers
|
||||
*/
|
||||
export function AbacusTarget({ number }: AbacusTargetProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
lineHeight: 0,
|
||||
}}
|
||||
>
|
||||
<AbacusReact
|
||||
value={number}
|
||||
columns={1}
|
||||
interactive={false}
|
||||
showNumbers={false}
|
||||
hideInactiveBeads={true}
|
||||
scaleFactor={0.72}
|
||||
customStyles={{
|
||||
columnPosts: { opacity: 0 },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,373 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useComplementRace } from '../context/ComplementRaceContext'
|
||||
import { GameControls } from './GameControls'
|
||||
import { GameCountdown } from './GameCountdown'
|
||||
import { GameDisplay } from './GameDisplay'
|
||||
import { GameIntro } from './GameIntro'
|
||||
import { GameResults } from './GameResults'
|
||||
|
||||
export function ComplementRaceGame() {
|
||||
const { state } = useComplementRace()
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="game-page-root"
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
padding: '20px 8px',
|
||||
minHeight: '100vh',
|
||||
maxHeight: '100vh',
|
||||
background:
|
||||
state.style === 'sprint'
|
||||
? 'linear-gradient(to bottom, #2563eb 0%, #60a5fa 100%)'
|
||||
: 'radial-gradient(ellipse at center, #8db978 0%, #7ba565 40%, #6a9354 100%)',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* Background pattern - subtle grass texture */}
|
||||
{state.style !== 'sprint' && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
overflow: 'hidden',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
opacity: 0.15,
|
||||
}}
|
||||
>
|
||||
<svg width="100%" height="100%">
|
||||
<defs>
|
||||
<pattern
|
||||
id="grass-texture"
|
||||
x="0"
|
||||
y="0"
|
||||
width="40"
|
||||
height="40"
|
||||
patternUnits="userSpaceOnUse"
|
||||
>
|
||||
<rect width="40" height="40" fill="transparent" />
|
||||
<line x1="2" y1="5" x2="8" y2="5" stroke="#2d5016" strokeWidth="1" opacity="0.3" />
|
||||
<line
|
||||
x1="15"
|
||||
y1="8"
|
||||
x2="20"
|
||||
y2="8"
|
||||
stroke="#2d5016"
|
||||
strokeWidth="1"
|
||||
opacity="0.25"
|
||||
/>
|
||||
<line
|
||||
x1="25"
|
||||
y1="12"
|
||||
x2="32"
|
||||
y2="12"
|
||||
stroke="#2d5016"
|
||||
strokeWidth="1"
|
||||
opacity="0.2"
|
||||
/>
|
||||
<line
|
||||
x1="5"
|
||||
y1="18"
|
||||
x2="12"
|
||||
y2="18"
|
||||
stroke="#2d5016"
|
||||
strokeWidth="1"
|
||||
opacity="0.3"
|
||||
/>
|
||||
<line
|
||||
x1="28"
|
||||
y1="22"
|
||||
x2="35"
|
||||
y2="22"
|
||||
stroke="#2d5016"
|
||||
strokeWidth="1"
|
||||
opacity="0.25"
|
||||
/>
|
||||
<line
|
||||
x1="10"
|
||||
y1="30"
|
||||
x2="16"
|
||||
y2="30"
|
||||
stroke="#2d5016"
|
||||
strokeWidth="1"
|
||||
opacity="0.2"
|
||||
/>
|
||||
<line
|
||||
x1="22"
|
||||
y1="35"
|
||||
x2="28"
|
||||
y2="35"
|
||||
stroke="#2d5016"
|
||||
strokeWidth="1"
|
||||
opacity="0.3"
|
||||
/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#grass-texture)" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subtle tree clusters around edges - top-down view with gentle sway */}
|
||||
{state.style !== 'sprint' && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
overflow: 'hidden',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
}}
|
||||
>
|
||||
{/* Top-left tree cluster */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '5%',
|
||||
left: '3%',
|
||||
width: '80px',
|
||||
height: '80px',
|
||||
borderRadius: '50%',
|
||||
background: 'radial-gradient(circle, #4a7c3a 0%, #3d6630 60%, transparent 70%)',
|
||||
opacity: 0.2,
|
||||
filter: 'blur(4px)',
|
||||
animation: 'treeSway1 8s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Top-right tree cluster */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '8%',
|
||||
right: '5%',
|
||||
width: '100px',
|
||||
height: '100px',
|
||||
borderRadius: '50%',
|
||||
background: 'radial-gradient(circle, #4a7c3a 0%, #3d6630 60%, transparent 70%)',
|
||||
opacity: 0.18,
|
||||
filter: 'blur(5px)',
|
||||
animation: 'treeSway2 10s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Bottom-left tree cluster */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '10%',
|
||||
left: '8%',
|
||||
width: '90px',
|
||||
height: '90px',
|
||||
borderRadius: '50%',
|
||||
background: 'radial-gradient(circle, #4a7c3a 0%, #3d6630 60%, transparent 70%)',
|
||||
opacity: 0.15,
|
||||
filter: 'blur(4px)',
|
||||
animation: 'treeSway1 9s ease-in-out infinite reverse',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Bottom-right tree cluster */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '5%',
|
||||
right: '4%',
|
||||
width: '110px',
|
||||
height: '110px',
|
||||
borderRadius: '50%',
|
||||
background: 'radial-gradient(circle, #4a7c3a 0%, #3d6630 60%, transparent 70%)',
|
||||
opacity: 0.2,
|
||||
filter: 'blur(6px)',
|
||||
animation: 'treeSway2 11s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Additional smaller clusters for depth */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '40%',
|
||||
left: '2%',
|
||||
width: '60px',
|
||||
height: '60px',
|
||||
borderRadius: '50%',
|
||||
background: 'radial-gradient(circle, #4a7c3a 0%, #3d6630 60%, transparent 70%)',
|
||||
opacity: 0.12,
|
||||
filter: 'blur(3px)',
|
||||
animation: 'treeSway1 7s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '55%',
|
||||
right: '3%',
|
||||
width: '70px',
|
||||
height: '70px',
|
||||
borderRadius: '50%',
|
||||
background: 'radial-gradient(circle, #4a7c3a 0%, #3d6630 60%, transparent 70%)',
|
||||
opacity: 0.14,
|
||||
filter: 'blur(4px)',
|
||||
animation: 'treeSway2 8.5s ease-in-out infinite reverse',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Flying bird shadows - very subtle from aerial view */}
|
||||
{state.style !== 'sprint' && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
overflow: 'hidden',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '30%',
|
||||
left: '-5%',
|
||||
width: '15px',
|
||||
height: '8px',
|
||||
background: 'rgba(0, 0, 0, 0.08)',
|
||||
borderRadius: '50%',
|
||||
filter: 'blur(2px)',
|
||||
animation: 'birdFly1 20s linear infinite',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '60%',
|
||||
left: '-5%',
|
||||
width: '12px',
|
||||
height: '6px',
|
||||
background: 'rgba(0, 0, 0, 0.06)',
|
||||
borderRadius: '50%',
|
||||
filter: 'blur(2px)',
|
||||
animation: 'birdFly2 28s linear infinite',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '45%',
|
||||
left: '-5%',
|
||||
width: '10px',
|
||||
height: '5px',
|
||||
background: 'rgba(0, 0, 0, 0.05)',
|
||||
borderRadius: '50%',
|
||||
filter: 'blur(1px)',
|
||||
animation: 'birdFly1 35s linear infinite',
|
||||
animationDelay: '-12s',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subtle cloud shadows moving across field */}
|
||||
{state.style !== 'sprint' && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
overflow: 'hidden',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-10%',
|
||||
left: '-20%',
|
||||
width: '300px',
|
||||
height: '200px',
|
||||
background: 'radial-gradient(ellipse, rgba(0, 0, 0, 0.03) 0%, transparent 60%)',
|
||||
borderRadius: '50%',
|
||||
filter: 'blur(20px)',
|
||||
animation: 'cloudShadow1 45s linear infinite',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-10%',
|
||||
left: '-20%',
|
||||
width: '250px',
|
||||
height: '180px',
|
||||
background: 'radial-gradient(ellipse, rgba(0, 0, 0, 0.025) 0%, transparent 60%)',
|
||||
borderRadius: '50%',
|
||||
filter: 'blur(25px)',
|
||||
animation: 'cloudShadow2 60s linear infinite',
|
||||
animationDelay: '-20s',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CSS animations */}
|
||||
<style>{`
|
||||
@keyframes treeSway1 {
|
||||
0%, 100% { transform: scale(1) translate(0, 0); }
|
||||
25% { transform: scale(1.02) translate(2px, -1px); }
|
||||
50% { transform: scale(0.98) translate(-1px, 1px); }
|
||||
75% { transform: scale(1.01) translate(-2px, -1px); }
|
||||
}
|
||||
@keyframes treeSway2 {
|
||||
0%, 100% { transform: scale(1) translate(0, 0); }
|
||||
30% { transform: scale(1.015) translate(-2px, 1px); }
|
||||
60% { transform: scale(0.985) translate(2px, -1px); }
|
||||
80% { transform: scale(1.01) translate(1px, 1px); }
|
||||
}
|
||||
@keyframes birdFly1 {
|
||||
0% { transform: translate(0, 0); }
|
||||
100% { transform: translate(calc(100vw + 100px), -20vh); }
|
||||
}
|
||||
@keyframes birdFly2 {
|
||||
0% { transform: translate(0, 0); }
|
||||
100% { transform: translate(calc(100vw + 100px), 15vh); }
|
||||
}
|
||||
@keyframes cloudShadow1 {
|
||||
0% { transform: translate(0, 0); }
|
||||
100% { transform: translate(calc(100vw + 400px), 30vh); }
|
||||
}
|
||||
@keyframes cloudShadow2 {
|
||||
0% { transform: translate(0, 0); }
|
||||
100% { transform: translate(calc(100vw + 350px), -20vh); }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
margin: '0 auto',
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
{state.gamePhase === 'intro' && <GameIntro />}
|
||||
{state.gamePhase === 'controls' && <GameControls />}
|
||||
{state.gamePhase === 'countdown' && <GameCountdown />}
|
||||
{state.gamePhase === 'playing' && <GameDisplay />}
|
||||
{state.gamePhase === 'results' && <GameResults />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,480 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useComplementRace } from '../context/ComplementRaceContext'
|
||||
import type { ComplementDisplay, GameMode, GameStyle, TimeoutSetting } from '../lib/gameTypes'
|
||||
import { AbacusTarget } from './AbacusTarget'
|
||||
|
||||
export function GameControls() {
|
||||
const { state, dispatch } = useComplementRace()
|
||||
|
||||
const handleModeSelect = (mode: GameMode) => {
|
||||
dispatch({ type: 'SET_MODE', mode })
|
||||
}
|
||||
|
||||
const handleStyleSelect = (style: GameStyle) => {
|
||||
dispatch({ type: 'SET_STYLE', style })
|
||||
// Start the game immediately - no navigation needed
|
||||
if (style === 'sprint') {
|
||||
dispatch({ type: 'BEGIN_GAME' })
|
||||
} else {
|
||||
dispatch({ type: 'START_COUNTDOWN' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleTimeoutSelect = (timeout: TimeoutSetting) => {
|
||||
dispatch({ type: 'SET_TIMEOUT', timeout })
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'linear-gradient(to bottom, #0f172a 0%, #1e293b 50%, #334155 100%)',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* Animated background pattern */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundImage:
|
||||
'radial-gradient(circle at 20% 50%, rgba(59, 130, 246, 0.1) 0%, transparent 50%), radial-gradient(circle at 80% 80%, rgba(139, 92, 246, 0.1) 0%, transparent 50%)',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: '20px',
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: '32px',
|
||||
fontWeight: 'bold',
|
||||
background: 'linear-gradient(135deg, #60a5fa 0%, #a78bfa 100%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text',
|
||||
margin: 0,
|
||||
letterSpacing: '-0.5px',
|
||||
}}
|
||||
>
|
||||
Complement Race
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Settings Bar */}
|
||||
<div
|
||||
style={{
|
||||
padding: '0 20px 16px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
{/* Number Mode & Display */}
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(30, 41, 59, 0.8)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
borderRadius: '16px',
|
||||
padding: '16px',
|
||||
border: '1px solid rgba(148, 163, 184, 0.2)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '20px',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{/* Number Mode Pills */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
minWidth: '200px',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
color: '#94a3b8',
|
||||
fontWeight: '600',
|
||||
marginRight: '4px',
|
||||
}}
|
||||
>
|
||||
Mode:
|
||||
</span>
|
||||
{[
|
||||
{ mode: 'friends5' as GameMode, label: '5' },
|
||||
{ mode: 'friends10' as GameMode, label: '10' },
|
||||
{ mode: 'mixed' as GameMode, label: 'Mix' },
|
||||
].map(({ mode, label }) => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => handleModeSelect(mode)}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
borderRadius: '20px',
|
||||
border: 'none',
|
||||
background:
|
||||
state.mode === mode
|
||||
? 'linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%)'
|
||||
: 'rgba(148, 163, 184, 0.2)',
|
||||
color: state.mode === mode ? 'white' : '#94a3b8',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Complement Display Pills */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
minWidth: '200px',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
color: '#94a3b8',
|
||||
fontWeight: '600',
|
||||
marginRight: '4px',
|
||||
}}
|
||||
>
|
||||
Show:
|
||||
</span>
|
||||
{(['number', 'abacus', 'random'] as ComplementDisplay[]).map((displayMode) => (
|
||||
<button
|
||||
key={displayMode}
|
||||
onClick={() =>
|
||||
dispatch({
|
||||
type: 'SET_COMPLEMENT_DISPLAY',
|
||||
display: displayMode,
|
||||
})
|
||||
}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
borderRadius: '20px',
|
||||
border: 'none',
|
||||
background:
|
||||
state.complementDisplay === displayMode
|
||||
? 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
|
||||
: 'rgba(148, 163, 184, 0.2)',
|
||||
color: state.complementDisplay === displayMode ? 'white' : '#94a3b8',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
>
|
||||
{displayMode === 'number' ? '123' : displayMode === 'abacus' ? '🧮' : '🎲'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Speed Pills */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '6px',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
minWidth: '200px',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
color: '#94a3b8',
|
||||
fontWeight: '600',
|
||||
marginRight: '4px',
|
||||
}}
|
||||
>
|
||||
Speed:
|
||||
</span>
|
||||
{(
|
||||
[
|
||||
'preschool',
|
||||
'kindergarten',
|
||||
'relaxed',
|
||||
'slow',
|
||||
'normal',
|
||||
'fast',
|
||||
'expert',
|
||||
] as TimeoutSetting[]
|
||||
).map((timeout) => (
|
||||
<button
|
||||
key={timeout}
|
||||
onClick={() => handleTimeoutSelect(timeout)}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
borderRadius: '16px',
|
||||
border: 'none',
|
||||
background:
|
||||
state.timeoutSetting === timeout
|
||||
? 'linear-gradient(135deg, #ec4899 0%, #be185d 100%)'
|
||||
: 'rgba(148, 163, 184, 0.2)',
|
||||
color: state.timeoutSetting === timeout ? 'white' : '#94a3b8',
|
||||
fontWeight: state.timeoutSetting === timeout ? 'bold' : 'normal',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
fontSize: '11px',
|
||||
}}
|
||||
>
|
||||
{timeout === 'preschool'
|
||||
? 'Pre'
|
||||
: timeout === 'kindergarten'
|
||||
? 'K'
|
||||
: timeout.charAt(0).toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview - compact */}
|
||||
<div
|
||||
style={{
|
||||
marginTop: '12px',
|
||||
padding: '12px',
|
||||
borderRadius: '12px',
|
||||
background: 'rgba(15, 23, 42, 0.6)',
|
||||
border: '1px solid rgba(148, 163, 184, 0.2)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '11px', color: '#94a3b8', fontWeight: '600' }}>Preview:</span>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
fontSize: '20px',
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #3b82f6, #8b5cf6)',
|
||||
color: 'white',
|
||||
padding: '2px 10px',
|
||||
borderRadius: '6px',
|
||||
}}
|
||||
>
|
||||
?
|
||||
</div>
|
||||
<span style={{ fontSize: '16px', color: '#64748b' }}>+</span>
|
||||
{state.complementDisplay === 'number' ? (
|
||||
<span>3</span>
|
||||
) : state.complementDisplay === 'abacus' ? (
|
||||
<div style={{ transform: 'scale(0.8)' }}>
|
||||
<AbacusTarget number={3} />
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ fontSize: '14px' }}>🎲</span>
|
||||
)}
|
||||
<span style={{ fontSize: '16px', color: '#64748b' }}>=</span>
|
||||
<span style={{ color: '#10b981' }}>
|
||||
{state.mode === 'friends5' ? '5' : state.mode === 'friends10' ? '10' : '?'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* HERO SECTION - Race Cards */}
|
||||
<div
|
||||
data-component="race-cards-container"
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '0 20px 20px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '16px',
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{
|
||||
style: 'practice' as GameStyle,
|
||||
emoji: '🏁',
|
||||
title: 'Practice Race',
|
||||
desc: 'Race against AI to 20 correct answers',
|
||||
gradient: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
||||
shadowColor: 'rgba(16, 185, 129, 0.5)',
|
||||
accentColor: '#34d399',
|
||||
},
|
||||
{
|
||||
style: 'sprint' as GameStyle,
|
||||
emoji: '🚂',
|
||||
title: 'Steam Sprint',
|
||||
desc: 'High-speed 60-second train journey',
|
||||
gradient: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
|
||||
shadowColor: 'rgba(245, 158, 11, 0.5)',
|
||||
accentColor: '#fbbf24',
|
||||
},
|
||||
{
|
||||
style: 'survival' as GameStyle,
|
||||
emoji: '🔄',
|
||||
title: 'Survival Circuit',
|
||||
desc: 'Endless laps - beat your best time',
|
||||
gradient: 'linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%)',
|
||||
shadowColor: 'rgba(139, 92, 246, 0.5)',
|
||||
accentColor: '#a78bfa',
|
||||
},
|
||||
].map(({ style, emoji, title, desc, gradient, shadowColor, accentColor }) => (
|
||||
<button
|
||||
key={style}
|
||||
onClick={() => handleStyleSelect(style)}
|
||||
style={{
|
||||
position: 'relative',
|
||||
padding: '0',
|
||||
border: 'none',
|
||||
borderRadius: '24px',
|
||||
background: gradient,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
boxShadow: `0 10px 40px ${shadowColor}, 0 0 0 1px rgba(255, 255, 255, 0.1)`,
|
||||
transform: 'translateY(0)',
|
||||
flex: 1,
|
||||
minHeight: '140px',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-8px) scale(1.02)'
|
||||
e.currentTarget.style.boxShadow = `0 20px 60px ${shadowColor}, 0 0 0 2px ${accentColor}`
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0) scale(1)'
|
||||
e.currentTarget.style.boxShadow = `0 10px 40px ${shadowColor}, 0 0 0 1px rgba(255, 255, 255, 0.1)`
|
||||
}}
|
||||
>
|
||||
{/* Shine effect overlay */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'linear-gradient(135deg, rgba(255,255,255,0.2) 0%, transparent 50%)',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
padding: '28px 32px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '20px',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '64px',
|
||||
lineHeight: 1,
|
||||
filter: 'drop-shadow(0 4px 8px rgba(0,0,0,0.3))',
|
||||
}}
|
||||
>
|
||||
{emoji}
|
||||
</div>
|
||||
<div style={{ textAlign: 'left', flex: 1 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '28px',
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
marginBottom: '6px',
|
||||
textShadow: '0 2px 8px rgba(0,0,0,0.3)',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '15px',
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
textShadow: '0 1px 4px rgba(0,0,0,0.2)',
|
||||
}}
|
||||
>
|
||||
{desc}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PLAY NOW button */}
|
||||
<div
|
||||
style={{
|
||||
background: 'white',
|
||||
color: gradient.includes('10b981')
|
||||
? '#047857'
|
||||
: gradient.includes('f59e0b')
|
||||
? '#d97706'
|
||||
: '#6b21a8',
|
||||
padding: '16px 32px',
|
||||
borderRadius: '16px',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '18px',
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.25)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<span>PLAY</span>
|
||||
<span style={{ fontSize: '24px' }}>▶</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useComplementRace } from '../context/ComplementRaceContext'
|
||||
import { useSoundEffects } from '../hooks/useSoundEffects'
|
||||
|
||||
export function GameCountdown() {
|
||||
const { dispatch } = useComplementRace()
|
||||
const { playSound } = useSoundEffects()
|
||||
const [count, setCount] = useState(3)
|
||||
const [showGo, setShowGo] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const countdownInterval = setInterval(() => {
|
||||
setCount((prevCount) => {
|
||||
if (prevCount > 1) {
|
||||
// Play countdown beep (volume 0.4)
|
||||
playSound('countdown', 0.4)
|
||||
return prevCount - 1
|
||||
} else if (prevCount === 1) {
|
||||
// Show GO!
|
||||
setShowGo(true)
|
||||
// Play race start fanfare (volume 0.6)
|
||||
playSound('race_start', 0.6)
|
||||
return 0
|
||||
}
|
||||
return prevCount
|
||||
})
|
||||
}, 1000)
|
||||
|
||||
return () => clearInterval(countdownInterval)
|
||||
}, [playSound])
|
||||
|
||||
useEffect(() => {
|
||||
if (showGo) {
|
||||
// Hide countdown and start game after GO animation
|
||||
const timer = setTimeout(() => {
|
||||
dispatch({ type: 'BEGIN_GAME' })
|
||||
}, 1000)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [showGo, dispatch])
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'rgba(0, 0, 0, 0.9)',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: showGo ? '120px' : '160px',
|
||||
fontWeight: 'bold',
|
||||
color: showGo ? '#10b981' : 'white',
|
||||
textShadow: '0 4px 20px rgba(0, 0, 0, 0.5)',
|
||||
animation: showGo ? 'scaleUp 1s ease-out' : 'pulse 0.5s ease-in-out',
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
>
|
||||
{showGo ? 'GO!' : count}
|
||||
</div>
|
||||
|
||||
{!showGo && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: '32px',
|
||||
fontSize: '24px',
|
||||
color: 'rgba(255, 255, 255, 0.8)',
|
||||
fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
Get Ready!
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.1); opacity: 0.8; }
|
||||
}
|
||||
@keyframes scaleUp {
|
||||
0% { transform: scale(0.5); opacity: 0; }
|
||||
50% { transform: scale(1.2); opacity: 1; }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,452 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useComplementRace } from '../context/ComplementRaceContext'
|
||||
import { useAdaptiveDifficulty } from '../hooks/useAdaptiveDifficulty'
|
||||
import { useAIRacers } from '../hooks/useAIRacers'
|
||||
import { useSoundEffects } from '../hooks/useSoundEffects'
|
||||
import { useSteamJourney } from '../hooks/useSteamJourney'
|
||||
import { generatePassengers } from '../lib/passengerGenerator'
|
||||
import { AbacusTarget } from './AbacusTarget'
|
||||
import { CircularTrack } from './RaceTrack/CircularTrack'
|
||||
import { LinearTrack } from './RaceTrack/LinearTrack'
|
||||
import { SteamTrainJourney } from './RaceTrack/SteamTrainJourney'
|
||||
import { RouteCelebration } from './RouteCelebration'
|
||||
|
||||
type FeedbackAnimation = 'correct' | 'incorrect' | null
|
||||
|
||||
export function GameDisplay() {
|
||||
const { state, dispatch } = useComplementRace()
|
||||
useAIRacers() // Activate AI racer updates (not used in sprint mode)
|
||||
const { trackPerformance, getAdaptiveFeedbackMessage } = useAdaptiveDifficulty()
|
||||
const { boostMomentum } = useSteamJourney()
|
||||
const { playSound } = useSoundEffects()
|
||||
const [feedbackAnimation, setFeedbackAnimation] = useState<FeedbackAnimation>(null)
|
||||
|
||||
// Clear feedback animation after it plays (line 1996, 2001)
|
||||
useEffect(() => {
|
||||
if (feedbackAnimation) {
|
||||
const timer = setTimeout(() => {
|
||||
setFeedbackAnimation(null)
|
||||
}, 500) // Match animation duration
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [feedbackAnimation])
|
||||
|
||||
// Show adaptive feedback with auto-hide
|
||||
useEffect(() => {
|
||||
if (state.adaptiveFeedback) {
|
||||
const timer = setTimeout(() => {
|
||||
dispatch({ type: 'CLEAR_ADAPTIVE_FEEDBACK' })
|
||||
}, 3000)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [state.adaptiveFeedback, dispatch])
|
||||
|
||||
// Check for finish line (player reaches race goal) - only for practice mode
|
||||
useEffect(() => {
|
||||
if (
|
||||
state.correctAnswers >= state.raceGoal &&
|
||||
state.isGameActive &&
|
||||
state.style === 'practice'
|
||||
) {
|
||||
// Play celebration sound (line 14182)
|
||||
playSound('celebration')
|
||||
// End the game
|
||||
dispatch({ type: 'END_RACE' })
|
||||
// Show results after a short delay
|
||||
setTimeout(() => {
|
||||
dispatch({ type: 'SHOW_RESULTS' })
|
||||
}, 1500)
|
||||
}
|
||||
}, [state.correctAnswers, state.raceGoal, state.isGameActive, state.style, dispatch, playSound])
|
||||
|
||||
// For survival mode (endless circuit), track laps but never end
|
||||
// For sprint mode (steam sprint), end after 60 seconds (will implement later)
|
||||
|
||||
// Handle keyboard input
|
||||
useEffect(() => {
|
||||
const handleKeyPress = (e: KeyboardEvent) => {
|
||||
// Only process number keys
|
||||
if (/^[0-9]$/.test(e.key)) {
|
||||
const newInput = state.currentInput + e.key
|
||||
dispatch({ type: 'UPDATE_INPUT', input: newInput })
|
||||
|
||||
// Check if answer is complete
|
||||
if (state.currentQuestion) {
|
||||
const answer = parseInt(newInput, 10)
|
||||
const correctAnswer = state.currentQuestion.correctAnswer
|
||||
|
||||
// If we have enough digits to match the answer, submit
|
||||
if (newInput.length >= correctAnswer.toString().length) {
|
||||
const responseTime = Date.now() - state.questionStartTime
|
||||
const isCorrect = answer === correctAnswer
|
||||
const pairKey = `${state.currentQuestion.number}_${state.currentQuestion.correctAnswer}_${state.currentQuestion.targetSum}`
|
||||
|
||||
if (isCorrect) {
|
||||
// Correct answer
|
||||
dispatch({ type: 'SUBMIT_ANSWER', answer })
|
||||
trackPerformance(true, responseTime)
|
||||
|
||||
// Trigger correct answer animation (line 1996)
|
||||
setFeedbackAnimation('correct')
|
||||
|
||||
// Play appropriate sound based on performance (from web_generator.py lines 11530-11542)
|
||||
const newStreak = state.streak + 1
|
||||
if (newStreak > 0 && newStreak % 5 === 0) {
|
||||
// Epic streak sound for every 5th correct answer
|
||||
playSound('streak')
|
||||
} else if (responseTime < 800) {
|
||||
// Whoosh sound for very fast responses (under 800ms)
|
||||
playSound('whoosh')
|
||||
} else if (responseTime < 1200 && state.streak >= 3) {
|
||||
// Combo sound for rapid answers while on a streak
|
||||
playSound('combo')
|
||||
} else {
|
||||
// Regular correct sound
|
||||
playSound('correct')
|
||||
}
|
||||
|
||||
// Boost momentum for sprint mode
|
||||
if (state.style === 'sprint') {
|
||||
boostMomentum()
|
||||
|
||||
// Play train whistle for milestones in sprint mode (line 13222-13235)
|
||||
if (newStreak >= 5 && newStreak % 3 === 0) {
|
||||
// Major milestone - play train whistle
|
||||
setTimeout(() => {
|
||||
playSound('train_whistle', 0.4)
|
||||
}, 200)
|
||||
} else if (state.momentum >= 90) {
|
||||
// High momentum celebration - occasional whistle
|
||||
if (Math.random() < 0.3) {
|
||||
setTimeout(() => {
|
||||
playSound('train_whistle', 0.25)
|
||||
}, 150)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show adaptive feedback
|
||||
const feedback = getAdaptiveFeedbackMessage(pairKey, true, responseTime)
|
||||
if (feedback) {
|
||||
dispatch({ type: 'SHOW_ADAPTIVE_FEEDBACK', feedback })
|
||||
}
|
||||
|
||||
dispatch({ type: 'NEXT_QUESTION' })
|
||||
} else {
|
||||
// Incorrect answer
|
||||
trackPerformance(false, responseTime)
|
||||
|
||||
// Trigger incorrect answer animation (line 2001)
|
||||
setFeedbackAnimation('incorrect')
|
||||
|
||||
// Play incorrect sound (from web_generator.py line 11589)
|
||||
playSound('incorrect')
|
||||
|
||||
// Show adaptive feedback
|
||||
const feedback = getAdaptiveFeedbackMessage(pairKey, false, responseTime)
|
||||
if (feedback) {
|
||||
dispatch({ type: 'SHOW_ADAPTIVE_FEEDBACK', feedback })
|
||||
}
|
||||
|
||||
dispatch({ type: 'UPDATE_INPUT', input: '' })
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (e.key === 'Backspace') {
|
||||
dispatch({
|
||||
type: 'UPDATE_INPUT',
|
||||
input: state.currentInput.slice(0, -1),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyPress)
|
||||
return () => window.removeEventListener('keydown', handleKeyPress)
|
||||
}, [
|
||||
state.currentInput,
|
||||
state.currentQuestion,
|
||||
state.questionStartTime,
|
||||
state.style,
|
||||
state.streak,
|
||||
dispatch,
|
||||
trackPerformance,
|
||||
getAdaptiveFeedbackMessage,
|
||||
boostMomentum,
|
||||
playSound,
|
||||
state.momentum,
|
||||
])
|
||||
|
||||
// Handle route celebration continue
|
||||
const handleContinueToNextRoute = () => {
|
||||
const nextRoute = state.currentRoute + 1
|
||||
|
||||
// Start new route (this also hides celebration)
|
||||
dispatch({
|
||||
type: 'START_NEW_ROUTE',
|
||||
routeNumber: nextRoute,
|
||||
stations: state.stations, // Keep same stations for now
|
||||
})
|
||||
|
||||
// Generate new passengers
|
||||
const newPassengers = generatePassengers(state.stations)
|
||||
dispatch({ type: 'GENERATE_PASSENGERS', passengers: newPassengers })
|
||||
}
|
||||
|
||||
if (!state.currentQuestion) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="game-display"
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{/* Adaptive Feedback */}
|
||||
{state.adaptiveFeedback && (
|
||||
<div
|
||||
data-component="adaptive-feedback"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: '80px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
color: 'white',
|
||||
padding: '12px 24px',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 4px 20px rgba(102, 126, 234, 0.4)',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
zIndex: 1000,
|
||||
animation: 'slideDown 0.3s ease-out',
|
||||
maxWidth: '600px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{state.adaptiveFeedback.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats Header - constrained width, hidden for sprint mode */}
|
||||
{state.style !== 'sprint' && (
|
||||
<div
|
||||
data-component="stats-container"
|
||||
style={{
|
||||
maxWidth: '1200px',
|
||||
margin: '0 auto',
|
||||
width: '100%',
|
||||
padding: '0 20px',
|
||||
marginTop: '10px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
data-component="stats-header"
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-around',
|
||||
marginBottom: '10px',
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '10px',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
>
|
||||
<div data-stat="score" style={{ textAlign: 'center' }}>
|
||||
<div
|
||||
style={{
|
||||
color: '#6b7280',
|
||||
fontSize: '14px',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
Score
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 'bold',
|
||||
fontSize: '24px',
|
||||
color: '#3b82f6',
|
||||
}}
|
||||
>
|
||||
{state.score}
|
||||
</div>
|
||||
</div>
|
||||
<div data-stat="streak" style={{ textAlign: 'center' }}>
|
||||
<div
|
||||
style={{
|
||||
color: '#6b7280',
|
||||
fontSize: '14px',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
Streak
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 'bold',
|
||||
fontSize: '24px',
|
||||
color: '#10b981',
|
||||
}}
|
||||
>
|
||||
{state.streak} 🔥
|
||||
</div>
|
||||
</div>
|
||||
<div data-stat="progress" style={{ textAlign: 'center' }}>
|
||||
<div
|
||||
style={{
|
||||
color: '#6b7280',
|
||||
fontSize: '14px',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
Progress
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 'bold',
|
||||
fontSize: '24px',
|
||||
color: '#f59e0b',
|
||||
}}
|
||||
>
|
||||
{state.correctAnswers}/{state.raceGoal}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Race Track - full width, break out of padding */}
|
||||
<div
|
||||
data-component="track-container"
|
||||
style={{
|
||||
width: '100vw',
|
||||
position: 'relative',
|
||||
left: '50%',
|
||||
right: '50%',
|
||||
marginLeft: '-50vw',
|
||||
marginRight: '-50vw',
|
||||
padding: state.style === 'sprint' ? '0' : '0 20px',
|
||||
display: 'flex',
|
||||
justifyContent: state.style === 'sprint' ? 'stretch' : 'center',
|
||||
background: 'transparent',
|
||||
flex: state.style === 'sprint' ? 1 : 'initial',
|
||||
minHeight: state.style === 'sprint' ? 0 : 'initial',
|
||||
}}
|
||||
>
|
||||
{state.style === 'survival' ? (
|
||||
<CircularTrack
|
||||
playerProgress={state.correctAnswers}
|
||||
playerLap={state.playerLap}
|
||||
aiRacers={state.aiRacers}
|
||||
aiLaps={state.aiLaps}
|
||||
/>
|
||||
) : state.style === 'sprint' ? (
|
||||
<SteamTrainJourney
|
||||
momentum={state.momentum}
|
||||
trainPosition={state.trainPosition}
|
||||
pressure={state.pressure}
|
||||
elapsedTime={state.elapsedTime}
|
||||
currentQuestion={state.currentQuestion}
|
||||
currentInput={state.currentInput}
|
||||
/>
|
||||
) : (
|
||||
<LinearTrack
|
||||
playerProgress={state.correctAnswers}
|
||||
aiRacers={state.aiRacers}
|
||||
raceGoal={state.raceGoal}
|
||||
showFinishLine={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Question Display - only for non-sprint modes */}
|
||||
{state.style !== 'sprint' && (
|
||||
<div
|
||||
data-component="question-container"
|
||||
style={{
|
||||
maxWidth: '1200px',
|
||||
margin: '0 auto',
|
||||
width: '100%',
|
||||
padding: '0 20px',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
marginTop: '20px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
data-component="question-display"
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.98)',
|
||||
borderRadius: '24px',
|
||||
padding: '28px 50px',
|
||||
boxShadow: '0 16px 40px rgba(0, 0, 0, 0.3), 0 0 0 5px rgba(59, 130, 246, 0.4)',
|
||||
backdropFilter: 'blur(12px)',
|
||||
border: '4px solid rgba(255, 255, 255, 0.95)',
|
||||
}}
|
||||
>
|
||||
{/* Complement equation as main focus */}
|
||||
<div
|
||||
data-element="question-equation"
|
||||
style={{
|
||||
fontSize: '96px',
|
||||
fontWeight: 'bold',
|
||||
color: '#1f2937',
|
||||
lineHeight: '1.1',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '20px',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #3b82f6, #8b5cf6)',
|
||||
color: 'white',
|
||||
padding: '12px 32px',
|
||||
borderRadius: '16px',
|
||||
minWidth: '140px',
|
||||
display: 'inline-block',
|
||||
textShadow: '0 3px 10px rgba(0, 0, 0, 0.3)',
|
||||
}}
|
||||
>
|
||||
{state.currentInput || '?'}
|
||||
</span>
|
||||
<span style={{ color: '#6b7280' }}>+</span>
|
||||
{state.currentQuestion.showAsAbacus ? (
|
||||
<div
|
||||
style={{
|
||||
transform: 'scale(2.4) translateY(8%)',
|
||||
transformOrigin: 'center center',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<AbacusTarget number={state.currentQuestion.number} />
|
||||
</div>
|
||||
) : (
|
||||
<span>{state.currentQuestion.number}</span>
|
||||
)}
|
||||
<span style={{ color: '#6b7280' }}>=</span>
|
||||
<span style={{ color: '#10b981' }}>{state.currentQuestion.targetSum}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Route Celebration Modal */}
|
||||
{state.showRouteCelebration && state.style === 'sprint' && (
|
||||
<RouteCelebration
|
||||
completedRouteNumber={state.currentRoute}
|
||||
nextRouteNumber={state.currentRoute + 1}
|
||||
onContinue={handleContinueToNextRoute}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useComplementRace } from '../context/ComplementRaceContext'
|
||||
|
||||
export function GameIntro() {
|
||||
const { dispatch } = useComplementRace()
|
||||
|
||||
const handleStartClick = () => {
|
||||
dispatch({ type: 'SHOW_CONTROLS' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: '40px 20px',
|
||||
maxWidth: '800px',
|
||||
margin: '20px auto 0',
|
||||
}}
|
||||
>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: '48px',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '16px',
|
||||
background: 'linear-gradient(135deg, #3b82f6, #8b5cf6)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text',
|
||||
}}
|
||||
>
|
||||
Speed Complement Race
|
||||
</h1>
|
||||
|
||||
<p
|
||||
style={{
|
||||
fontSize: '18px',
|
||||
color: '#6b7280',
|
||||
marginBottom: '32px',
|
||||
lineHeight: '1.6',
|
||||
}}
|
||||
>
|
||||
Race against AI opponents while solving complement problems! Find the missing number to
|
||||
complete the equation.
|
||||
</p>
|
||||
|
||||
<div
|
||||
style={{
|
||||
background: 'white',
|
||||
borderRadius: '16px',
|
||||
padding: '32px',
|
||||
marginBottom: '32px',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '16px',
|
||||
color: '#1f2937',
|
||||
}}
|
||||
>
|
||||
How to Play
|
||||
</h2>
|
||||
|
||||
<ul
|
||||
style={{
|
||||
listStyle: 'none',
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<li style={{ display: 'flex', gap: '12px', alignItems: 'start' }}>
|
||||
<span style={{ fontSize: '24px' }}>🎯</span>
|
||||
<span style={{ color: '#4b5563', lineHeight: '1.6' }}>
|
||||
Find the complement number to reach the target sum
|
||||
</span>
|
||||
</li>
|
||||
<li style={{ display: 'flex', gap: '12px', alignItems: 'start' }}>
|
||||
<span style={{ fontSize: '24px' }}>⚡</span>
|
||||
<span style={{ color: '#4b5563', lineHeight: '1.6' }}>
|
||||
Type your answer quickly to move forward in the race
|
||||
</span>
|
||||
</li>
|
||||
<li style={{ display: 'flex', gap: '12px', alignItems: 'start' }}>
|
||||
<span style={{ fontSize: '24px' }}>🤖</span>
|
||||
<span style={{ color: '#4b5563', lineHeight: '1.6' }}>
|
||||
Compete against Swift AI and Math Bot with unique personalities
|
||||
</span>
|
||||
</li>
|
||||
<li style={{ display: 'flex', gap: '12px', alignItems: 'start' }}>
|
||||
<span style={{ fontSize: '24px' }}>🏆</span>
|
||||
<span style={{ color: '#4b5563', lineHeight: '1.6' }}>
|
||||
Earn points for correct answers and build up your streak
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleStartClick}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #10b981, #059669)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
padding: '16px 48px',
|
||||
fontSize: '20px',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 4px 12px rgba(16, 185, 129, 0.3)',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-2px)'
|
||||
e.currentTarget.style.boxShadow = '0 6px 16px rgba(16, 185, 129, 0.4)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)'
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(16, 185, 129, 0.3)'
|
||||
}}
|
||||
>
|
||||
Start Racing!
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useComplementRace } from '../context/ComplementRaceContext'
|
||||
|
||||
export function GameResults() {
|
||||
const { state, dispatch } = useComplementRace()
|
||||
|
||||
// Determine race outcome
|
||||
const playerWon = state.aiRacers.every((racer) => state.correctAnswers > racer.position)
|
||||
const playerPosition =
|
||||
state.aiRacers.filter((racer) => racer.position >= state.correctAnswers).length + 1
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '60px 40px 40px',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
minHeight: '100vh',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'white',
|
||||
borderRadius: '24px',
|
||||
padding: '48px',
|
||||
maxWidth: '600px',
|
||||
width: '100%',
|
||||
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.3)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{/* Result Header */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: '64px',
|
||||
marginBottom: '16px',
|
||||
}}
|
||||
>
|
||||
{playerWon ? '🏆' : playerPosition === 2 ? '🥈' : playerPosition === 3 ? '🥉' : '🎯'}
|
||||
</div>
|
||||
|
||||
<h1
|
||||
style={{
|
||||
fontSize: '36px',
|
||||
fontWeight: 'bold',
|
||||
color: '#1f2937',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
{playerWon ? 'Victory!' : `${playerPosition}${getOrdinalSuffix(playerPosition)} Place`}
|
||||
</h1>
|
||||
|
||||
<p
|
||||
style={{
|
||||
fontSize: '18px',
|
||||
color: '#6b7280',
|
||||
marginBottom: '32px',
|
||||
}}
|
||||
>
|
||||
{playerWon ? 'You beat all the AI racers!' : `You finished the race!`}
|
||||
</p>
|
||||
|
||||
{/* Stats */}
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(2, 1fr)',
|
||||
gap: '16px',
|
||||
marginBottom: '32px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: '#f3f4f6',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
color: '#6b7280',
|
||||
fontSize: '14px',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
Final Score
|
||||
</div>
|
||||
<div style={{ fontSize: '28px', fontWeight: 'bold', color: '#3b82f6' }}>
|
||||
{state.score}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
background: '#f3f4f6',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
color: '#6b7280',
|
||||
fontSize: '14px',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
Best Streak
|
||||
</div>
|
||||
<div style={{ fontSize: '28px', fontWeight: 'bold', color: '#10b981' }}>
|
||||
{state.bestStreak} 🔥
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
background: '#f3f4f6',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
color: '#6b7280',
|
||||
fontSize: '14px',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
Total Questions
|
||||
</div>
|
||||
<div style={{ fontSize: '28px', fontWeight: 'bold', color: '#f59e0b' }}>
|
||||
{state.totalQuestions}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
background: '#f3f4f6',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
color: '#6b7280',
|
||||
fontSize: '14px',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
Accuracy
|
||||
</div>
|
||||
<div style={{ fontSize: '28px', fontWeight: 'bold', color: '#8b5cf6' }}>
|
||||
{state.totalQuestions > 0
|
||||
? Math.round((state.correctAnswers / state.totalQuestions) * 100)
|
||||
: 0}
|
||||
%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Final Standings */}
|
||||
<div
|
||||
style={{
|
||||
marginBottom: '32px',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
color: '#1f2937',
|
||||
marginBottom: '12px',
|
||||
}}
|
||||
>
|
||||
Final Standings
|
||||
</h3>
|
||||
|
||||
{[
|
||||
{ name: 'You', position: state.correctAnswers, icon: '👤' },
|
||||
...state.aiRacers.map((racer) => ({
|
||||
name: racer.name,
|
||||
position: racer.position,
|
||||
icon: racer.icon,
|
||||
})),
|
||||
]
|
||||
.sort((a, b) => b.position - a.position)
|
||||
.map((racer, index) => (
|
||||
<div
|
||||
key={racer.name}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '12px',
|
||||
background: racer.name === 'You' ? '#eff6ff' : '#f9fafb',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '8px',
|
||||
border: racer.name === 'You' ? '2px solid #3b82f6' : 'none',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
color: '#9ca3af',
|
||||
minWidth: '32px',
|
||||
}}
|
||||
>
|
||||
#{index + 1}
|
||||
</div>
|
||||
<div style={{ fontSize: '20px' }}>{racer.icon}</div>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: racer.name === 'You' ? 'bold' : 'normal',
|
||||
}}
|
||||
>
|
||||
{racer.name}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
color: '#6b7280',
|
||||
}}
|
||||
>
|
||||
{Math.floor(racer.position)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => dispatch({ type: 'RESET_GAME' })}
|
||||
style={{
|
||||
flex: 1,
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
color: 'white',
|
||||
padding: '16px 32px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.2s',
|
||||
boxShadow: '0 4px 12px rgba(102, 126, 234, 0.4)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-2px)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)'
|
||||
}}
|
||||
>
|
||||
Race Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getOrdinalSuffix(num: number): string {
|
||||
if (num === 1) return 'st'
|
||||
if (num === 2) return 'nd'
|
||||
if (num === 3) return 'rd'
|
||||
return 'th'
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import type { Passenger, Station } from '../lib/gameTypes'
|
||||
|
||||
interface PassengerCardProps {
|
||||
passenger: Passenger
|
||||
originStation: Station | undefined
|
||||
destinationStation: Station | undefined
|
||||
}
|
||||
|
||||
export const PassengerCard = memo(function PassengerCard({
|
||||
passenger,
|
||||
originStation,
|
||||
destinationStation,
|
||||
}: PassengerCardProps) {
|
||||
if (!destinationStation || !originStation) return null
|
||||
|
||||
// Vintage train station colors
|
||||
const bgColor = passenger.isDelivered
|
||||
? '#1a3a1a' // Dark green for delivered
|
||||
: !passenger.isBoarded
|
||||
? '#2a2419' // Dark brown/sepia for waiting
|
||||
: passenger.isUrgent
|
||||
? '#3a2419' // Dark red-brown for urgent
|
||||
: '#1a2a3a' // Dark blue for aboard
|
||||
|
||||
const accentColor = passenger.isDelivered
|
||||
? '#4ade80' // Green
|
||||
: !passenger.isBoarded
|
||||
? '#d4af37' // Gold for waiting
|
||||
: passenger.isUrgent
|
||||
? '#ff6b35' // Orange-red for urgent
|
||||
: '#60a5fa' // Blue for aboard
|
||||
|
||||
const borderColor =
|
||||
passenger.isUrgent && passenger.isBoarded && !passenger.isDelivered ? '#ff6b35' : '#d4af37'
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: bgColor,
|
||||
border: `2px solid ${borderColor}`,
|
||||
borderRadius: '4px',
|
||||
padding: '8px 10px',
|
||||
minWidth: '220px',
|
||||
maxWidth: '280px',
|
||||
boxShadow:
|
||||
passenger.isUrgent && !passenger.isDelivered && passenger.isBoarded
|
||||
? '0 0 16px rgba(255, 107, 53, 0.5)'
|
||||
: '0 4px 12px rgba(0, 0, 0, 0.4)',
|
||||
position: 'relative',
|
||||
fontFamily: '"Courier New", Courier, monospace',
|
||||
animation:
|
||||
passenger.isUrgent && !passenger.isDelivered && passenger.isBoarded
|
||||
? 'urgentFlicker 1.5s ease-in-out infinite'
|
||||
: 'none',
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
>
|
||||
{/* Top row: Passenger info and status */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '6px',
|
||||
borderBottom: `1px solid ${accentColor}33`,
|
||||
paddingBottom: '4px',
|
||||
paddingRight: '42px', // Make room for points badge
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '20px', lineHeight: '1' }}>
|
||||
{passenger.isDelivered ? '✅' : passenger.avatar}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
fontWeight: 'bold',
|
||||
color: accentColor,
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
{passenger.name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status indicator */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: '9px',
|
||||
color: accentColor,
|
||||
fontWeight: 'bold',
|
||||
letterSpacing: '0.5px',
|
||||
background: `${accentColor}22`,
|
||||
padding: '2px 6px',
|
||||
borderRadius: '2px',
|
||||
border: `1px solid ${accentColor}66`,
|
||||
whiteSpace: 'nowrap',
|
||||
marginTop: '0',
|
||||
}}
|
||||
>
|
||||
{passenger.isDelivered ? 'DLVRD' : passenger.isBoarded ? 'BOARD' : 'WAIT'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Route information */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '3px',
|
||||
fontSize: '10px',
|
||||
color: '#e8d4a0',
|
||||
}}
|
||||
>
|
||||
{/* From station */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: accentColor,
|
||||
fontSize: '8px',
|
||||
fontWeight: 'bold',
|
||||
width: '28px',
|
||||
letterSpacing: '0.3px',
|
||||
}}
|
||||
>
|
||||
FROM:
|
||||
</span>
|
||||
<span style={{ fontSize: '14px', lineHeight: '1' }}>{originStation.icon}</span>
|
||||
<span
|
||||
style={{
|
||||
fontWeight: '600',
|
||||
fontSize: '10px',
|
||||
letterSpacing: '0.3px',
|
||||
}}
|
||||
>
|
||||
{originStation.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* To station */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: accentColor,
|
||||
fontSize: '8px',
|
||||
fontWeight: 'bold',
|
||||
width: '28px',
|
||||
letterSpacing: '0.3px',
|
||||
}}
|
||||
>
|
||||
TO:
|
||||
</span>
|
||||
<span style={{ fontSize: '14px', lineHeight: '1' }}>{destinationStation.icon}</span>
|
||||
<span
|
||||
style={{
|
||||
fontWeight: '600',
|
||||
fontSize: '10px',
|
||||
letterSpacing: '0.3px',
|
||||
}}
|
||||
>
|
||||
{destinationStation.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Points badge */}
|
||||
{!passenger.isDelivered && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '6px',
|
||||
right: '6px',
|
||||
background: `${accentColor}33`,
|
||||
border: `1px solid ${accentColor}`,
|
||||
borderRadius: '2px',
|
||||
padding: '2px 6px',
|
||||
fontSize: '10px',
|
||||
fontWeight: 'bold',
|
||||
color: accentColor,
|
||||
letterSpacing: '0.5px',
|
||||
}}
|
||||
>
|
||||
{passenger.isUrgent ? '+20' : '+10'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Urgent indicator */}
|
||||
{passenger.isUrgent && !passenger.isDelivered && passenger.isBoarded && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '8px',
|
||||
bottom: '6px',
|
||||
fontSize: '10px',
|
||||
animation: 'urgentBlink 0.8s ease-in-out infinite',
|
||||
filter: 'drop-shadow(0 0 4px rgba(255, 107, 53, 0.8))',
|
||||
}}
|
||||
>
|
||||
⚠️
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
@keyframes urgentFlicker {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 16px rgba(255, 107, 53, 0.5);
|
||||
border-color: #ff6b35;
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 24px rgba(255, 107, 53, 0.8);
|
||||
border-color: #ffaa35;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes urgentBlink {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -1,180 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { animated, useSpring } from '@react-spring/web'
|
||||
import { AbacusReact } from '@soroban/abacus-react'
|
||||
|
||||
interface PressureGaugeProps {
|
||||
pressure: number // 0-150 PSI
|
||||
}
|
||||
|
||||
export function PressureGauge({ pressure }: PressureGaugeProps) {
|
||||
const maxPressure = 150
|
||||
|
||||
// Animate pressure value smoothly with spring physics
|
||||
const spring = useSpring({
|
||||
pressure,
|
||||
config: {
|
||||
tension: 120,
|
||||
friction: 14,
|
||||
clamp: false,
|
||||
},
|
||||
})
|
||||
|
||||
// Calculate needle angle - sweeps 180° from left to right
|
||||
// 0 PSI = 180° (pointing left), 150 PSI = 0° (pointing right)
|
||||
const angle = spring.pressure.to((p) => 180 - (p / maxPressure) * 180)
|
||||
|
||||
// Get pressure color (animated)
|
||||
const color = spring.pressure.to((p) => {
|
||||
if (p < 50) return '#ef4444' // Red (low)
|
||||
if (p < 100) return '#f59e0b' // Orange (medium)
|
||||
return '#10b981' // Green (high)
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
background: 'rgba(255, 255, 255, 0.95)',
|
||||
padding: '16px',
|
||||
borderRadius: '12px',
|
||||
minWidth: '220px',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.2)',
|
||||
}}
|
||||
>
|
||||
{/* Title */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: '#6b7280',
|
||||
marginBottom: '8px',
|
||||
fontWeight: 'bold',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
PRESSURE
|
||||
</div>
|
||||
|
||||
{/* SVG Gauge */}
|
||||
<svg
|
||||
viewBox="-40 -20 280 170"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
{/* Background arc - semicircle from left to right (bottom half) */}
|
||||
<path
|
||||
d="M 20 100 A 80 80 0 0 1 180 100"
|
||||
fill="none"
|
||||
stroke="#e5e7eb"
|
||||
strokeWidth="8"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
|
||||
{/* Tick marks */}
|
||||
{[0, 50, 100, 150].map((psi, index) => {
|
||||
// Angle from 180° (left) to 0° (right)
|
||||
const tickAngle = 180 - (psi / maxPressure) * 180
|
||||
const tickRad = (tickAngle * Math.PI) / 180
|
||||
const x1 = 100 + Math.cos(tickRad) * 70
|
||||
const y1 = 100 - Math.sin(tickRad) * 70 // Subtract for SVG coords
|
||||
const x2 = 100 + Math.cos(tickRad) * 80
|
||||
const y2 = 100 - Math.sin(tickRad) * 80 // Subtract for SVG coords
|
||||
|
||||
// Position for abacus label
|
||||
const labelX = 100 + Math.cos(tickRad) * 112
|
||||
const labelY = 100 - Math.sin(tickRad) * 112
|
||||
|
||||
return (
|
||||
<g key={`tick-${index}`}>
|
||||
<line
|
||||
x1={x1}
|
||||
y1={y1}
|
||||
x2={x2}
|
||||
y2={y2}
|
||||
stroke="#6b7280"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<foreignObject x={labelX - 30} y={labelY - 25} width="60" height="100">
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
lineHeight: 0,
|
||||
}}
|
||||
>
|
||||
<AbacusReact
|
||||
value={psi}
|
||||
columns={3}
|
||||
interactive={false}
|
||||
showNumbers={false}
|
||||
hideInactiveBeads={false}
|
||||
scaleFactor={0.6}
|
||||
customStyles={{
|
||||
columnPosts: { opacity: 0 },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</foreignObject>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Center pivot */}
|
||||
<circle cx="100" cy="100" r="4" fill="#1f2937" />
|
||||
|
||||
{/* Needle - animated */}
|
||||
<animated.line
|
||||
x1="100"
|
||||
y1="100"
|
||||
x2={angle.to((a) => 100 + Math.cos((a * Math.PI) / 180) * 70)}
|
||||
y2={angle.to((a) => 100 - Math.sin((a * Math.PI) / 180) * 70)}
|
||||
stroke={color}
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
style={{
|
||||
filter: color.to((c) => `drop-shadow(0 2px 3px ${c})`),
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Abacus readout */}
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '8px',
|
||||
minHeight: '32px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
lineHeight: 0,
|
||||
}}
|
||||
>
|
||||
<AbacusReact
|
||||
value={Math.round(pressure)}
|
||||
columns={3}
|
||||
interactive={false}
|
||||
showNumbers={false}
|
||||
hideInactiveBeads={true}
|
||||
scaleFactor={0.35}
|
||||
customStyles={{
|
||||
columnPosts: { opacity: 0 },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span style={{ fontSize: '12px', color: '#6b7280', fontWeight: 'bold' }}>PSI</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,489 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useGameMode } from '@/contexts/GameModeContext'
|
||||
import { useUserProfile } from '@/contexts/UserProfileContext'
|
||||
import { useComplementRace } from '../../context/ComplementRaceContext'
|
||||
import { useSoundEffects } from '../../hooks/useSoundEffects'
|
||||
import type { AIRacer } from '../../lib/gameTypes'
|
||||
import { SpeechBubble } from '../AISystem/SpeechBubble'
|
||||
|
||||
interface CircularTrackProps {
|
||||
playerProgress: number
|
||||
playerLap: number
|
||||
aiRacers: AIRacer[]
|
||||
aiLaps: Map<string, number>
|
||||
}
|
||||
|
||||
export function CircularTrack({ playerProgress, playerLap, aiRacers, aiLaps }: CircularTrackProps) {
|
||||
const { state, dispatch } = useComplementRace()
|
||||
const { players } = useGameMode()
|
||||
const { profile: _profile } = useUserProfile()
|
||||
const { playSound } = useSoundEffects()
|
||||
const [celebrationCooldown, setCelebrationCooldown] = useState<Set<string>>(new Set())
|
||||
|
||||
// Get the first active player's emoji
|
||||
const activePlayers = Array.from(players.values()).filter((p) => p.id)
|
||||
const firstActivePlayer = activePlayers[0]
|
||||
const playerEmoji = firstActivePlayer?.emoji ?? '👤'
|
||||
const [dimensions, setDimensions] = useState({ width: 600, height: 400 })
|
||||
|
||||
// Update dimensions on mount and resize
|
||||
useEffect(() => {
|
||||
const updateDimensions = () => {
|
||||
const vw = window.innerWidth
|
||||
const vh = window.innerHeight
|
||||
const isLandscape = vw > vh
|
||||
|
||||
if (isLandscape) {
|
||||
// Landscape: wider track (emphasize horizontal straights)
|
||||
const width = Math.min(vw * 0.75, 800)
|
||||
const height = Math.min(vh * 0.5, 350)
|
||||
setDimensions({ width, height })
|
||||
} else {
|
||||
// Portrait: taller track (emphasize vertical straights)
|
||||
const width = Math.min(vw * 0.85, 350)
|
||||
const height = Math.min(vh * 0.5, 550)
|
||||
setDimensions({ width, height })
|
||||
}
|
||||
}
|
||||
|
||||
updateDimensions()
|
||||
window.addEventListener('resize', updateDimensions)
|
||||
return () => window.removeEventListener('resize', updateDimensions)
|
||||
}, [])
|
||||
|
||||
const padding = 40
|
||||
const trackWidth = dimensions.width - padding * 2
|
||||
const trackHeight = dimensions.height - padding * 2
|
||||
|
||||
// For a rounded rectangle track, we have straight sections and curved ends
|
||||
const straightLength = Math.max(trackWidth, trackHeight) - Math.min(trackWidth, trackHeight)
|
||||
const radius = Math.min(trackWidth, trackHeight) / 2
|
||||
const isHorizontal = trackWidth > trackHeight
|
||||
|
||||
// Calculate position on rounded rectangle track
|
||||
const getCircularPosition = (progress: number) => {
|
||||
const progressPerLap = 50
|
||||
const normalizedProgress = (progress % progressPerLap) / progressPerLap
|
||||
|
||||
// Track perimeter consists of: 2 straights + 2 semicircles
|
||||
const straightPerim = straightLength
|
||||
const curvePerim = Math.PI * radius
|
||||
const totalPerim = 2 * straightPerim + 2 * curvePerim
|
||||
|
||||
const distanceAlongTrack = normalizedProgress * totalPerim
|
||||
|
||||
const centerX = dimensions.width / 2
|
||||
const centerY = dimensions.height / 2
|
||||
|
||||
let x: number, y: number, angle: number
|
||||
|
||||
if (isHorizontal) {
|
||||
// Horizontal track: straight sections on top/bottom, curves on left/right
|
||||
const topStraightEnd = straightPerim
|
||||
const rightCurveEnd = topStraightEnd + curvePerim
|
||||
const bottomStraightEnd = rightCurveEnd + straightPerim
|
||||
const _leftCurveEnd = bottomStraightEnd + curvePerim
|
||||
|
||||
if (distanceAlongTrack < topStraightEnd) {
|
||||
// Top straight (moving right)
|
||||
const t = distanceAlongTrack / straightPerim
|
||||
x = centerX - straightLength / 2 + t * straightLength
|
||||
y = centerY - radius
|
||||
angle = 90
|
||||
} else if (distanceAlongTrack < rightCurveEnd) {
|
||||
// Right curve
|
||||
const curveProgress = (distanceAlongTrack - topStraightEnd) / curvePerim
|
||||
const curveAngle = curveProgress * Math.PI - Math.PI / 2
|
||||
x = centerX + straightLength / 2 + radius * Math.cos(curveAngle)
|
||||
y = centerY + radius * Math.sin(curveAngle)
|
||||
angle = curveProgress * 180 + 90
|
||||
} else if (distanceAlongTrack < bottomStraightEnd) {
|
||||
// Bottom straight (moving left)
|
||||
const t = (distanceAlongTrack - rightCurveEnd) / straightPerim
|
||||
x = centerX + straightLength / 2 - t * straightLength
|
||||
y = centerY + radius
|
||||
angle = 270
|
||||
} else {
|
||||
// Left curve
|
||||
const curveProgress = (distanceAlongTrack - bottomStraightEnd) / curvePerim
|
||||
const curveAngle = curveProgress * Math.PI + Math.PI / 2
|
||||
x = centerX - straightLength / 2 + radius * Math.cos(curveAngle)
|
||||
y = centerY + radius * Math.sin(curveAngle)
|
||||
angle = curveProgress * 180 + 270
|
||||
}
|
||||
} else {
|
||||
// Vertical track: straight sections on left/right, curves on top/bottom
|
||||
const leftStraightEnd = straightPerim
|
||||
const bottomCurveEnd = leftStraightEnd + curvePerim
|
||||
const rightStraightEnd = bottomCurveEnd + straightPerim
|
||||
const _topCurveEnd = rightStraightEnd + curvePerim
|
||||
|
||||
if (distanceAlongTrack < leftStraightEnd) {
|
||||
// Left straight (moving down)
|
||||
const t = distanceAlongTrack / straightPerim
|
||||
x = centerX - radius
|
||||
y = centerY - straightLength / 2 + t * straightLength
|
||||
angle = 180
|
||||
} else if (distanceAlongTrack < bottomCurveEnd) {
|
||||
// Bottom curve
|
||||
const curveProgress = (distanceAlongTrack - leftStraightEnd) / curvePerim
|
||||
const curveAngle = curveProgress * Math.PI
|
||||
x = centerX + radius * Math.cos(curveAngle)
|
||||
y = centerY + straightLength / 2 + radius * Math.sin(curveAngle)
|
||||
angle = curveProgress * 180 + 180
|
||||
} else if (distanceAlongTrack < rightStraightEnd) {
|
||||
// Right straight (moving up)
|
||||
const t = (distanceAlongTrack - bottomCurveEnd) / straightPerim
|
||||
x = centerX + radius
|
||||
y = centerY + straightLength / 2 - t * straightLength
|
||||
angle = 0
|
||||
} else {
|
||||
// Top curve
|
||||
const curveProgress = (distanceAlongTrack - rightStraightEnd) / curvePerim
|
||||
const curveAngle = curveProgress * Math.PI + Math.PI
|
||||
x = centerX + radius * Math.cos(curveAngle)
|
||||
y = centerY - straightLength / 2 + radius * Math.sin(curveAngle)
|
||||
angle = curveProgress * 180
|
||||
}
|
||||
}
|
||||
|
||||
return { x, y, angle }
|
||||
}
|
||||
|
||||
// Check for lap completions and show celebrations
|
||||
useEffect(() => {
|
||||
// Check player lap
|
||||
const playerCurrentLap = Math.floor(playerProgress / 50)
|
||||
if (playerCurrentLap > playerLap && !celebrationCooldown.has('player')) {
|
||||
dispatch({ type: 'COMPLETE_LAP', racerId: 'player' })
|
||||
// Play celebration sound (line 12801)
|
||||
playSound('lap_celebration', 0.6)
|
||||
setCelebrationCooldown((prev) => new Set(prev).add('player'))
|
||||
setTimeout(() => {
|
||||
setCelebrationCooldown((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete('player')
|
||||
return next
|
||||
})
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
// Check AI laps
|
||||
aiRacers.forEach((racer) => {
|
||||
const aiCurrentLap = Math.floor(racer.position / 50)
|
||||
const aiPreviousLap = aiLaps.get(racer.id) || 0
|
||||
if (aiCurrentLap > aiPreviousLap && !celebrationCooldown.has(racer.id)) {
|
||||
dispatch({ type: 'COMPLETE_LAP', racerId: racer.id })
|
||||
setCelebrationCooldown((prev) => new Set(prev).add(racer.id))
|
||||
setTimeout(() => {
|
||||
setCelebrationCooldown((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(racer.id)
|
||||
return next
|
||||
})
|
||||
}, 2000)
|
||||
}
|
||||
})
|
||||
}, [
|
||||
playerProgress,
|
||||
playerLap,
|
||||
aiRacers,
|
||||
aiLaps,
|
||||
celebrationCooldown,
|
||||
dispatch, // Play celebration sound (line 12801)
|
||||
playSound,
|
||||
])
|
||||
|
||||
const playerPos = getCircularPosition(playerProgress)
|
||||
|
||||
// Create rounded rectangle path with wider curves (banking effect)
|
||||
const createRoundedRectPath = (radiusOffset: number, isOuter: boolean = false) => {
|
||||
const centerX = dimensions.width / 2
|
||||
const centerY = dimensions.height / 2
|
||||
|
||||
// Make curves wider by increasing radius more on outer edges
|
||||
const curveWidthBonus = isOuter ? radiusOffset * 0.15 : radiusOffset * -0.1
|
||||
const r = radius + radiusOffset + curveWidthBonus
|
||||
|
||||
if (isHorizontal) {
|
||||
// Horizontal track - curved ends on left/right
|
||||
const leftCenterX = centerX - straightLength / 2
|
||||
const rightCenterX = centerX + straightLength / 2
|
||||
const curveTopY = centerY - r
|
||||
const curveBottomY = centerY + r
|
||||
|
||||
return `
|
||||
M ${leftCenterX} ${curveTopY}
|
||||
L ${rightCenterX} ${curveTopY}
|
||||
A ${r} ${r} 0 0 1 ${rightCenterX} ${curveBottomY}
|
||||
L ${leftCenterX} ${curveBottomY}
|
||||
A ${r} ${r} 0 0 1 ${leftCenterX} ${curveTopY}
|
||||
Z
|
||||
`
|
||||
} else {
|
||||
// Vertical track - curved ends on top/bottom
|
||||
const topCenterY = centerY - straightLength / 2
|
||||
const bottomCenterY = centerY + straightLength / 2
|
||||
const curveLeftX = centerX - r
|
||||
const curveRightX = centerX + r
|
||||
|
||||
return `
|
||||
M ${curveLeftX} ${topCenterY}
|
||||
L ${curveLeftX} ${bottomCenterY}
|
||||
A ${r} ${r} 0 0 0 ${curveRightX} ${bottomCenterY}
|
||||
L ${curveRightX} ${topCenterY}
|
||||
A ${r} ${r} 0 0 0 ${curveLeftX} ${topCenterY}
|
||||
Z
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="circular-track"
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: `${dimensions.width}px`,
|
||||
height: `${dimensions.height}px`,
|
||||
margin: '0 auto',
|
||||
}}
|
||||
>
|
||||
{/* SVG Track */}
|
||||
<svg
|
||||
data-component="track-svg"
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
}}
|
||||
>
|
||||
{/* Infield grass */}
|
||||
<path d={createRoundedRectPath(15, false)} fill="#7cb342" stroke="none" />
|
||||
|
||||
{/* Track background - reddish clay color */}
|
||||
<path d={createRoundedRectPath(-10, true)} fill="#d97757" stroke="none" />
|
||||
|
||||
{/* Track outer edge - white boundary */}
|
||||
<path d={createRoundedRectPath(-15, true)} fill="none" stroke="white" strokeWidth="3" />
|
||||
|
||||
{/* Track inner edge - white boundary */}
|
||||
<path d={createRoundedRectPath(15, false)} fill="none" stroke="white" strokeWidth="3" />
|
||||
|
||||
{/* Lane markers - dashed white lines */}
|
||||
{[-5, 0, 5].map((offset) => (
|
||||
<path
|
||||
key={offset}
|
||||
d={createRoundedRectPath(offset, offset < 0)}
|
||||
fill="none"
|
||||
stroke="white"
|
||||
strokeWidth="1.5"
|
||||
strokeDasharray="8 8"
|
||||
opacity="0.6"
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Start/Finish line - checkered flag pattern */}
|
||||
{(() => {
|
||||
const centerX = dimensions.width / 2
|
||||
const centerY = dimensions.height / 2
|
||||
const trackThickness = 35 // Track width from inner to outer edge
|
||||
|
||||
if (isHorizontal) {
|
||||
// Horizontal track: vertical finish line crossing the top straight
|
||||
const x = centerX
|
||||
const yStart = centerY - radius - 18 // Outer edge
|
||||
const squareSize = trackThickness / 6
|
||||
const lineWidth = 12
|
||||
return (
|
||||
<g>
|
||||
{/* Checkered pattern - vertical line */}
|
||||
{[0, 1, 2, 3, 4, 5].map((i) => (
|
||||
<rect
|
||||
key={i}
|
||||
x={x - lineWidth / 2}
|
||||
y={yStart + squareSize * i}
|
||||
width={lineWidth}
|
||||
height={squareSize}
|
||||
fill={i % 2 === 0 ? 'black' : 'white'}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
)
|
||||
} else {
|
||||
// Vertical track: horizontal finish line crossing the left straight
|
||||
const xStart = centerX - radius - 18 // Outer edge
|
||||
const y = centerY
|
||||
const squareSize = trackThickness / 6
|
||||
const lineWidth = 12
|
||||
return (
|
||||
<g>
|
||||
{/* Checkered pattern - horizontal line */}
|
||||
{[0, 1, 2, 3, 4, 5].map((i) => (
|
||||
<rect
|
||||
key={i}
|
||||
x={xStart + squareSize * i}
|
||||
y={y - lineWidth / 2}
|
||||
width={squareSize}
|
||||
height={lineWidth}
|
||||
fill={i % 2 === 0 ? 'black' : 'white'}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
)
|
||||
}
|
||||
})()}
|
||||
|
||||
{/* Distance markers (quarter points) */}
|
||||
{[0.25, 0.5, 0.75].map((fraction) => {
|
||||
const pos = getCircularPosition(fraction * 50)
|
||||
const markerLength = 12
|
||||
const perpAngle = (pos.angle + 90) * (Math.PI / 180)
|
||||
const x1 = pos.x - markerLength * Math.cos(perpAngle)
|
||||
const y1 = pos.y - markerLength * Math.sin(perpAngle)
|
||||
const x2 = pos.x + markerLength * Math.cos(perpAngle)
|
||||
const y2 = pos.y + markerLength * Math.sin(perpAngle)
|
||||
return (
|
||||
<line
|
||||
key={fraction}
|
||||
x1={x1}
|
||||
y1={y1}
|
||||
x2={x2}
|
||||
y2={y2}
|
||||
stroke="white"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{/* Player racer */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${playerPos.x}px`,
|
||||
top: `${playerPos.y}px`,
|
||||
transform: `translate(-50%, -50%) rotate(${playerPos.angle}deg)`,
|
||||
fontSize: '32px',
|
||||
filter: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2))',
|
||||
zIndex: 10,
|
||||
transition: 'left 0.3s ease-out, top 0.3s ease-out',
|
||||
}}
|
||||
>
|
||||
{playerEmoji}
|
||||
</div>
|
||||
|
||||
{/* AI racers */}
|
||||
{aiRacers.map((racer, _index) => {
|
||||
const aiPos = getCircularPosition(racer.position)
|
||||
const activeBubble = state.activeSpeechBubbles.get(racer.id)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={racer.id}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${aiPos.x}px`,
|
||||
top: `${aiPos.y}px`,
|
||||
transform: `translate(-50%, -50%) rotate(${aiPos.angle}deg)`,
|
||||
fontSize: '28px',
|
||||
filter: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2))',
|
||||
zIndex: 5,
|
||||
transition: 'left 0.2s linear, top 0.2s linear',
|
||||
}}
|
||||
>
|
||||
{racer.icon}
|
||||
{activeBubble && (
|
||||
<div
|
||||
style={{
|
||||
transform: `rotate(${-aiPos.angle}deg)`, // Counter-rotate bubble
|
||||
}}
|
||||
>
|
||||
<SpeechBubble
|
||||
message={activeBubble}
|
||||
onHide={() => dispatch({ type: 'CLEAR_AI_COMMENT', racerId: racer.id })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Lap counter */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
background: 'rgba(255, 255, 255, 0.95)',
|
||||
borderRadius: '50%',
|
||||
width: '120px',
|
||||
height: '120px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||
border: '3px solid #3b82f6',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
color: '#6b7280',
|
||||
marginBottom: '4px',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
Lap
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '36px',
|
||||
fontWeight: 'bold',
|
||||
color: '#3b82f6',
|
||||
}}
|
||||
>
|
||||
{playerLap + 1}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: '#9ca3af',
|
||||
marginTop: '4px',
|
||||
}}
|
||||
>
|
||||
{Math.floor(((playerProgress % 50) / 50) * 100)}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lap celebration */}
|
||||
{celebrationCooldown.has('player') && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '20px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
background: 'linear-gradient(135deg, #fbbf24, #f59e0b)',
|
||||
color: 'white',
|
||||
padding: '12px 24px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
boxShadow: '0 4px 20px rgba(251, 191, 36, 0.4)',
|
||||
animation: 'bounce 0.5s ease',
|
||||
zIndex: 100,
|
||||
}}
|
||||
>
|
||||
🎉 Lap {playerLap + 1} Complete! 🎉
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import type { ComplementQuestion, Passenger, Station } from '../../lib/gameTypes'
|
||||
import { AbacusTarget } from '../AbacusTarget'
|
||||
import { PassengerCard } from '../PassengerCard'
|
||||
import { PressureGauge } from '../PressureGauge'
|
||||
|
||||
interface RouteTheme {
|
||||
emoji: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface GameHUDProps {
|
||||
routeTheme: RouteTheme
|
||||
currentRoute: number
|
||||
periodName: string
|
||||
timeRemaining: number
|
||||
pressure: number
|
||||
nonDeliveredPassengers: Passenger[]
|
||||
stations: Station[]
|
||||
currentQuestion: ComplementQuestion | null
|
||||
currentInput: string
|
||||
}
|
||||
|
||||
export const GameHUD = memo(
|
||||
({
|
||||
routeTheme,
|
||||
currentRoute,
|
||||
periodName,
|
||||
timeRemaining,
|
||||
pressure,
|
||||
nonDeliveredPassengers,
|
||||
stations,
|
||||
currentQuestion,
|
||||
currentInput,
|
||||
}: GameHUDProps) => {
|
||||
return (
|
||||
<>
|
||||
{/* Route and time of day indicator */}
|
||||
<div
|
||||
data-component="route-info"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '10px',
|
||||
left: '10px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
{/* Current Route */}
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(0, 0, 0, 0.3)',
|
||||
color: 'white',
|
||||
padding: '8px 14px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
backdropFilter: 'blur(4px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '20px' }}>{routeTheme.emoji}</span>
|
||||
<div>
|
||||
<div style={{ fontSize: '14px', opacity: 0.8 }}>Route {currentRoute}</div>
|
||||
<div style={{ fontSize: '12px', opacity: 0.9 }}>{routeTheme.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time of Day */}
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(0, 0, 0, 0.3)',
|
||||
color: 'white',
|
||||
padding: '6px 12px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
backdropFilter: 'blur(4px)',
|
||||
}}
|
||||
>
|
||||
{periodName}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time remaining */}
|
||||
<div
|
||||
data-component="time-remaining"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '10px',
|
||||
right: '10px',
|
||||
background: 'rgba(0, 0, 0, 0.3)',
|
||||
color: 'white',
|
||||
padding: '6px 12px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
backdropFilter: 'blur(4px)',
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
⏱️ {timeRemaining}s
|
||||
</div>
|
||||
|
||||
{/* Pressure gauge */}
|
||||
<div
|
||||
data-component="pressure-gauge-container"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: '20px',
|
||||
left: '20px',
|
||||
zIndex: 1000,
|
||||
width: '120px',
|
||||
}}
|
||||
>
|
||||
<PressureGauge pressure={pressure} />
|
||||
</div>
|
||||
|
||||
{/* Passenger cards - show all non-delivered passengers */}
|
||||
{nonDeliveredPassengers.length > 0 && (
|
||||
<div
|
||||
data-component="passenger-list"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: '20px',
|
||||
right: '20px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column-reverse',
|
||||
gap: '8px',
|
||||
zIndex: 1000,
|
||||
maxHeight: 'calc(100vh - 40px)',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
{nonDeliveredPassengers.map((passenger) => (
|
||||
<PassengerCard
|
||||
key={passenger.id}
|
||||
passenger={passenger}
|
||||
originStation={stations.find((s) => s.id === passenger.originStationId)}
|
||||
destinationStation={stations.find((s) => s.id === passenger.destinationStationId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Question Display - centered at bottom, equation-focused */}
|
||||
{currentQuestion && (
|
||||
<div
|
||||
data-component="sprint-question-display"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: '20px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
background: 'rgba(255, 255, 255, 0.98)',
|
||||
borderRadius: '24px',
|
||||
padding: '28px 50px',
|
||||
boxShadow: '0 16px 40px rgba(0, 0, 0, 0.5), 0 0 0 5px rgba(59, 130, 246, 0.4)',
|
||||
backdropFilter: 'blur(12px)',
|
||||
border: '4px solid rgba(255, 255, 255, 0.95)',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
{/* Complement equation as main focus */}
|
||||
<div
|
||||
data-element="sprint-question-equation"
|
||||
style={{
|
||||
fontSize: '96px',
|
||||
fontWeight: 'bold',
|
||||
color: '#1f2937',
|
||||
lineHeight: '1.1',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '20px',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #3b82f6, #8b5cf6)',
|
||||
color: 'white',
|
||||
padding: '12px 32px',
|
||||
borderRadius: '16px',
|
||||
minWidth: '140px',
|
||||
display: 'inline-block',
|
||||
textShadow: '0 3px 10px rgba(0, 0, 0, 0.3)',
|
||||
}}
|
||||
>
|
||||
{currentInput || '?'}
|
||||
</span>
|
||||
<span style={{ color: '#6b7280' }}>+</span>
|
||||
{currentQuestion.showAsAbacus ? (
|
||||
<div
|
||||
style={{
|
||||
transform: 'scale(2.4) translateY(8%)',
|
||||
transformOrigin: 'center center',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<AbacusTarget number={currentQuestion.number} />
|
||||
</div>
|
||||
) : (
|
||||
<span>{currentQuestion.number}</span>
|
||||
)}
|
||||
<span style={{ color: '#6b7280' }}>=</span>
|
||||
<span style={{ color: '#10b981' }}>{currentQuestion.targetSum}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
GameHUD.displayName = 'GameHUD'
|
||||
@@ -1,172 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useGameMode } from '@/contexts/GameModeContext'
|
||||
import { useUserProfile } from '@/contexts/UserProfileContext'
|
||||
import { useComplementRace } from '../../context/ComplementRaceContext'
|
||||
import type { AIRacer } from '../../lib/gameTypes'
|
||||
import { SpeechBubble } from '../AISystem/SpeechBubble'
|
||||
|
||||
interface LinearTrackProps {
|
||||
playerProgress: number
|
||||
aiRacers: AIRacer[]
|
||||
raceGoal: number
|
||||
showFinishLine?: boolean
|
||||
}
|
||||
|
||||
export function LinearTrack({
|
||||
playerProgress,
|
||||
aiRacers,
|
||||
raceGoal,
|
||||
showFinishLine = true,
|
||||
}: LinearTrackProps) {
|
||||
const { state, dispatch } = useComplementRace()
|
||||
const { players } = useGameMode()
|
||||
const { profile: _profile } = useUserProfile()
|
||||
|
||||
// Get the first active player's emoji
|
||||
const activePlayers = Array.from(players.values()).filter((p) => p.id)
|
||||
const firstActivePlayer = activePlayers[0]
|
||||
const playerEmoji = firstActivePlayer?.emoji ?? '👤'
|
||||
|
||||
// Position calculation: leftPercent = Math.min(98, (progress / raceGoal) * 96 + 2)
|
||||
// 2% minimum (start), 98% maximum (near finish), 96% range for race
|
||||
const getPosition = (progress: number) => {
|
||||
return Math.min(98, (progress / raceGoal) * 96 + 2)
|
||||
}
|
||||
|
||||
const playerPosition = getPosition(playerProgress)
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="linear-track"
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '200px',
|
||||
background:
|
||||
'linear-gradient(to bottom, #87ceeb 0%, #e0f2fe 50%, #90ee90 50%, #d4f1d4 100%)',
|
||||
borderRadius: '12px',
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||
marginTop: '20px',
|
||||
}}
|
||||
>
|
||||
{/* Track lines */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '2px',
|
||||
background: 'rgba(0, 0, 0, 0.1)',
|
||||
transform: 'translateY(-50%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '40%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '1px',
|
||||
background: 'rgba(0, 0, 0, 0.05)',
|
||||
transform: 'translateY(-50%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '60%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '1px',
|
||||
background: 'rgba(0, 0, 0, 0.05)',
|
||||
transform: 'translateY(-50%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Finish line */}
|
||||
{showFinishLine && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '2%',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: '4px',
|
||||
background:
|
||||
'repeating-linear-gradient(0deg, black 0px, black 10px, white 10px, white 20px)',
|
||||
boxShadow: '0 0 10px rgba(0, 0, 0, 0.3)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Player racer */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${playerPosition}%`,
|
||||
top: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
fontSize: '32px',
|
||||
transition: 'left 0.3s ease-out',
|
||||
filter: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2))',
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
{playerEmoji}
|
||||
</div>
|
||||
|
||||
{/* AI racers */}
|
||||
{aiRacers.map((racer, index) => {
|
||||
const aiPosition = getPosition(racer.position)
|
||||
const activeBubble = state.activeSpeechBubbles.get(racer.id)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={racer.id}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${aiPosition}%`,
|
||||
top: `${35 + index * 15}%`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
fontSize: '28px',
|
||||
transition: 'left 0.2s linear',
|
||||
filter: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2))',
|
||||
zIndex: 5,
|
||||
}}
|
||||
>
|
||||
{racer.icon}
|
||||
{activeBubble && (
|
||||
<SpeechBubble
|
||||
message={activeBubble}
|
||||
onHide={() => dispatch({ type: 'CLEAR_AI_COMMENT', racerId: racer.id })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Progress indicator */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '10px',
|
||||
left: '10px',
|
||||
background: 'rgba(255, 255, 255, 0.9)',
|
||||
padding: '6px 12px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
color: '#1f2937',
|
||||
boxShadow: '0 2px 6px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
>
|
||||
{playerProgress} / {raceGoal}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import type { Passenger, Station } from '../../lib/gameTypes'
|
||||
import type { Landmark } from '../../lib/landmarks'
|
||||
|
||||
interface RailroadTrackPathProps {
|
||||
tiesAndRails: {
|
||||
ties: Array<{ x1: number; y1: number; x2: number; y2: number }>
|
||||
leftRailPath: string
|
||||
rightRailPath: string
|
||||
} | null
|
||||
referencePath: string
|
||||
pathRef: React.RefObject<SVGPathElement>
|
||||
landmarkPositions: Array<{ x: number; y: number }>
|
||||
landmarks: Landmark[]
|
||||
stationPositions: Array<{ x: number; y: number }>
|
||||
stations: Station[]
|
||||
passengers: Passenger[]
|
||||
boardingAnimations: Map<string, unknown>
|
||||
disembarkingAnimations: Map<string, unknown>
|
||||
}
|
||||
|
||||
export const RailroadTrackPath = memo(
|
||||
({
|
||||
tiesAndRails,
|
||||
referencePath,
|
||||
pathRef,
|
||||
landmarkPositions,
|
||||
landmarks,
|
||||
stationPositions,
|
||||
stations,
|
||||
passengers,
|
||||
boardingAnimations,
|
||||
disembarkingAnimations,
|
||||
}: RailroadTrackPathProps) => {
|
||||
return (
|
||||
<>
|
||||
{/* Railroad ties */}
|
||||
{tiesAndRails?.ties.map((tie, index) => (
|
||||
<line
|
||||
key={`tie-${index}`}
|
||||
x1={tie.x1}
|
||||
y1={tie.y1}
|
||||
x2={tie.x2}
|
||||
y2={tie.y2}
|
||||
stroke="#654321"
|
||||
strokeWidth="5"
|
||||
strokeLinecap="round"
|
||||
opacity="0.8"
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Left rail */}
|
||||
{tiesAndRails?.leftRailPath && (
|
||||
<path
|
||||
d={tiesAndRails.leftRailPath}
|
||||
fill="none"
|
||||
stroke="#C0C0C0"
|
||||
strokeWidth="5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Right rail */}
|
||||
{tiesAndRails?.rightRailPath && (
|
||||
<path
|
||||
d={tiesAndRails.rightRailPath}
|
||||
fill="none"
|
||||
stroke="#C0C0C0"
|
||||
strokeWidth="5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Reference path (invisible, used for positioning) */}
|
||||
<path ref={pathRef} d={referencePath} fill="none" stroke="transparent" strokeWidth="2" />
|
||||
|
||||
{/* Landmarks - background scenery */}
|
||||
{landmarkPositions.map((pos, index) => (
|
||||
<text
|
||||
key={`landmark-${index}`}
|
||||
x={pos.x}
|
||||
y={pos.y}
|
||||
textAnchor="middle"
|
||||
style={{
|
||||
fontSize: `${(landmarks[index]?.size || 24) * 2.0}px`,
|
||||
pointerEvents: 'none',
|
||||
opacity: 0.7,
|
||||
filter: 'drop-shadow(0 2px 3px rgba(0, 0, 0, 0.2))',
|
||||
}}
|
||||
>
|
||||
{landmarks[index]?.emoji}
|
||||
</text>
|
||||
))}
|
||||
|
||||
{/* Station markers */}
|
||||
{stationPositions.map((pos, index) => {
|
||||
const station = stations[index]
|
||||
// Find passengers waiting at this station (exclude currently boarding)
|
||||
const waitingPassengers = passengers.filter(
|
||||
(p) =>
|
||||
p.originStationId === station?.id &&
|
||||
!p.isBoarded &&
|
||||
!p.isDelivered &&
|
||||
!boardingAnimations.has(p.id)
|
||||
)
|
||||
// Find passengers delivered at this station (exclude currently disembarking)
|
||||
const deliveredPassengers = passengers.filter(
|
||||
(p) =>
|
||||
p.destinationStationId === station?.id &&
|
||||
p.isDelivered &&
|
||||
!disembarkingAnimations.has(p.id)
|
||||
)
|
||||
|
||||
return (
|
||||
<g key={`station-${index}`}>
|
||||
{/* Station platform */}
|
||||
<circle
|
||||
cx={pos.x}
|
||||
cy={pos.y}
|
||||
r="18"
|
||||
fill="#8B4513"
|
||||
stroke="#654321"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
{/* Station icon */}
|
||||
<text
|
||||
x={pos.x}
|
||||
y={pos.y - 40}
|
||||
textAnchor="middle"
|
||||
fontSize="48"
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
{station?.icon}
|
||||
</text>
|
||||
{/* Station name */}
|
||||
<text
|
||||
x={pos.x}
|
||||
y={pos.y + 50}
|
||||
textAnchor="middle"
|
||||
fontSize="20"
|
||||
fill="#1f2937"
|
||||
stroke="#f59e0b"
|
||||
strokeWidth="0.5"
|
||||
style={{
|
||||
fontWeight: 900,
|
||||
pointerEvents: 'none',
|
||||
fontFamily: '"Comic Sans MS", "Chalkboard SE", "Bradley Hand", cursive',
|
||||
textShadow: '0 2px 4px rgba(0, 0, 0, 0.2)',
|
||||
letterSpacing: '0.5px',
|
||||
paintOrder: 'stroke fill',
|
||||
}}
|
||||
>
|
||||
{station?.name}
|
||||
</text>
|
||||
|
||||
{/* Waiting passengers at this station */}
|
||||
{waitingPassengers.map((passenger, pIndex) => (
|
||||
<text
|
||||
key={`waiting-${passenger.id}`}
|
||||
x={pos.x + (pIndex - waitingPassengers.length / 2 + 0.5) * 28}
|
||||
y={pos.y - 30}
|
||||
textAnchor="middle"
|
||||
style={{
|
||||
fontSize: '55px',
|
||||
pointerEvents: 'none',
|
||||
filter: passenger.isUrgent
|
||||
? 'drop-shadow(0 0 8px rgba(245, 158, 11, 0.8))'
|
||||
: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))',
|
||||
}}
|
||||
>
|
||||
{passenger.avatar}
|
||||
</text>
|
||||
))}
|
||||
|
||||
{/* Delivered passengers at this station (celebrating) */}
|
||||
{deliveredPassengers.map((passenger, pIndex) => (
|
||||
<text
|
||||
key={`delivered-${passenger.id}`}
|
||||
x={pos.x + (pIndex - deliveredPassengers.length / 2 + 0.5) * 28}
|
||||
y={pos.y - 30}
|
||||
textAnchor="middle"
|
||||
style={{
|
||||
fontSize: '55px',
|
||||
pointerEvents: 'none',
|
||||
filter: 'drop-shadow(0 0 12px rgba(16, 185, 129, 0.8))',
|
||||
animation: 'celebrateDelivery 2s ease-out forwards',
|
||||
}}
|
||||
>
|
||||
{passenger.avatar}
|
||||
</text>
|
||||
))}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
RailroadTrackPath.displayName = 'RailroadTrackPath'
|
||||
@@ -1,318 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { animated, useSpring } from '@react-spring/web'
|
||||
import { memo, useMemo, useRef, useState } from 'react'
|
||||
import { useGameMode } from '@/contexts/GameModeContext'
|
||||
import { useUserProfile } from '@/contexts/UserProfileContext'
|
||||
import { useComplementRace } from '../../context/ComplementRaceContext'
|
||||
import {
|
||||
type BoardingAnimation,
|
||||
type DisembarkingAnimation,
|
||||
usePassengerAnimations,
|
||||
} from '../../hooks/usePassengerAnimations'
|
||||
import type { ComplementQuestion } from '../../lib/gameTypes'
|
||||
import { useSteamJourney } from '../../hooks/useSteamJourney'
|
||||
import { useTrackManagement } from '../../hooks/useTrackManagement'
|
||||
import { useTrainTransforms } from '../../hooks/useTrainTransforms'
|
||||
import { calculateMaxConcurrentPassengers } from '../../lib/passengerGenerator'
|
||||
import { RailroadTrackGenerator } from '../../lib/RailroadTrackGenerator'
|
||||
import { getRouteTheme } from '../../lib/routeThemes'
|
||||
import { GameHUD } from './GameHUD'
|
||||
import { RailroadTrackPath } from './RailroadTrackPath'
|
||||
import { TrainAndCars } from './TrainAndCars'
|
||||
import { TrainTerrainBackground } from './TrainTerrainBackground'
|
||||
|
||||
const BoardingPassengerAnimation = memo(({ animation }: { animation: BoardingAnimation }) => {
|
||||
const spring = useSpring({
|
||||
from: { x: animation.fromX, y: animation.fromY, opacity: 1 },
|
||||
to: { x: animation.toX, y: animation.toY, opacity: 1 },
|
||||
config: { tension: 120, friction: 14 },
|
||||
})
|
||||
|
||||
return (
|
||||
<animated.text
|
||||
x={spring.x}
|
||||
y={spring.y}
|
||||
textAnchor="middle"
|
||||
opacity={spring.opacity}
|
||||
style={{
|
||||
fontSize: '55px',
|
||||
pointerEvents: 'none',
|
||||
filter: animation.passenger.isUrgent
|
||||
? 'drop-shadow(0 0 8px rgba(245, 158, 11, 0.8))'
|
||||
: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))',
|
||||
}}
|
||||
>
|
||||
{animation.passenger.avatar}
|
||||
</animated.text>
|
||||
)
|
||||
})
|
||||
BoardingPassengerAnimation.displayName = 'BoardingPassengerAnimation'
|
||||
|
||||
const DisembarkingPassengerAnimation = memo(
|
||||
({ animation }: { animation: DisembarkingAnimation }) => {
|
||||
const spring = useSpring({
|
||||
from: { x: animation.fromX, y: animation.fromY, opacity: 1 },
|
||||
to: { x: animation.toX, y: animation.toY, opacity: 1 },
|
||||
config: { tension: 120, friction: 14 },
|
||||
})
|
||||
|
||||
return (
|
||||
<animated.text
|
||||
x={spring.x}
|
||||
y={spring.y}
|
||||
textAnchor="middle"
|
||||
opacity={spring.opacity}
|
||||
style={{
|
||||
fontSize: '55px',
|
||||
pointerEvents: 'none',
|
||||
filter: 'drop-shadow(0 0 12px rgba(16, 185, 129, 0.8))',
|
||||
}}
|
||||
>
|
||||
{animation.passenger.avatar}
|
||||
</animated.text>
|
||||
)
|
||||
}
|
||||
)
|
||||
DisembarkingPassengerAnimation.displayName = 'DisembarkingPassengerAnimation'
|
||||
|
||||
interface SteamTrainJourneyProps {
|
||||
momentum: number
|
||||
trainPosition: number
|
||||
pressure: number
|
||||
elapsedTime: number
|
||||
currentQuestion: ComplementQuestion | null
|
||||
currentInput: string
|
||||
}
|
||||
|
||||
export function SteamTrainJourney({
|
||||
momentum,
|
||||
trainPosition,
|
||||
pressure,
|
||||
elapsedTime,
|
||||
currentQuestion,
|
||||
currentInput,
|
||||
}: SteamTrainJourneyProps) {
|
||||
const { state } = useComplementRace()
|
||||
const { getSkyGradient, getTimeOfDayPeriod } = useSteamJourney()
|
||||
const _skyGradient = getSkyGradient()
|
||||
const period = getTimeOfDayPeriod()
|
||||
const { players } = useGameMode()
|
||||
const { profile: _profile } = useUserProfile()
|
||||
|
||||
// Get the first active player's emoji
|
||||
const activePlayers = Array.from(players.values()).filter((p) => p.id)
|
||||
const firstActivePlayer = activePlayers[0]
|
||||
const playerEmoji = firstActivePlayer?.emoji ?? '👤'
|
||||
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
const pathRef = useRef<SVGPathElement>(null)
|
||||
const [trackGenerator] = useState(() => new RailroadTrackGenerator(800, 600))
|
||||
|
||||
// Calculate the number of train cars dynamically based on max concurrent passengers
|
||||
const maxCars = useMemo(() => {
|
||||
const maxPassengers = calculateMaxConcurrentPassengers(state.passengers, state.stations)
|
||||
// Ensure at least 1 car, even if no passengers
|
||||
return Math.max(1, maxPassengers)
|
||||
}, [state.passengers, state.stations])
|
||||
|
||||
const carSpacing = 7 // Distance between cars (in % of track)
|
||||
|
||||
// Train transforms (extracted to hook)
|
||||
const { trainTransform, trainCars, locomotiveOpacity } = useTrainTransforms({
|
||||
trainPosition,
|
||||
trackGenerator,
|
||||
pathRef,
|
||||
maxCars,
|
||||
carSpacing,
|
||||
})
|
||||
|
||||
// Track management (extracted to hook)
|
||||
const {
|
||||
trackData,
|
||||
tiesAndRails,
|
||||
stationPositions,
|
||||
landmarks,
|
||||
landmarkPositions,
|
||||
displayPassengers,
|
||||
} = useTrackManagement({
|
||||
currentRoute: state.currentRoute,
|
||||
trainPosition,
|
||||
trackGenerator,
|
||||
pathRef,
|
||||
stations: state.stations,
|
||||
passengers: state.passengers,
|
||||
maxCars,
|
||||
carSpacing,
|
||||
})
|
||||
|
||||
// Passenger animations (extracted to hook)
|
||||
const { boardingAnimations, disembarkingAnimations } = usePassengerAnimations({
|
||||
passengers: state.passengers,
|
||||
stations: state.stations,
|
||||
stationPositions,
|
||||
trainPosition,
|
||||
trackGenerator,
|
||||
pathRef,
|
||||
})
|
||||
|
||||
// Time remaining (60 seconds total)
|
||||
const timeRemaining = Math.max(0, 60 - Math.floor(elapsedTime / 1000))
|
||||
|
||||
// Period names for display
|
||||
const periodNames = ['Dawn', 'Morning', 'Midday', 'Afternoon', 'Dusk', 'Night']
|
||||
|
||||
// Get current route theme
|
||||
const routeTheme = getRouteTheme(state.currentRoute)
|
||||
|
||||
// Memoize filtered passenger lists to avoid recalculating on every render
|
||||
const boardedPassengers = useMemo(
|
||||
() => displayPassengers.filter((p) => p.isBoarded && !p.isDelivered),
|
||||
[displayPassengers]
|
||||
)
|
||||
|
||||
const nonDeliveredPassengers = useMemo(
|
||||
() => displayPassengers.filter((p) => !p.isDelivered),
|
||||
[displayPassengers]
|
||||
)
|
||||
|
||||
// Memoize ground texture circles to avoid recreating on every render
|
||||
const groundTextureCircles = useMemo(
|
||||
() =>
|
||||
Array.from({ length: 30 }).map((_, i) => ({
|
||||
key: `ground-texture-${i}`,
|
||||
cx: -30 + i * 28 + (i % 3) * 10,
|
||||
cy: 140 + (i % 5) * 60,
|
||||
r: 2 + (i % 3),
|
||||
})),
|
||||
[]
|
||||
)
|
||||
|
||||
if (!trackData) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="steam-train-journey"
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background: 'transparent',
|
||||
overflow: 'visible',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'stretch',
|
||||
}}
|
||||
>
|
||||
{/* Game HUD - overlays and UI elements */}
|
||||
<GameHUD
|
||||
routeTheme={routeTheme}
|
||||
currentRoute={state.currentRoute}
|
||||
periodName={periodNames[period]}
|
||||
timeRemaining={timeRemaining}
|
||||
pressure={pressure}
|
||||
nonDeliveredPassengers={nonDeliveredPassengers}
|
||||
stations={state.stations}
|
||||
currentQuestion={currentQuestion}
|
||||
currentInput={currentInput}
|
||||
/>
|
||||
|
||||
{/* Railroad track SVG */}
|
||||
<svg
|
||||
data-component="railroad-track"
|
||||
ref={svgRef}
|
||||
viewBox="-50 -50 900 700"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
aspectRatio: '800 / 600',
|
||||
overflow: 'visible',
|
||||
}}
|
||||
>
|
||||
{/* Terrain background - ground, mountains, and tunnels */}
|
||||
<TrainTerrainBackground
|
||||
ballastPath={trackData.ballastPath}
|
||||
groundTextureCircles={groundTextureCircles}
|
||||
/>
|
||||
|
||||
{/* Railroad track, landmarks, and stations */}
|
||||
<RailroadTrackPath
|
||||
tiesAndRails={tiesAndRails}
|
||||
referencePath={trackData.referencePath}
|
||||
pathRef={pathRef}
|
||||
landmarkPositions={landmarkPositions}
|
||||
landmarks={landmarks}
|
||||
stationPositions={stationPositions}
|
||||
stations={state.stations}
|
||||
passengers={displayPassengers}
|
||||
boardingAnimations={boardingAnimations}
|
||||
disembarkingAnimations={disembarkingAnimations}
|
||||
/>
|
||||
|
||||
{/* Train, cars, and passenger animations */}
|
||||
<TrainAndCars
|
||||
boardingAnimations={boardingAnimations}
|
||||
disembarkingAnimations={disembarkingAnimations}
|
||||
BoardingPassengerAnimation={BoardingPassengerAnimation}
|
||||
DisembarkingPassengerAnimation={DisembarkingPassengerAnimation}
|
||||
trainCars={trainCars}
|
||||
boardedPassengers={boardedPassengers}
|
||||
trainTransform={trainTransform}
|
||||
locomotiveOpacity={locomotiveOpacity}
|
||||
playerEmoji={playerEmoji}
|
||||
momentum={momentum}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* CSS animations */}
|
||||
<style>{`
|
||||
@keyframes steamPuffSVG {
|
||||
0% {
|
||||
opacity: 0.8;
|
||||
transform: scale(0.5) translate(0, 0);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
transform: scale(1.5) translate(15px, -30px);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale(2) translate(25px, -60px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes coalFallingSVG {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
transform: translate(5px, 15px) scale(0.8);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(8px, 30px) scale(0.5);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes celebrateDelivery {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
20% {
|
||||
transform: scale(1.3) translateY(-10px);
|
||||
}
|
||||
40% {
|
||||
transform: scale(1.2) translateY(-5px);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale(0.8) translateY(-20px);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import type { BoardingAnimation, DisembarkingAnimation } from '../../hooks/usePassengerAnimations'
|
||||
import type { Passenger } from '../../lib/gameTypes'
|
||||
|
||||
interface TrainCarTransform {
|
||||
x: number
|
||||
y: number
|
||||
rotation: number
|
||||
position: number
|
||||
opacity: number
|
||||
}
|
||||
|
||||
interface TrainTransform {
|
||||
x: number
|
||||
y: number
|
||||
rotation: number
|
||||
}
|
||||
|
||||
interface TrainAndCarsProps {
|
||||
boardingAnimations: Map<string, BoardingAnimation>
|
||||
disembarkingAnimations: Map<string, DisembarkingAnimation>
|
||||
BoardingPassengerAnimation: React.ComponentType<{
|
||||
animation: BoardingAnimation
|
||||
}>
|
||||
DisembarkingPassengerAnimation: React.ComponentType<{
|
||||
animation: DisembarkingAnimation
|
||||
}>
|
||||
trainCars: TrainCarTransform[]
|
||||
boardedPassengers: Passenger[]
|
||||
trainTransform: TrainTransform
|
||||
locomotiveOpacity: number
|
||||
playerEmoji: string
|
||||
momentum: number
|
||||
}
|
||||
|
||||
export const TrainAndCars = memo(
|
||||
({
|
||||
boardingAnimations,
|
||||
disembarkingAnimations,
|
||||
BoardingPassengerAnimation,
|
||||
DisembarkingPassengerAnimation,
|
||||
trainCars,
|
||||
boardedPassengers,
|
||||
trainTransform,
|
||||
locomotiveOpacity,
|
||||
playerEmoji,
|
||||
momentum,
|
||||
}: TrainAndCarsProps) => {
|
||||
return (
|
||||
<>
|
||||
{/* Boarding animations - passengers moving from station to train car */}
|
||||
{Array.from(boardingAnimations.values()).map((animation) => (
|
||||
<BoardingPassengerAnimation
|
||||
key={`boarding-${animation.passenger.id}`}
|
||||
animation={animation}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Disembarking animations - passengers moving from train car to station */}
|
||||
{Array.from(disembarkingAnimations.values()).map((animation) => (
|
||||
<DisembarkingPassengerAnimation
|
||||
key={`disembarking-${animation.passenger.id}`}
|
||||
animation={animation}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Train cars - render in reverse order so locomotive appears on top */}
|
||||
{trainCars.map((carTransform, carIndex) => {
|
||||
// Assign passenger to this car (if one exists for this car index)
|
||||
const passenger = boardedPassengers[carIndex]
|
||||
|
||||
return (
|
||||
<g
|
||||
key={`train-car-${carIndex}`}
|
||||
data-component="train-car"
|
||||
transform={`translate(${carTransform.x}, ${carTransform.y}) rotate(${carTransform.rotation}) scale(-1, 1)`}
|
||||
opacity={carTransform.opacity}
|
||||
style={{
|
||||
transition: 'opacity 0.5s ease-in',
|
||||
}}
|
||||
>
|
||||
{/* Train car */}
|
||||
<text
|
||||
data-element="train-car-body"
|
||||
x={0}
|
||||
y={0}
|
||||
textAnchor="middle"
|
||||
style={{
|
||||
fontSize: '65px',
|
||||
filter: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
🚃
|
||||
</text>
|
||||
|
||||
{/* Passenger inside this car (hide if currently boarding) */}
|
||||
{passenger && !boardingAnimations.has(passenger.id) && (
|
||||
<text
|
||||
data-element="car-passenger"
|
||||
x={0}
|
||||
y={0}
|
||||
textAnchor="middle"
|
||||
style={{
|
||||
fontSize: '42px',
|
||||
filter: passenger.isUrgent
|
||||
? 'drop-shadow(0 0 6px rgba(245, 158, 11, 0.8))'
|
||||
: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{passenger.avatar}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Locomotive - rendered last so it appears on top */}
|
||||
<g
|
||||
data-component="locomotive-group"
|
||||
transform={`translate(${trainTransform.x}, ${trainTransform.y}) rotate(${trainTransform.rotation}) scale(-1, 1)`}
|
||||
opacity={locomotiveOpacity}
|
||||
style={{
|
||||
transition: 'opacity 0.5s ease-in',
|
||||
}}
|
||||
>
|
||||
{/* Train locomotive */}
|
||||
<text
|
||||
data-element="train-locomotive"
|
||||
x={0}
|
||||
y={0}
|
||||
textAnchor="middle"
|
||||
style={{
|
||||
fontSize: '100px',
|
||||
filter: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
🚂
|
||||
</text>
|
||||
|
||||
{/* Player engineer - layered over the train */}
|
||||
<text
|
||||
data-element="player-engineer"
|
||||
x={45}
|
||||
y={0}
|
||||
textAnchor="middle"
|
||||
style={{
|
||||
fontSize: '70px',
|
||||
filter: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{playerEmoji}
|
||||
</text>
|
||||
|
||||
{/* Steam puffs - positioned at smokestack, layered over train */}
|
||||
{momentum > 10 &&
|
||||
[0, 0.6, 1.2].map((delay, i) => (
|
||||
<circle
|
||||
key={`steam-${i}`}
|
||||
cx={-35}
|
||||
cy={-35}
|
||||
r="10"
|
||||
fill="rgba(255, 255, 255, 0.6)"
|
||||
style={{
|
||||
filter: 'blur(4px)',
|
||||
animation: `steamPuffSVG 2s ease-out infinite`,
|
||||
animationDelay: `${delay}s`,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Coal particles - animated when shoveling */}
|
||||
{momentum > 60 &&
|
||||
[0, 0.3, 0.6].map((delay, i) => (
|
||||
<circle
|
||||
key={`coal-${i}`}
|
||||
cx={25}
|
||||
cy={0}
|
||||
r="3"
|
||||
fill="#2c2c2c"
|
||||
style={{
|
||||
animation: 'coalFallingSVG 1.2s ease-out infinite',
|
||||
animationDelay: `${delay}s`,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
</>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
TrainAndCars.displayName = 'TrainAndCars'
|
||||
@@ -1,144 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
|
||||
interface TrainTerrainBackgroundProps {
|
||||
ballastPath: string
|
||||
groundTextureCircles: Array<{
|
||||
key: string
|
||||
cx: number
|
||||
cy: number
|
||||
r: number
|
||||
}>
|
||||
}
|
||||
|
||||
export const TrainTerrainBackground = memo(
|
||||
({ ballastPath, groundTextureCircles }: TrainTerrainBackgroundProps) => {
|
||||
return (
|
||||
<>
|
||||
{/* Gradient definitions for mountain shading and ground */}
|
||||
<defs>
|
||||
<linearGradient id="mountainGradientLeft" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style={{ stopColor: '#a0a0a0', stopOpacity: 0.8 }} />
|
||||
<stop offset="50%" style={{ stopColor: '#7a7a7a', stopOpacity: 0.6 }} />
|
||||
<stop offset="100%" style={{ stopColor: '#5a5a5a', stopOpacity: 0.4 }} />
|
||||
</linearGradient>
|
||||
<linearGradient id="mountainGradientRight" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style={{ stopColor: '#5a5a5a', stopOpacity: 0.4 }} />
|
||||
<stop offset="50%" style={{ stopColor: '#7a7a7a', stopOpacity: 0.6 }} />
|
||||
<stop offset="100%" style={{ stopColor: '#a0a0a0', stopOpacity: 0.8 }} />
|
||||
</linearGradient>
|
||||
<linearGradient id="groundGradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style={{ stopColor: '#6a8759', stopOpacity: 0.3 }} />
|
||||
<stop offset="100%" style={{ stopColor: '#8B7355', stopOpacity: 0 }} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{/* Ground layer - extends full width and height to cover entire track area */}
|
||||
<rect x="-50" y="120" width="900" height="530" fill="#8B7355" />
|
||||
|
||||
{/* Ground surface gradient for depth */}
|
||||
<rect x="-50" y="120" width="900" height="60" fill="url(#groundGradient)" />
|
||||
|
||||
{/* Ground texture - scattered rocks/pebbles */}
|
||||
{groundTextureCircles.map((circle) => (
|
||||
<circle
|
||||
key={circle.key}
|
||||
cx={circle.cx}
|
||||
cy={circle.cy}
|
||||
r={circle.r}
|
||||
fill="#654321"
|
||||
opacity={0.3}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Railroad ballast (gravel bed) */}
|
||||
<path d={ballastPath} fill="none" stroke="#8B7355" strokeWidth="40" strokeLinecap="round" />
|
||||
|
||||
{/* Left mountain and tunnel */}
|
||||
<g data-element="left-tunnel">
|
||||
{/* Mountain base - extends from left edge */}
|
||||
<rect x="-50" y="200" width="120" height="450" fill="#6b7280" />
|
||||
|
||||
{/* Mountain peak - triangular slope */}
|
||||
<path d="M -50 200 L 70 200 L 20 -50 L -50 100 Z" fill="#8b8b8b" />
|
||||
|
||||
{/* Mountain ridge shading */}
|
||||
<path d="M -50 200 L 70 200 L 20 -50 Z" fill="url(#mountainGradientLeft)" />
|
||||
|
||||
{/* Tunnel depth/interior (dark entrance) */}
|
||||
<ellipse cx="20" cy="300" rx="50" ry="55" fill="#0a0a0a" />
|
||||
|
||||
{/* Tunnel arch opening */}
|
||||
<path
|
||||
d="M 20 355 L -50 355 L -50 245 Q -50 235, 20 235 Q 70 235, 70 245 L 70 355 Z"
|
||||
fill="#1a1a1a"
|
||||
stroke="#4a4a4a"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
|
||||
{/* Tunnel arch rim (stone bricks) */}
|
||||
<path
|
||||
d="M -50 245 Q -50 235, 20 235 Q 70 235, 70 245"
|
||||
fill="none"
|
||||
stroke="#8b7355"
|
||||
strokeWidth="8"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
|
||||
{/* Stone brick texture around arch */}
|
||||
<path
|
||||
d="M -50 245 Q -50 235, 20 235 Q 70 235, 70 245"
|
||||
fill="none"
|
||||
stroke="#654321"
|
||||
strokeWidth="2"
|
||||
strokeDasharray="15,10"
|
||||
/>
|
||||
</g>
|
||||
|
||||
{/* Right mountain and tunnel */}
|
||||
<g data-element="right-tunnel">
|
||||
{/* Mountain base - extends to right edge */}
|
||||
<rect x="680" y="200" width="170" height="450" fill="#6b7280" />
|
||||
|
||||
{/* Mountain peak - triangular slope */}
|
||||
<path d="M 730 200 L 850 200 L 850 100 L 780 -50 Z" fill="#8b8b8b" />
|
||||
|
||||
{/* Mountain ridge shading */}
|
||||
<path d="M 730 200 L 850 150 L 780 -50 Z" fill="url(#mountainGradientRight)" />
|
||||
|
||||
{/* Tunnel depth/interior (dark entrance) */}
|
||||
<ellipse cx="780" cy="300" rx="50" ry="55" fill="#0a0a0a" />
|
||||
|
||||
{/* Tunnel arch opening */}
|
||||
<path
|
||||
d="M 780 355 L 730 355 L 730 245 Q 730 235, 780 235 Q 850 235, 850 245 L 850 355 Z"
|
||||
fill="#1a1a1a"
|
||||
stroke="#4a4a4a"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
|
||||
{/* Tunnel arch rim (stone bricks) */}
|
||||
<path
|
||||
d="M 730 245 Q 730 235, 780 235 Q 850 235, 850 245"
|
||||
fill="none"
|
||||
stroke="#8b7355"
|
||||
strokeWidth="8"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
|
||||
{/* Stone brick texture around arch */}
|
||||
<path
|
||||
d="M 730 245 Q 730 235, 780 235 Q 850 235, 850 245"
|
||||
fill="none"
|
||||
stroke="#654321"
|
||||
strokeWidth="2"
|
||||
strokeDasharray="15,10"
|
||||
/>
|
||||
</g>
|
||||
</>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
TrainTerrainBackground.displayName = 'TrainTerrainBackground'
|
||||
@@ -1,166 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
import type { Passenger, Station } from '../../../lib/gameTypes'
|
||||
import { GameHUD } from '../GameHUD'
|
||||
|
||||
// Mock child components
|
||||
vi.mock('../../PassengerCard', () => ({
|
||||
PassengerCard: ({ passenger }: { passenger: Passenger }) => (
|
||||
<div data-testid="passenger-card">{passenger.avatar}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../PressureGauge', () => ({
|
||||
PressureGauge: ({ pressure }: { pressure: number }) => (
|
||||
<div data-testid="pressure-gauge">{pressure}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('GameHUD', () => {
|
||||
const mockRouteTheme = {
|
||||
emoji: '🚂',
|
||||
name: 'Mountain Pass',
|
||||
}
|
||||
|
||||
const mockStations: Station[] = [
|
||||
{ id: 'station-1', name: 'Station 1', position: 20, icon: '🏭' },
|
||||
{ id: 'station-2', name: 'Station 2', position: 60, icon: '🏛️' },
|
||||
]
|
||||
|
||||
const mockPassenger: Passenger = {
|
||||
id: 'passenger-1',
|
||||
avatar: '👨',
|
||||
originStationId: 'station-1',
|
||||
destinationStationId: 'station-2',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
routeTheme: mockRouteTheme,
|
||||
currentRoute: 1,
|
||||
periodName: '🌅 Dawn',
|
||||
timeRemaining: 45,
|
||||
pressure: 75,
|
||||
nonDeliveredPassengers: [],
|
||||
stations: mockStations,
|
||||
currentQuestion: {
|
||||
number: 3,
|
||||
targetSum: 10,
|
||||
correctAnswer: 7,
|
||||
showAsAbacus: false,
|
||||
},
|
||||
currentInput: '7',
|
||||
}
|
||||
|
||||
test('renders route information', () => {
|
||||
render(<GameHUD {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('Route 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Mountain Pass')).toBeInTheDocument()
|
||||
expect(screen.getByText('🚂')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('renders time of day period', () => {
|
||||
render(<GameHUD {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('🌅 Dawn')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('renders time remaining', () => {
|
||||
render(<GameHUD {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText(/45s/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('renders pressure gauge', () => {
|
||||
render(<GameHUD {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('pressure-gauge')).toBeInTheDocument()
|
||||
expect(screen.getByText('75')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('renders passenger list when passengers exist', () => {
|
||||
render(<GameHUD {...defaultProps} nonDeliveredPassengers={[mockPassenger]} />)
|
||||
|
||||
expect(screen.getByTestId('passenger-card')).toBeInTheDocument()
|
||||
expect(screen.getByText('👨')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('does not render passenger list when empty', () => {
|
||||
render(<GameHUD {...defaultProps} nonDeliveredPassengers={[]} />)
|
||||
|
||||
expect(screen.queryByTestId('passenger-card')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('renders current question when provided', () => {
|
||||
render(<GameHUD {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('7')).toBeInTheDocument() // currentInput
|
||||
expect(screen.getByText('3')).toBeInTheDocument() // question.number
|
||||
expect(screen.getByText('10')).toBeInTheDocument() // targetSum
|
||||
expect(screen.getByText('+')).toBeInTheDocument()
|
||||
expect(screen.getByText('=')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('shows question mark when no input', () => {
|
||||
render(<GameHUD {...defaultProps} currentInput="" />)
|
||||
|
||||
expect(screen.getByText('?')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('does not render question display when currentQuestion is null', () => {
|
||||
render(<GameHUD {...defaultProps} currentQuestion={null} />)
|
||||
|
||||
expect(screen.queryByText('+')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('=')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('renders multiple passengers', () => {
|
||||
const passengers = [
|
||||
mockPassenger,
|
||||
{ ...mockPassenger, id: 'passenger-2', avatar: '👩' },
|
||||
{ ...mockPassenger, id: 'passenger-3', avatar: '👧' },
|
||||
]
|
||||
|
||||
render(<GameHUD {...defaultProps} nonDeliveredPassengers={passengers} />)
|
||||
|
||||
expect(screen.getAllByTestId('passenger-card')).toHaveLength(3)
|
||||
expect(screen.getByText('👨')).toBeInTheDocument()
|
||||
expect(screen.getByText('👩')).toBeInTheDocument()
|
||||
expect(screen.getByText('👧')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('updates when route changes', () => {
|
||||
const { rerender } = render(<GameHUD {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('Route 1')).toBeInTheDocument()
|
||||
|
||||
rerender(<GameHUD {...defaultProps} currentRoute={2} />)
|
||||
|
||||
expect(screen.getByText('Route 2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('updates when time remaining changes', () => {
|
||||
const { rerender } = render(<GameHUD {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText(/45s/)).toBeInTheDocument()
|
||||
|
||||
rerender(<GameHUD {...defaultProps} timeRemaining={30} />)
|
||||
|
||||
expect(screen.getByText(/30s/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('memoization: same props do not cause re-render', () => {
|
||||
const { rerender, container } = render(<GameHUD {...defaultProps} />)
|
||||
|
||||
const initialHTML = container.innerHTML
|
||||
|
||||
// Rerender with same props
|
||||
rerender(<GameHUD {...defaultProps} />)
|
||||
|
||||
// Should be memoized (same HTML)
|
||||
expect(container.innerHTML).toBe(initialHTML)
|
||||
})
|
||||
})
|
||||
@@ -1,191 +0,0 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import { TrainTerrainBackground } from '../TrainTerrainBackground'
|
||||
|
||||
describe('TrainTerrainBackground', () => {
|
||||
const mockGroundCircles = [
|
||||
{ key: 'ground-1', cx: 10, cy: 150, r: 2 },
|
||||
{ key: 'ground-2', cx: 40, cy: 180, r: 3 },
|
||||
]
|
||||
|
||||
test('renders without crashing', () => {
|
||||
const { container } = render(
|
||||
<svg>
|
||||
<TrainTerrainBackground
|
||||
ballastPath="M 0 300 L 800 300"
|
||||
groundTextureCircles={mockGroundCircles}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
test('renders gradient definitions', () => {
|
||||
const { container } = render(
|
||||
<svg>
|
||||
<TrainTerrainBackground
|
||||
ballastPath="M 0 300 L 800 300"
|
||||
groundTextureCircles={mockGroundCircles}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
const defs = container.querySelector('defs')
|
||||
expect(defs).toBeTruthy()
|
||||
|
||||
// Check for gradient IDs
|
||||
expect(container.querySelector('#mountainGradientLeft')).toBeTruthy()
|
||||
expect(container.querySelector('#mountainGradientRight')).toBeTruthy()
|
||||
expect(container.querySelector('#groundGradient')).toBeTruthy()
|
||||
})
|
||||
|
||||
test('renders ground layer rects', () => {
|
||||
const { container } = render(
|
||||
<svg>
|
||||
<TrainTerrainBackground
|
||||
ballastPath="M 0 300 L 800 300"
|
||||
groundTextureCircles={mockGroundCircles}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
const rects = container.querySelectorAll('rect')
|
||||
expect(rects.length).toBeGreaterThan(0)
|
||||
|
||||
// Check for ground base layer
|
||||
const groundRect = Array.from(rects).find(
|
||||
(rect) => rect.getAttribute('fill') === '#8B7355' && rect.getAttribute('width') === '900'
|
||||
)
|
||||
expect(groundRect).toBeTruthy()
|
||||
})
|
||||
|
||||
test('renders ground texture circles', () => {
|
||||
const { container } = render(
|
||||
<svg>
|
||||
<TrainTerrainBackground
|
||||
ballastPath="M 0 300 L 800 300"
|
||||
groundTextureCircles={mockGroundCircles}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
const circles = container.querySelectorAll('circle')
|
||||
expect(circles.length).toBeGreaterThanOrEqual(2)
|
||||
|
||||
// Verify circle attributes
|
||||
const firstCircle = circles[0]
|
||||
expect(firstCircle.getAttribute('cx')).toBe('10')
|
||||
expect(firstCircle.getAttribute('cy')).toBe('150')
|
||||
expect(firstCircle.getAttribute('r')).toBe('2')
|
||||
})
|
||||
|
||||
test('renders ballast path with correct attributes', () => {
|
||||
const { container } = render(
|
||||
<svg>
|
||||
<TrainTerrainBackground
|
||||
ballastPath="M 0 300 L 800 300"
|
||||
groundTextureCircles={mockGroundCircles}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
const ballastPath = Array.from(container.querySelectorAll('path')).find(
|
||||
(path) =>
|
||||
path.getAttribute('d') === 'M 0 300 L 800 300' && path.getAttribute('stroke') === '#8B7355'
|
||||
)
|
||||
expect(ballastPath).toBeTruthy()
|
||||
expect(ballastPath?.getAttribute('stroke-width')).toBe('40')
|
||||
})
|
||||
|
||||
test('renders left tunnel structure', () => {
|
||||
const { container } = render(
|
||||
<svg>
|
||||
<TrainTerrainBackground
|
||||
ballastPath="M 0 300 L 800 300"
|
||||
groundTextureCircles={mockGroundCircles}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
const leftTunnel = container.querySelector('[data-element="left-tunnel"]')
|
||||
expect(leftTunnel).toBeTruthy()
|
||||
|
||||
// Check for tunnel elements
|
||||
const ellipses = leftTunnel?.querySelectorAll('ellipse')
|
||||
expect(ellipses?.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('renders right tunnel structure', () => {
|
||||
const { container } = render(
|
||||
<svg>
|
||||
<TrainTerrainBackground
|
||||
ballastPath="M 0 300 L 800 300"
|
||||
groundTextureCircles={mockGroundCircles}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
const rightTunnel = container.querySelector('[data-element="right-tunnel"]')
|
||||
expect(rightTunnel).toBeTruthy()
|
||||
|
||||
// Check for tunnel elements
|
||||
const ellipses = rightTunnel?.querySelectorAll('ellipse')
|
||||
expect(ellipses?.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('renders mountains with gradient fills', () => {
|
||||
const { container } = render(
|
||||
<svg>
|
||||
<TrainTerrainBackground
|
||||
ballastPath="M 0 300 L 800 300"
|
||||
groundTextureCircles={mockGroundCircles}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
// Check for paths with gradient fills
|
||||
const gradientPaths = Array.from(container.querySelectorAll('path')).filter((path) =>
|
||||
path.getAttribute('fill')?.includes('url(#mountainGradient')
|
||||
)
|
||||
expect(gradientPaths.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
test('handles empty groundTextureCircles array', () => {
|
||||
const { container } = render(
|
||||
<svg>
|
||||
<TrainTerrainBackground ballastPath="M 0 300 L 800 300" groundTextureCircles={[]} />
|
||||
</svg>
|
||||
)
|
||||
|
||||
// Should still render other elements
|
||||
expect(container.querySelector('defs')).toBeTruthy()
|
||||
expect(container.querySelector('[data-element="left-tunnel"]')).toBeTruthy()
|
||||
})
|
||||
|
||||
test('memoization: does not re-render with same props', () => {
|
||||
const { rerender, container } = render(
|
||||
<svg>
|
||||
<TrainTerrainBackground
|
||||
ballastPath="M 0 300 L 800 300"
|
||||
groundTextureCircles={mockGroundCircles}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
const initialHTML = container.innerHTML
|
||||
|
||||
// Rerender with same props
|
||||
rerender(
|
||||
<svg>
|
||||
<TrainTerrainBackground
|
||||
ballastPath="M 0 300 L 800 300"
|
||||
groundTextureCircles={mockGroundCircles}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
// HTML should be identical (component memoized)
|
||||
expect(container.innerHTML).toBe(initialHTML)
|
||||
})
|
||||
})
|
||||
@@ -1,171 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { getRouteTheme } from '../lib/routeThemes'
|
||||
|
||||
interface RouteCelebrationProps {
|
||||
completedRouteNumber: number
|
||||
nextRouteNumber: number
|
||||
onContinue: () => void
|
||||
}
|
||||
|
||||
export function RouteCelebration({
|
||||
completedRouteNumber,
|
||||
nextRouteNumber,
|
||||
onContinue,
|
||||
}: RouteCelebrationProps) {
|
||||
const completedTheme = getRouteTheme(completedRouteNumber)
|
||||
const nextTheme = getRouteTheme(nextRouteNumber)
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0, 0, 0, 0.7)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 9999,
|
||||
animation: 'fadeIn 0.3s ease-out',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
borderRadius: '24px',
|
||||
padding: '40px',
|
||||
maxWidth: '500px',
|
||||
textAlign: 'center',
|
||||
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.5)',
|
||||
animation: 'scaleIn 0.5s ease-out',
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
{/* Celebration header */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: '64px',
|
||||
marginBottom: '20px',
|
||||
animation: 'bounce 1s ease-in-out infinite',
|
||||
}}
|
||||
>
|
||||
🎉
|
||||
</div>
|
||||
|
||||
<h2
|
||||
style={{
|
||||
fontSize: '32px',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '16px',
|
||||
textShadow: '0 2px 10px rgba(0, 0, 0, 0.3)',
|
||||
}}
|
||||
>
|
||||
Route Complete!
|
||||
</h2>
|
||||
|
||||
{/* Completed route info */}
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
marginBottom: '24px',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '40px', marginBottom: '8px' }}>{completedTheme.emoji}</div>
|
||||
<div style={{ fontSize: '20px', fontWeight: '600' }}>{completedTheme.name}</div>
|
||||
<div style={{ fontSize: '16px', opacity: 0.9, marginTop: '4px' }}>
|
||||
Route {completedRouteNumber}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Next route preview */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
opacity: 0.9,
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
Next destination:
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.15)',
|
||||
borderRadius: '12px',
|
||||
padding: '12px',
|
||||
marginBottom: '24px',
|
||||
border: '2px dashed rgba(255, 255, 255, 0.3)',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '32px', marginBottom: '4px' }}>{nextTheme.emoji}</div>
|
||||
<div style={{ fontSize: '18px', fontWeight: '600' }}>{nextTheme.name}</div>
|
||||
<div style={{ fontSize: '14px', opacity: 0.8, marginTop: '4px' }}>
|
||||
Route {nextRouteNumber}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Continue button */}
|
||||
<button
|
||||
onClick={onContinue}
|
||||
style={{
|
||||
background: 'white',
|
||||
color: '#667eea',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
padding: '16px 32px',
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.2)',
|
||||
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-2px)'
|
||||
e.currentTarget.style.boxShadow = '0 6px 16px rgba(0, 0, 0, 0.3)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)'
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.2)'
|
||||
}}
|
||||
>
|
||||
Continue Journey 🚂
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
transform: scale(0.8);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,459 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type React from 'react'
|
||||
import { createContext, type ReactNode, useContext, useReducer } from 'react'
|
||||
import type { AIRacer, DifficultyTracker, GameAction, GameState, Station } from '../lib/gameTypes'
|
||||
|
||||
const initialDifficultyTracker: DifficultyTracker = {
|
||||
pairPerformance: new Map(),
|
||||
baseTimeLimit: 3000,
|
||||
currentTimeLimit: 3000,
|
||||
difficultyLevel: 1,
|
||||
consecutiveCorrect: 0,
|
||||
consecutiveIncorrect: 0,
|
||||
learningMode: true,
|
||||
adaptationRate: 0.1,
|
||||
}
|
||||
|
||||
const initialAIRacers: AIRacer[] = [
|
||||
{
|
||||
id: 'ai-racer-1',
|
||||
position: 0,
|
||||
speed: 0.32, // Balanced speed for good challenge
|
||||
name: 'Swift AI',
|
||||
personality: 'competitive',
|
||||
icon: '🏃♂️',
|
||||
lastComment: 0,
|
||||
commentCooldown: 0,
|
||||
previousPosition: 0,
|
||||
},
|
||||
{
|
||||
id: 'ai-racer-2',
|
||||
position: 0,
|
||||
speed: 0.2, // Balanced speed for good challenge
|
||||
name: 'Math Bot',
|
||||
personality: 'analytical',
|
||||
icon: '🏃',
|
||||
lastComment: 0,
|
||||
commentCooldown: 0,
|
||||
previousPosition: 0,
|
||||
},
|
||||
]
|
||||
|
||||
const initialStations: Station[] = [
|
||||
{ id: 'station-0', name: 'Depot', position: 0, icon: '🏭' },
|
||||
{ id: 'station-1', name: 'Riverside', position: 20, icon: '🌊' },
|
||||
{ id: 'station-2', name: 'Hillside', position: 40, icon: '⛰️' },
|
||||
{ id: 'station-3', name: 'Canyon View', position: 60, icon: '🏜️' },
|
||||
{ id: 'station-4', name: 'Meadows', position: 80, icon: '🌾' },
|
||||
{ id: 'station-5', name: 'Grand Central', position: 100, icon: '🏛️' },
|
||||
]
|
||||
|
||||
const initialState: GameState = {
|
||||
// Game configuration
|
||||
mode: 'friends5',
|
||||
style: 'practice',
|
||||
timeoutSetting: 'normal',
|
||||
complementDisplay: 'abacus', // Default to showing abacus
|
||||
|
||||
// Current question
|
||||
currentQuestion: null,
|
||||
previousQuestion: null,
|
||||
|
||||
// Game progress
|
||||
score: 0,
|
||||
streak: 0,
|
||||
bestStreak: 0,
|
||||
totalQuestions: 0,
|
||||
correctAnswers: 0,
|
||||
|
||||
// Game status
|
||||
isGameActive: false,
|
||||
isPaused: false,
|
||||
gamePhase: 'controls',
|
||||
|
||||
// Timing
|
||||
gameStartTime: null,
|
||||
questionStartTime: Date.now(),
|
||||
|
||||
// Race mechanics
|
||||
raceGoal: 20,
|
||||
timeLimit: null,
|
||||
speedMultiplier: 1.0,
|
||||
aiRacers: initialAIRacers,
|
||||
|
||||
// Adaptive difficulty
|
||||
difficultyTracker: initialDifficultyTracker,
|
||||
|
||||
// Survival mode specific
|
||||
playerLap: 0,
|
||||
aiLaps: new Map(),
|
||||
survivalMultiplier: 1.0,
|
||||
|
||||
// Sprint mode specific
|
||||
momentum: 0,
|
||||
trainPosition: 0,
|
||||
pressure: 0,
|
||||
elapsedTime: 0,
|
||||
lastCorrectAnswerTime: Date.now(),
|
||||
currentRoute: 1,
|
||||
stations: initialStations,
|
||||
passengers: [],
|
||||
deliveredPassengers: 0,
|
||||
cumulativeDistance: 0,
|
||||
showRouteCelebration: false,
|
||||
|
||||
// Input
|
||||
currentInput: '',
|
||||
|
||||
// UI state
|
||||
showScoreModal: false,
|
||||
activeSpeechBubbles: new Map(),
|
||||
adaptiveFeedback: null,
|
||||
}
|
||||
|
||||
function gameReducer(state: GameState, action: GameAction): GameState {
|
||||
switch (action.type) {
|
||||
case 'SET_MODE':
|
||||
return { ...state, mode: action.mode }
|
||||
|
||||
case 'SET_STYLE':
|
||||
return { ...state, style: action.style }
|
||||
|
||||
case 'SET_TIMEOUT':
|
||||
return { ...state, timeoutSetting: action.timeout }
|
||||
|
||||
case 'SET_COMPLEMENT_DISPLAY':
|
||||
return { ...state, complementDisplay: action.display }
|
||||
|
||||
case 'SHOW_CONTROLS':
|
||||
return { ...state, gamePhase: 'controls' }
|
||||
|
||||
case 'START_COUNTDOWN':
|
||||
return { ...state, gamePhase: 'countdown' }
|
||||
|
||||
case 'BEGIN_GAME': {
|
||||
// Generate first question when game starts
|
||||
const generateFirstQuestion = () => {
|
||||
let targetSum: number
|
||||
if (state.mode === 'friends5') {
|
||||
targetSum = 5
|
||||
} else if (state.mode === 'friends10') {
|
||||
targetSum = 10
|
||||
} else {
|
||||
targetSum = Math.random() > 0.5 ? 5 : 10
|
||||
}
|
||||
|
||||
const newNumber =
|
||||
targetSum === 5 ? Math.floor(Math.random() * 5) : Math.floor(Math.random() * 10)
|
||||
|
||||
// Decide once whether to show as abacus
|
||||
const showAsAbacus =
|
||||
state.complementDisplay === 'abacus' ||
|
||||
(state.complementDisplay === 'random' && Math.random() < 0.5)
|
||||
|
||||
return {
|
||||
number: newNumber,
|
||||
targetSum,
|
||||
correctAnswer: targetSum - newNumber,
|
||||
showAsAbacus,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
gamePhase: 'playing',
|
||||
isGameActive: true,
|
||||
gameStartTime: Date.now(),
|
||||
questionStartTime: Date.now(),
|
||||
currentQuestion: generateFirstQuestion(),
|
||||
}
|
||||
}
|
||||
|
||||
case 'NEXT_QUESTION': {
|
||||
// Generate new question based on mode
|
||||
const generateQuestion = () => {
|
||||
let targetSum: number
|
||||
if (state.mode === 'friends5') {
|
||||
targetSum = 5
|
||||
} else if (state.mode === 'friends10') {
|
||||
targetSum = 10
|
||||
} else {
|
||||
targetSum = Math.random() > 0.5 ? 5 : 10
|
||||
}
|
||||
|
||||
let newNumber: number
|
||||
let attempts = 0
|
||||
|
||||
do {
|
||||
if (targetSum === 5) {
|
||||
newNumber = Math.floor(Math.random() * 5)
|
||||
} else {
|
||||
newNumber = Math.floor(Math.random() * 10)
|
||||
}
|
||||
attempts++
|
||||
} while (
|
||||
state.currentQuestion &&
|
||||
state.currentQuestion.number === newNumber &&
|
||||
state.currentQuestion.targetSum === targetSum &&
|
||||
attempts < 10
|
||||
)
|
||||
|
||||
// Decide once whether to show as abacus
|
||||
const showAsAbacus =
|
||||
state.complementDisplay === 'abacus' ||
|
||||
(state.complementDisplay === 'random' && Math.random() < 0.5)
|
||||
|
||||
return {
|
||||
number: newNumber,
|
||||
targetSum,
|
||||
correctAnswer: targetSum - newNumber,
|
||||
showAsAbacus,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
previousQuestion: state.currentQuestion,
|
||||
currentQuestion: generateQuestion(),
|
||||
questionStartTime: Date.now(),
|
||||
currentInput: '',
|
||||
}
|
||||
}
|
||||
|
||||
case 'UPDATE_INPUT':
|
||||
return { ...state, currentInput: action.input }
|
||||
|
||||
case 'SUBMIT_ANSWER': {
|
||||
if (!state.currentQuestion) return state
|
||||
|
||||
const isCorrect = action.answer === state.currentQuestion.correctAnswer
|
||||
const responseTime = Date.now() - state.questionStartTime
|
||||
|
||||
if (isCorrect) {
|
||||
// Calculate speed bonus: max(0, 300 - (avgTime * 10))
|
||||
const speedBonus = Math.max(0, 300 - responseTime / 100)
|
||||
|
||||
// Update score: correctAnswers * 100 + streak * 50 + speedBonus
|
||||
const newStreak = state.streak + 1
|
||||
const newCorrectAnswers = state.correctAnswers + 1
|
||||
const newScore = state.score + 100 + newStreak * 50 + speedBonus
|
||||
|
||||
return {
|
||||
...state,
|
||||
correctAnswers: newCorrectAnswers,
|
||||
streak: newStreak,
|
||||
bestStreak: Math.max(state.bestStreak, newStreak),
|
||||
score: Math.round(newScore),
|
||||
totalQuestions: state.totalQuestions + 1,
|
||||
}
|
||||
} else {
|
||||
// Incorrect answer - reset streak but keep score
|
||||
return {
|
||||
...state,
|
||||
streak: 0,
|
||||
totalQuestions: state.totalQuestions + 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case 'UPDATE_AI_POSITIONS':
|
||||
return {
|
||||
...state,
|
||||
aiRacers: state.aiRacers.map((racer) => {
|
||||
const update = action.positions.find((p) => p.id === racer.id)
|
||||
return update
|
||||
? {
|
||||
...racer,
|
||||
previousPosition: racer.position,
|
||||
position: update.position,
|
||||
}
|
||||
: racer
|
||||
}),
|
||||
}
|
||||
|
||||
case 'UPDATE_MOMENTUM':
|
||||
return { ...state, momentum: action.momentum }
|
||||
|
||||
case 'UPDATE_TRAIN_POSITION':
|
||||
return { ...state, trainPosition: action.position }
|
||||
|
||||
case 'UPDATE_STEAM_JOURNEY':
|
||||
return {
|
||||
...state,
|
||||
momentum: action.momentum,
|
||||
trainPosition: action.trainPosition,
|
||||
pressure: action.pressure,
|
||||
elapsedTime: action.elapsedTime,
|
||||
}
|
||||
|
||||
case 'COMPLETE_LAP':
|
||||
if (action.racerId === 'player') {
|
||||
return { ...state, playerLap: state.playerLap + 1 }
|
||||
} else {
|
||||
const newAILaps = new Map(state.aiLaps)
|
||||
newAILaps.set(action.racerId, (newAILaps.get(action.racerId) || 0) + 1)
|
||||
return { ...state, aiLaps: newAILaps }
|
||||
}
|
||||
|
||||
case 'PAUSE_RACE':
|
||||
return { ...state, isPaused: true }
|
||||
|
||||
case 'RESUME_RACE':
|
||||
return { ...state, isPaused: false }
|
||||
|
||||
case 'END_RACE':
|
||||
return { ...state, isGameActive: false }
|
||||
|
||||
case 'SHOW_RESULTS':
|
||||
return { ...state, gamePhase: 'results', showScoreModal: true }
|
||||
|
||||
case 'RESET_GAME':
|
||||
return {
|
||||
...initialState,
|
||||
// Preserve configuration settings
|
||||
mode: state.mode,
|
||||
style: state.style,
|
||||
timeoutSetting: state.timeoutSetting,
|
||||
complementDisplay: state.complementDisplay,
|
||||
gamePhase: 'controls',
|
||||
}
|
||||
|
||||
case 'TRIGGER_AI_COMMENTARY': {
|
||||
const newBubbles = new Map(state.activeSpeechBubbles)
|
||||
newBubbles.set(action.racerId, action.message)
|
||||
return {
|
||||
...state,
|
||||
activeSpeechBubbles: newBubbles,
|
||||
// Update racer's lastComment time and cooldown
|
||||
aiRacers: state.aiRacers.map((racer) =>
|
||||
racer.id === action.racerId
|
||||
? {
|
||||
...racer,
|
||||
lastComment: Date.now(),
|
||||
commentCooldown: Math.random() * 4000 + 2000, // 2-6 seconds
|
||||
}
|
||||
: racer
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
case 'CLEAR_AI_COMMENT': {
|
||||
const clearedBubbles = new Map(state.activeSpeechBubbles)
|
||||
clearedBubbles.delete(action.racerId)
|
||||
return {
|
||||
...state,
|
||||
activeSpeechBubbles: clearedBubbles,
|
||||
}
|
||||
}
|
||||
|
||||
case 'UPDATE_DIFFICULTY_TRACKER':
|
||||
return {
|
||||
...state,
|
||||
difficultyTracker: action.tracker,
|
||||
}
|
||||
|
||||
case 'UPDATE_AI_SPEEDS':
|
||||
return {
|
||||
...state,
|
||||
aiRacers: action.racers,
|
||||
}
|
||||
|
||||
case 'SHOW_ADAPTIVE_FEEDBACK':
|
||||
return {
|
||||
...state,
|
||||
adaptiveFeedback: action.feedback,
|
||||
}
|
||||
|
||||
case 'CLEAR_ADAPTIVE_FEEDBACK':
|
||||
return {
|
||||
...state,
|
||||
adaptiveFeedback: null,
|
||||
}
|
||||
|
||||
case 'GENERATE_PASSENGERS':
|
||||
return {
|
||||
...state,
|
||||
passengers: action.passengers,
|
||||
}
|
||||
|
||||
case 'BOARD_PASSENGER':
|
||||
return {
|
||||
...state,
|
||||
passengers: state.passengers.map((p) =>
|
||||
p.id === action.passengerId ? { ...p, isBoarded: true } : p
|
||||
),
|
||||
}
|
||||
|
||||
case 'DELIVER_PASSENGER':
|
||||
return {
|
||||
...state,
|
||||
passengers: state.passengers.map((p) =>
|
||||
p.id === action.passengerId ? { ...p, isDelivered: true } : p
|
||||
),
|
||||
deliveredPassengers: state.deliveredPassengers + 1,
|
||||
score: state.score + action.points,
|
||||
}
|
||||
|
||||
case 'START_NEW_ROUTE':
|
||||
return {
|
||||
...state,
|
||||
currentRoute: action.routeNumber,
|
||||
stations: action.stations,
|
||||
trainPosition: -5, // Start off-screen to the left for smooth fade-in
|
||||
deliveredPassengers: 0,
|
||||
showRouteCelebration: false,
|
||||
momentum: 50, // Give some starting momentum for the new route
|
||||
pressure: 50,
|
||||
}
|
||||
|
||||
case 'COMPLETE_ROUTE':
|
||||
return {
|
||||
...state,
|
||||
cumulativeDistance: state.cumulativeDistance + 100,
|
||||
showRouteCelebration: true,
|
||||
}
|
||||
|
||||
case 'HIDE_ROUTE_CELEBRATION':
|
||||
return {
|
||||
...state,
|
||||
showRouteCelebration: false,
|
||||
}
|
||||
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
interface ComplementRaceContextType {
|
||||
state: GameState
|
||||
dispatch: React.Dispatch<GameAction>
|
||||
}
|
||||
|
||||
const ComplementRaceContext = createContext<ComplementRaceContextType | undefined>(undefined)
|
||||
|
||||
interface ComplementRaceProviderProps {
|
||||
children: ReactNode
|
||||
initialStyle?: 'practice' | 'sprint' | 'survival'
|
||||
}
|
||||
|
||||
export function ComplementRaceProvider({ children, initialStyle }: ComplementRaceProviderProps) {
|
||||
const [state, dispatch] = useReducer(gameReducer, {
|
||||
...initialState,
|
||||
style: initialStyle || initialState.style,
|
||||
})
|
||||
|
||||
return (
|
||||
<ComplementRaceContext.Provider value={{ state, dispatch }}>
|
||||
{children}
|
||||
</ComplementRaceContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useComplementRace() {
|
||||
const context = useContext(ComplementRaceContext)
|
||||
if (context === undefined) {
|
||||
throw new Error('useComplementRace must be used within ComplementRaceProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -1,279 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
import type { Passenger, Station } from '../../lib/gameTypes'
|
||||
import type { RailroadTrackGenerator } from '../../lib/RailroadTrackGenerator'
|
||||
import { usePassengerAnimations } from '../usePassengerAnimations'
|
||||
|
||||
describe('usePassengerAnimations', () => {
|
||||
let mockPathRef: React.RefObject<SVGPathElement>
|
||||
let mockTrackGenerator: RailroadTrackGenerator
|
||||
let mockStation1: Station
|
||||
let mockStation2: Station
|
||||
let mockPassenger1: Passenger
|
||||
let mockPassenger2: Passenger
|
||||
|
||||
beforeEach(() => {
|
||||
// Create mock path element
|
||||
const mockPath = document.createElementNS('http://www.w3.org/2000/svg', 'path')
|
||||
mockPathRef = { current: mockPath }
|
||||
|
||||
// Mock track generator
|
||||
mockTrackGenerator = {
|
||||
getTrainTransform: vi.fn((_path: SVGPathElement, position: number) => ({
|
||||
x: position * 10,
|
||||
y: 300,
|
||||
rotation: 0,
|
||||
})),
|
||||
} as unknown as RailroadTrackGenerator
|
||||
|
||||
// Create mock stations
|
||||
mockStation1 = {
|
||||
id: 'station-1',
|
||||
name: 'Station 1',
|
||||
position: 20,
|
||||
icon: '🏭',
|
||||
}
|
||||
|
||||
mockStation2 = {
|
||||
id: 'station-2',
|
||||
name: 'Station 2',
|
||||
position: 60,
|
||||
icon: '🏛️',
|
||||
}
|
||||
|
||||
// Create mock passengers
|
||||
mockPassenger1 = {
|
||||
id: 'passenger-1',
|
||||
avatar: '👨',
|
||||
originStationId: 'station-1',
|
||||
destinationStationId: 'station-2',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
}
|
||||
|
||||
mockPassenger2 = {
|
||||
id: 'passenger-2',
|
||||
avatar: '👩',
|
||||
originStationId: 'station-1',
|
||||
destinationStationId: 'station-2',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: true,
|
||||
}
|
||||
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
test('initializes with empty animation maps', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePassengerAnimations({
|
||||
passengers: [],
|
||||
stations: [mockStation1, mockStation2],
|
||||
stationPositions: [
|
||||
{ x: 100, y: 300 },
|
||||
{ x: 500, y: 300 },
|
||||
],
|
||||
trainPosition: 0,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.current.boardingAnimations.size).toBe(0)
|
||||
expect(result.current.disembarkingAnimations.size).toBe(0)
|
||||
})
|
||||
|
||||
test('creates boarding animation when passenger boards', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ passengers }) =>
|
||||
usePassengerAnimations({
|
||||
passengers,
|
||||
stations: [mockStation1, mockStation2],
|
||||
stationPositions: [
|
||||
{ x: 100, y: 300 },
|
||||
{ x: 500, y: 300 },
|
||||
],
|
||||
trainPosition: 20,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
}),
|
||||
{
|
||||
initialProps: {
|
||||
passengers: [mockPassenger1],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// Initially no boarding animations
|
||||
expect(result.current.boardingAnimations.size).toBe(0)
|
||||
|
||||
// Passenger boards
|
||||
const boardedPassenger = { ...mockPassenger1, isBoarded: true }
|
||||
rerender({ passengers: [boardedPassenger] })
|
||||
|
||||
// Should create boarding animation
|
||||
expect(result.current.boardingAnimations.size).toBe(1)
|
||||
expect(result.current.boardingAnimations.has('passenger-1')).toBe(true)
|
||||
|
||||
const animation = result.current.boardingAnimations.get('passenger-1')
|
||||
expect(animation).toBeDefined()
|
||||
expect(animation?.passenger).toEqual(boardedPassenger)
|
||||
expect(animation?.fromX).toBe(100) // Station position
|
||||
expect(animation?.fromY).toBe(270) // Station position - 30
|
||||
expect(mockTrackGenerator.getTrainTransform).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('creates disembarking animation when passenger is delivered', () => {
|
||||
const boardedPassenger = { ...mockPassenger1, isBoarded: true }
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ passengers }) =>
|
||||
usePassengerAnimations({
|
||||
passengers,
|
||||
stations: [mockStation1, mockStation2],
|
||||
stationPositions: [
|
||||
{ x: 100, y: 300 },
|
||||
{ x: 500, y: 300 },
|
||||
],
|
||||
trainPosition: 60,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
}),
|
||||
{
|
||||
initialProps: {
|
||||
passengers: [boardedPassenger],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// Initially no disembarking animations
|
||||
expect(result.current.disembarkingAnimations.size).toBe(0)
|
||||
|
||||
// Passenger is delivered
|
||||
const deliveredPassenger = { ...boardedPassenger, isDelivered: true }
|
||||
rerender({ passengers: [deliveredPassenger] })
|
||||
|
||||
// Should create disembarking animation
|
||||
expect(result.current.disembarkingAnimations.size).toBe(1)
|
||||
expect(result.current.disembarkingAnimations.has('passenger-1')).toBe(true)
|
||||
|
||||
const animation = result.current.disembarkingAnimations.get('passenger-1')
|
||||
expect(animation).toBeDefined()
|
||||
expect(animation?.passenger).toEqual(deliveredPassenger)
|
||||
expect(animation?.toX).toBe(500) // Destination station position
|
||||
expect(animation?.toY).toBe(270) // Station position - 30
|
||||
})
|
||||
|
||||
test('handles multiple passengers boarding simultaneously', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ passengers }) =>
|
||||
usePassengerAnimations({
|
||||
passengers,
|
||||
stations: [mockStation1, mockStation2],
|
||||
stationPositions: [
|
||||
{ x: 100, y: 300 },
|
||||
{ x: 500, y: 300 },
|
||||
],
|
||||
trainPosition: 20,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
}),
|
||||
{
|
||||
initialProps: {
|
||||
passengers: [mockPassenger1, mockPassenger2],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// Both passengers board
|
||||
const boardedPassengers = [
|
||||
{ ...mockPassenger1, isBoarded: true },
|
||||
{ ...mockPassenger2, isBoarded: true },
|
||||
]
|
||||
rerender({ passengers: boardedPassengers })
|
||||
|
||||
// Should create boarding animations for both
|
||||
expect(result.current.boardingAnimations.size).toBe(2)
|
||||
expect(result.current.boardingAnimations.has('passenger-1')).toBe(true)
|
||||
expect(result.current.boardingAnimations.has('passenger-2')).toBe(true)
|
||||
})
|
||||
|
||||
test('does not create animation if passenger already boarded in previous state', () => {
|
||||
const boardedPassenger = { ...mockPassenger1, isBoarded: true }
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePassengerAnimations({
|
||||
passengers: [boardedPassenger],
|
||||
stations: [mockStation1, mockStation2],
|
||||
stationPositions: [
|
||||
{ x: 100, y: 300 },
|
||||
{ x: 500, y: 300 },
|
||||
],
|
||||
trainPosition: 20,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
})
|
||||
)
|
||||
|
||||
// No animation since passenger was already boarded
|
||||
expect(result.current.boardingAnimations.size).toBe(0)
|
||||
})
|
||||
|
||||
test('returns empty animations when pathRef is null', () => {
|
||||
const nullPathRef: React.RefObject<SVGPathElement> = { current: null }
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ passengers }) =>
|
||||
usePassengerAnimations({
|
||||
passengers,
|
||||
stations: [mockStation1, mockStation2],
|
||||
stationPositions: [
|
||||
{ x: 100, y: 300 },
|
||||
{ x: 500, y: 300 },
|
||||
],
|
||||
trainPosition: 20,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: nullPathRef,
|
||||
}),
|
||||
{
|
||||
initialProps: {
|
||||
passengers: [mockPassenger1],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// Passenger boards
|
||||
const boardedPassenger = { ...mockPassenger1, isBoarded: true }
|
||||
rerender({ passengers: [boardedPassenger] })
|
||||
|
||||
// Should not create animation without path
|
||||
expect(result.current.boardingAnimations.size).toBe(0)
|
||||
})
|
||||
|
||||
test('returns empty animations when stationPositions is empty', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ passengers }) =>
|
||||
usePassengerAnimations({
|
||||
passengers,
|
||||
stations: [mockStation1, mockStation2],
|
||||
stationPositions: [],
|
||||
trainPosition: 20,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
}),
|
||||
{
|
||||
initialProps: {
|
||||
passengers: [mockPassenger1],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// Passenger boards
|
||||
const boardedPassenger = { ...mockPassenger1, isBoarded: true }
|
||||
rerender({ passengers: [boardedPassenger] })
|
||||
|
||||
// Should not create animation without station positions
|
||||
expect(result.current.boardingAnimations.size).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -1,353 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
// Mock sound effects
|
||||
vi.mock('../useSoundEffects', () => ({
|
||||
useSoundEffects: () => ({
|
||||
playSound: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
/**
|
||||
* Boarding Logic Tests
|
||||
*
|
||||
* These tests simulate the game loop's boarding logic to find edge cases
|
||||
* where passengers get left behind at stations.
|
||||
*/
|
||||
|
||||
interface Passenger {
|
||||
id: string
|
||||
name: string
|
||||
avatar: string
|
||||
originStationId: string
|
||||
destinationStationId: string
|
||||
isBoarded: boolean
|
||||
isDelivered: boolean
|
||||
isUrgent: boolean
|
||||
}
|
||||
|
||||
interface Station {
|
||||
id: string
|
||||
name: string
|
||||
icon: string
|
||||
position: number
|
||||
}
|
||||
|
||||
describe('useSteamJourney - Boarding Logic', () => {
|
||||
const CAR_SPACING = 7
|
||||
let stations: Station[]
|
||||
let passengers: Passenger[]
|
||||
|
||||
beforeEach(() => {
|
||||
stations = [
|
||||
{ id: 's1', name: 'Station 1', icon: '🏠', position: 20 },
|
||||
{ id: 's2', name: 'Station 2', icon: '🏢', position: 50 },
|
||||
{ id: 's3', name: 'Station 3', icon: '🏪', position: 80 },
|
||||
]
|
||||
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
/**
|
||||
* Simulate the boarding logic from useSteamJourney (with fix)
|
||||
*/
|
||||
function simulateBoardingAtPosition(
|
||||
trainPosition: number,
|
||||
passengers: Passenger[],
|
||||
stations: Station[],
|
||||
maxCars: number
|
||||
): Passenger[] {
|
||||
const updatedPassengers = [...passengers]
|
||||
const currentBoardedPassengers = updatedPassengers.filter((p) => p.isBoarded && !p.isDelivered)
|
||||
|
||||
// Track which cars are assigned in THIS frame to prevent double-boarding
|
||||
const carsAssignedThisFrame = new Set<number>()
|
||||
|
||||
// Simulate the boarding logic
|
||||
updatedPassengers.forEach((passenger, passengerIndex) => {
|
||||
if (passenger.isBoarded || passenger.isDelivered) return
|
||||
|
||||
const station = stations.find((s) => s.id === passenger.originStationId)
|
||||
if (!station) return
|
||||
|
||||
// Check if any empty car is at this station
|
||||
for (let carIndex = 0; carIndex < maxCars; carIndex++) {
|
||||
// Skip if this car already has a passenger OR was assigned this frame
|
||||
if (currentBoardedPassengers[carIndex] || carsAssignedThisFrame.has(carIndex)) continue
|
||||
|
||||
const carPosition = Math.max(0, trainPosition - (carIndex + 1) * CAR_SPACING)
|
||||
const distance = Math.abs(carPosition - station.position)
|
||||
|
||||
// If car is at station (within 3% tolerance), board this passenger
|
||||
if (distance < 3) {
|
||||
updatedPassengers[passengerIndex] = { ...passenger, isBoarded: true }
|
||||
// Mark this car as assigned in this frame
|
||||
carsAssignedThisFrame.add(carIndex)
|
||||
return // Board this passenger and move on
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return updatedPassengers
|
||||
}
|
||||
|
||||
test('single passenger at station boards when car arrives', () => {
|
||||
passengers = [
|
||||
{
|
||||
id: 'p1',
|
||||
name: 'Alice',
|
||||
avatar: '👩',
|
||||
originStationId: 's1',
|
||||
destinationStationId: 's2',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
},
|
||||
]
|
||||
|
||||
// Train at position 27%, first car at position 20% (station 1)
|
||||
const result = simulateBoardingAtPosition(27, passengers, stations, 1)
|
||||
|
||||
expect(result[0].isBoarded).toBe(true)
|
||||
})
|
||||
|
||||
test('EDGE CASE: multiple passengers at same station with enough cars', () => {
|
||||
passengers = [
|
||||
{
|
||||
id: 'p1',
|
||||
name: 'Alice',
|
||||
avatar: '👩',
|
||||
originStationId: 's1',
|
||||
destinationStationId: 's2',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
},
|
||||
{
|
||||
id: 'p2',
|
||||
name: 'Bob',
|
||||
avatar: '👨',
|
||||
originStationId: 's1',
|
||||
destinationStationId: 's2',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
},
|
||||
{
|
||||
id: 'p3',
|
||||
name: 'Charlie',
|
||||
avatar: '👴',
|
||||
originStationId: 's1',
|
||||
destinationStationId: 's2',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
},
|
||||
]
|
||||
|
||||
// Train at position 34%, cars at: 27%, 20%, 13%
|
||||
// Car 1 (27%): 7% away from station (too far)
|
||||
// Car 2 (20%): 0% away from station (at station!)
|
||||
// Car 3 (13%): 7% away from station (too far)
|
||||
let result = simulateBoardingAtPosition(34, passengers, stations, 3)
|
||||
|
||||
// First iteration: car 2 is at station, should board first passenger
|
||||
expect(result[0].isBoarded).toBe(true)
|
||||
|
||||
// But what about the other passengers? They should board on subsequent frames
|
||||
// Let's simulate the train advancing slightly
|
||||
result = simulateBoardingAtPosition(35, result, stations, 3)
|
||||
|
||||
// Now car 1 is at 28% (still too far), car 2 at 21% (still close), car 3 at 14% (too far)
|
||||
// Passenger 2 should still not board yet
|
||||
|
||||
// Advance more - when does car 1 reach the station?
|
||||
result = simulateBoardingAtPosition(27, result, stations, 3)
|
||||
// Car 1 at 20% (at station!)
|
||||
expect(result[1].isBoarded).toBe(true)
|
||||
|
||||
// What about passenger 3? Need car 3 to reach station
|
||||
// Car 3 position = trainPosition - (3 * 7) = trainPosition - 21
|
||||
// For car 3 to be at 20%, need trainPosition = 41
|
||||
result = simulateBoardingAtPosition(41, result, stations, 3)
|
||||
// Car 3 at 20% (at station!)
|
||||
expect(result[2].isBoarded).toBe(true)
|
||||
})
|
||||
|
||||
test('EDGE CASE: passengers left behind when train moves too fast', () => {
|
||||
passengers = [
|
||||
{
|
||||
id: 'p1',
|
||||
name: 'Alice',
|
||||
avatar: '👩',
|
||||
originStationId: 's1',
|
||||
destinationStationId: 's2',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
},
|
||||
{
|
||||
id: 'p2',
|
||||
name: 'Bob',
|
||||
avatar: '👨',
|
||||
originStationId: 's1',
|
||||
destinationStationId: 's2',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
},
|
||||
]
|
||||
|
||||
// Simulate train speeding through station
|
||||
// Only 2 cars, but 2 passengers at same station
|
||||
|
||||
// Frame 1: Train at 27%, car 1 at 20%, car 2 at 13%
|
||||
let result = simulateBoardingAtPosition(27, passengers, stations, 2)
|
||||
expect(result[0].isBoarded).toBe(true)
|
||||
expect(result[1].isBoarded).toBe(false)
|
||||
|
||||
// Frame 2: Train jumps to 35% (high momentum)
|
||||
// Car 1 at 28%, car 2 at 21%
|
||||
result = simulateBoardingAtPosition(35, result, stations, 2)
|
||||
// Car 2 is at 21%, within 1% of station at 20%
|
||||
expect(result[1].isBoarded).toBe(true)
|
||||
|
||||
// Frame 3: Train at 45% - both cars past station
|
||||
result = simulateBoardingAtPosition(45, result, stations, 2)
|
||||
// Car 1 at 38%, car 2 at 31% - both way past 20%
|
||||
|
||||
// All passengers should have boarded
|
||||
expect(result.every((p) => p.isBoarded)).toBe(true)
|
||||
})
|
||||
|
||||
test('EDGE CASE: passenger left behind when boarding window is missed', () => {
|
||||
passengers = [
|
||||
{
|
||||
id: 'p1',
|
||||
name: 'Alice',
|
||||
avatar: '👩',
|
||||
originStationId: 's1',
|
||||
destinationStationId: 's2',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
},
|
||||
{
|
||||
id: 'p2',
|
||||
name: 'Bob',
|
||||
avatar: '👨',
|
||||
originStationId: 's1',
|
||||
destinationStationId: 's2',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
},
|
||||
]
|
||||
|
||||
// Only 1 car, 2 passengers
|
||||
// Frame 1: Train at 27%, car at 20%
|
||||
let result = simulateBoardingAtPosition(27, passengers, stations, 1)
|
||||
expect(result[0].isBoarded).toBe(true)
|
||||
expect(result[1].isBoarded).toBe(false) // Second passenger waiting
|
||||
|
||||
// Frame 2: Train jumps way past (very high momentum)
|
||||
result = simulateBoardingAtPosition(50, result, stations, 1)
|
||||
// Car at 43% - way past station at 20%
|
||||
|
||||
// Second passenger SHOULD BE LEFT BEHIND!
|
||||
expect(result[1].isBoarded).toBe(false)
|
||||
})
|
||||
|
||||
test('EDGE CASE: only one passenger boards per car per frame', () => {
|
||||
passengers = [
|
||||
{
|
||||
id: 'p1',
|
||||
name: 'Alice',
|
||||
avatar: '👩',
|
||||
originStationId: 's1',
|
||||
destinationStationId: 's2',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
},
|
||||
{
|
||||
id: 'p2',
|
||||
name: 'Bob',
|
||||
avatar: '👨',
|
||||
originStationId: 's1',
|
||||
destinationStationId: 's2',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
},
|
||||
]
|
||||
|
||||
// Only 1 car, both passengers at same station
|
||||
// With the fix, only first passenger should board in this frame
|
||||
const result = simulateBoardingAtPosition(27, passengers, stations, 1)
|
||||
|
||||
// First passenger boards
|
||||
expect(result[0].isBoarded).toBe(true)
|
||||
// Second passenger does NOT board (car already assigned this frame)
|
||||
expect(result[1].isBoarded).toBe(false)
|
||||
})
|
||||
|
||||
test('all passengers board before train completely passes station', () => {
|
||||
passengers = [
|
||||
{
|
||||
id: 'p1',
|
||||
name: 'Alice',
|
||||
avatar: '👩',
|
||||
originStationId: 's1',
|
||||
destinationStationId: 's2',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
},
|
||||
{
|
||||
id: 'p2',
|
||||
name: 'Bob',
|
||||
avatar: '👨',
|
||||
originStationId: 's1',
|
||||
destinationStationId: 's2',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
},
|
||||
{
|
||||
id: 'p3',
|
||||
name: 'Charlie',
|
||||
avatar: '👴',
|
||||
originStationId: 's1',
|
||||
destinationStationId: 's2',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
},
|
||||
]
|
||||
|
||||
// 3 passengers, 3 cars
|
||||
// Simulate train moving through station frame by frame
|
||||
let result = passengers
|
||||
|
||||
// Train approaching station
|
||||
for (let pos = 13; pos <= 40; pos += 1) {
|
||||
result = simulateBoardingAtPosition(pos, result, stations, 3)
|
||||
}
|
||||
|
||||
// All passengers should have boarded by the time last car passes
|
||||
const allBoarded = result.every((p) => p.isBoarded)
|
||||
const leftBehind = result.filter((p) => !p.isBoarded)
|
||||
|
||||
expect(allBoarded).toBe(true)
|
||||
if (!allBoarded) {
|
||||
console.log(
|
||||
'Passengers left behind:',
|
||||
leftBehind.map((p) => p.name)
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,292 +0,0 @@
|
||||
/**
|
||||
* Unit tests for passenger boarding/delivery logic in useSteamJourney
|
||||
*
|
||||
* These tests ensure that:
|
||||
* 1. Passengers always board when an empty car reaches their origin station
|
||||
* 2. Passengers are never left behind
|
||||
* 3. Multiple passengers can board at the same station on different cars
|
||||
* 4. Passengers are delivered to the correct destination
|
||||
*/
|
||||
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { ComplementRaceProvider, useComplementRace } from '../../context/ComplementRaceContext'
|
||||
import type { Passenger, Station } from '../../lib/gameTypes'
|
||||
import { useSteamJourney } from '../useSteamJourney'
|
||||
|
||||
// Mock sound effects
|
||||
vi.mock('../useSoundEffects', () => ({
|
||||
useSoundEffects: () => ({
|
||||
playSound: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Wrapper component
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<ComplementRaceProvider initialStyle="sprint">{children}</ComplementRaceProvider>
|
||||
)
|
||||
|
||||
// Helper to create test passengers
|
||||
const createPassenger = (
|
||||
id: string,
|
||||
originStationId: string,
|
||||
destinationStationId: string,
|
||||
isBoarded = false,
|
||||
isDelivered = false
|
||||
): Passenger => ({
|
||||
id,
|
||||
name: `Passenger ${id}`,
|
||||
avatar: '👤',
|
||||
originStationId,
|
||||
destinationStationId,
|
||||
isUrgent: false,
|
||||
isBoarded,
|
||||
isDelivered,
|
||||
})
|
||||
|
||||
// Test stations
|
||||
const _testStations: Station[] = [
|
||||
{ id: 'station-0', name: 'Start', position: 0, icon: '🏁' },
|
||||
{ id: 'station-1', name: 'Middle', position: 50, icon: '🏢' },
|
||||
{ id: 'station-2', name: 'End', position: 100, icon: '🏁' },
|
||||
]
|
||||
|
||||
describe('useSteamJourney - Passenger Boarding', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
test('passenger boards when train reaches their origin station', () => {
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
const journey = useSteamJourney()
|
||||
const race = useComplementRace()
|
||||
return { journey, race }
|
||||
},
|
||||
{ wrapper }
|
||||
)
|
||||
|
||||
// Setup: Add passenger waiting at station-1 (position 50)
|
||||
const passenger = createPassenger('p1', 'station-1', 'station-2')
|
||||
|
||||
act(() => {
|
||||
result.current.race.dispatch({ type: 'BEGIN_GAME' })
|
||||
result.current.race.dispatch({
|
||||
type: 'GENERATE_PASSENGERS',
|
||||
passengers: [passenger],
|
||||
})
|
||||
// Set train position just before station-1
|
||||
result.current.race.dispatch({
|
||||
type: 'UPDATE_STEAM_JOURNEY',
|
||||
momentum: 50,
|
||||
trainPosition: 40, // First car will be at ~33 (40 - 7)
|
||||
pressure: 75,
|
||||
elapsedTime: 1000,
|
||||
})
|
||||
})
|
||||
|
||||
// Verify passenger is waiting
|
||||
expect(result.current.race.state.passengers[0].isBoarded).toBe(false)
|
||||
|
||||
// Move train to station-1 position
|
||||
act(() => {
|
||||
result.current.race.dispatch({
|
||||
type: 'UPDATE_STEAM_JOURNEY',
|
||||
momentum: 50,
|
||||
trainPosition: 57, // First car at position 50 (57 - 7)
|
||||
pressure: 75,
|
||||
elapsedTime: 2000,
|
||||
})
|
||||
})
|
||||
|
||||
// Advance timers to trigger the interval
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100)
|
||||
})
|
||||
|
||||
// Verify passenger boarded
|
||||
const boardedPassenger = result.current.race.state.passengers.find((p) => p.id === 'p1')
|
||||
expect(boardedPassenger?.isBoarded).toBe(true)
|
||||
})
|
||||
|
||||
test('multiple passengers can board at the same station on different cars', () => {
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
const journey = useSteamJourney()
|
||||
const race = useComplementRace()
|
||||
return { journey, race }
|
||||
},
|
||||
{ wrapper }
|
||||
)
|
||||
|
||||
// Setup: Three passengers waiting at station-1
|
||||
const passengers = [
|
||||
createPassenger('p1', 'station-1', 'station-2'),
|
||||
createPassenger('p2', 'station-1', 'station-2'),
|
||||
createPassenger('p3', 'station-1', 'station-2'),
|
||||
]
|
||||
|
||||
act(() => {
|
||||
result.current.race.dispatch({ type: 'BEGIN_GAME' })
|
||||
result.current.race.dispatch({
|
||||
type: 'GENERATE_PASSENGERS',
|
||||
passengers,
|
||||
})
|
||||
// Set train with 3 empty cars approaching station-1 (position 50)
|
||||
// Cars at: 50 (57-7), 43 (57-14), 36 (57-21)
|
||||
result.current.race.dispatch({
|
||||
type: 'UPDATE_STEAM_JOURNEY',
|
||||
momentum: 60,
|
||||
trainPosition: 57,
|
||||
pressure: 90,
|
||||
elapsedTime: 1000,
|
||||
})
|
||||
})
|
||||
|
||||
// Advance timers
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100)
|
||||
})
|
||||
|
||||
// All three passengers should board (one per car)
|
||||
const boardedCount = result.current.race.state.passengers.filter((p) => p.isBoarded).length
|
||||
expect(boardedCount).toBe(3)
|
||||
})
|
||||
|
||||
test('passenger is not left behind when train passes quickly', () => {
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
const journey = useSteamJourney()
|
||||
const race = useComplementRace()
|
||||
return { journey, race }
|
||||
},
|
||||
{ wrapper }
|
||||
)
|
||||
|
||||
const passenger = createPassenger('p1', 'station-1', 'station-2')
|
||||
|
||||
act(() => {
|
||||
result.current.race.dispatch({ type: 'BEGIN_GAME' })
|
||||
result.current.race.dispatch({
|
||||
type: 'GENERATE_PASSENGERS',
|
||||
passengers: [passenger],
|
||||
})
|
||||
})
|
||||
|
||||
// Simulate train passing through station quickly
|
||||
const positions = [40, 45, 50, 52, 54, 56, 58, 60, 65, 70]
|
||||
|
||||
for (const pos of positions) {
|
||||
act(() => {
|
||||
result.current.race.dispatch({
|
||||
type: 'UPDATE_STEAM_JOURNEY',
|
||||
momentum: 80,
|
||||
trainPosition: pos,
|
||||
pressure: 120,
|
||||
elapsedTime: 1000 + pos * 50,
|
||||
})
|
||||
vi.advanceTimersByTime(50)
|
||||
})
|
||||
|
||||
// Check if passenger boarded
|
||||
const boardedPassenger = result.current.race.state.passengers.find((p) => p.id === 'p1')
|
||||
if (boardedPassenger?.isBoarded) {
|
||||
// Success! Passenger boarded during the pass
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// If we get here, passenger was left behind
|
||||
const boardedPassenger = result.current.race.state.passengers.find((p) => p.id === 'p1')
|
||||
expect(boardedPassenger?.isBoarded).toBe(true)
|
||||
})
|
||||
|
||||
test('passenger boards on correct car based on availability', () => {
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
const journey = useSteamJourney()
|
||||
const race = useComplementRace()
|
||||
return { journey, race }
|
||||
},
|
||||
{ wrapper }
|
||||
)
|
||||
|
||||
// Setup: One passenger already on car 0, another waiting
|
||||
const passengers = [
|
||||
createPassenger('p1', 'station-0', 'station-2', true, false), // Already boarded on car 0
|
||||
createPassenger('p2', 'station-1', 'station-2'), // Waiting at station-1
|
||||
]
|
||||
|
||||
act(() => {
|
||||
result.current.race.dispatch({ type: 'BEGIN_GAME' })
|
||||
result.current.race.dispatch({
|
||||
type: 'GENERATE_PASSENGERS',
|
||||
passengers,
|
||||
})
|
||||
// Train at station-1, car 0 occupied, car 1 empty
|
||||
result.current.race.dispatch({
|
||||
type: 'UPDATE_STEAM_JOURNEY',
|
||||
momentum: 50,
|
||||
trainPosition: 57, // Car 0 at 50, Car 1 at 43
|
||||
pressure: 75,
|
||||
elapsedTime: 2000,
|
||||
})
|
||||
})
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100)
|
||||
})
|
||||
|
||||
// p2 should board (on car 1 since car 0 is occupied)
|
||||
const p2 = result.current.race.state.passengers.find((p) => p.id === 'p2')
|
||||
expect(p2?.isBoarded).toBe(true)
|
||||
|
||||
// p1 should still be boarded
|
||||
const p1 = result.current.race.state.passengers.find((p) => p.id === 'p1')
|
||||
expect(p1?.isBoarded).toBe(true)
|
||||
expect(p1?.isDelivered).toBe(false)
|
||||
})
|
||||
|
||||
test('passenger is delivered when their car reaches destination', () => {
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
const journey = useSteamJourney()
|
||||
const race = useComplementRace()
|
||||
return { journey, race }
|
||||
},
|
||||
{ wrapper }
|
||||
)
|
||||
|
||||
// Setup: Passenger already boarded, heading to station-2 (position 100)
|
||||
const passenger = createPassenger('p1', 'station-0', 'station-2', true, false)
|
||||
|
||||
act(() => {
|
||||
result.current.race.dispatch({ type: 'BEGIN_GAME' })
|
||||
result.current.race.dispatch({
|
||||
type: 'GENERATE_PASSENGERS',
|
||||
passengers: [passenger],
|
||||
})
|
||||
// Move train so car 0 reaches station-2
|
||||
result.current.race.dispatch({
|
||||
type: 'UPDATE_STEAM_JOURNEY',
|
||||
momentum: 50,
|
||||
trainPosition: 107, // Car 0 at position 100 (107 - 7)
|
||||
pressure: 75,
|
||||
elapsedTime: 5000,
|
||||
})
|
||||
})
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100)
|
||||
})
|
||||
|
||||
// Passenger should be delivered
|
||||
const deliveredPassenger = result.current.race.state.passengers.find((p) => p.id === 'p1')
|
||||
expect(deliveredPassenger?.isDelivered).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,500 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
import type { Passenger, Station } from '../../lib/gameTypes'
|
||||
import type { RailroadTrackGenerator } from '../../lib/RailroadTrackGenerator'
|
||||
import { useTrackManagement } from '../useTrackManagement'
|
||||
|
||||
describe('useTrackManagement - Passenger Display', () => {
|
||||
let mockPathRef: React.RefObject<SVGPathElement>
|
||||
let mockTrackGenerator: RailroadTrackGenerator
|
||||
let mockStations: Station[]
|
||||
let mockPassengers: Passenger[]
|
||||
|
||||
beforeEach(() => {
|
||||
// Create mock path element
|
||||
const mockPath = document.createElementNS('http://www.w3.org/2000/svg', 'path')
|
||||
mockPath.getTotalLength = vi.fn(() => 1000)
|
||||
mockPath.getPointAtLength = vi.fn((distance: number) => ({
|
||||
x: distance,
|
||||
y: 300,
|
||||
}))
|
||||
mockPathRef = { current: mockPath }
|
||||
|
||||
// Mock track generator
|
||||
mockTrackGenerator = {
|
||||
generateTrack: vi.fn(() => ({
|
||||
ballastPath: 'M 0 0',
|
||||
referencePath: 'M 0 0',
|
||||
ties: [],
|
||||
leftRailPath: 'M 0 0',
|
||||
rightRailPath: 'M 0 0',
|
||||
})),
|
||||
generateTiesAndRails: vi.fn(() => ({
|
||||
ties: [],
|
||||
leftRailPath: 'M 0 0',
|
||||
rightRailPath: 'M 0 0',
|
||||
})),
|
||||
} as unknown as RailroadTrackGenerator
|
||||
|
||||
// Mock stations
|
||||
mockStations = [
|
||||
{ id: 'station1', name: 'Station 1', icon: '🏠', position: 20 },
|
||||
{ id: 'station2', name: 'Station 2', icon: '🏢', position: 50 },
|
||||
{ id: 'station3', name: 'Station 3', icon: '🏪', position: 80 },
|
||||
]
|
||||
|
||||
// Mock passengers - initial set
|
||||
mockPassengers = [
|
||||
{
|
||||
id: 'p1',
|
||||
name: 'Alice',
|
||||
avatar: '👩',
|
||||
originStationId: 'station1',
|
||||
destinationStationId: 'station2',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
},
|
||||
{
|
||||
id: 'p2',
|
||||
name: 'Bob',
|
||||
avatar: '👨',
|
||||
originStationId: 'station2',
|
||||
destinationStationId: 'station3',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
},
|
||||
]
|
||||
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
test('initial passengers are displayed', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTrackManagement({
|
||||
currentRoute: 1,
|
||||
trainPosition: 10,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers: mockPassengers,
|
||||
maxCars: 3,
|
||||
carSpacing: 7,
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.current.displayPassengers).toHaveLength(2)
|
||||
expect(result.current.displayPassengers[0].id).toBe('p1')
|
||||
expect(result.current.displayPassengers[1].id).toBe('p2')
|
||||
})
|
||||
|
||||
test('passengers update when boarded (same route gameplay)', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ passengers, position }) =>
|
||||
useTrackManagement({
|
||||
currentRoute: 1,
|
||||
trainPosition: position,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers,
|
||||
maxCars: 3,
|
||||
carSpacing: 7,
|
||||
}),
|
||||
{ initialProps: { passengers: mockPassengers, position: 25 } }
|
||||
)
|
||||
|
||||
// Initially 2 passengers
|
||||
expect(result.current.displayPassengers).toHaveLength(2)
|
||||
expect(result.current.displayPassengers[0].isBoarded).toBe(false)
|
||||
|
||||
// Board first passenger
|
||||
const boardedPassengers = mockPassengers.map((p) =>
|
||||
p.id === 'p1' ? { ...p, isBoarded: true } : p
|
||||
)
|
||||
|
||||
rerender({ passengers: boardedPassengers, position: 25 })
|
||||
|
||||
// Should show updated passengers
|
||||
expect(result.current.displayPassengers).toHaveLength(2)
|
||||
expect(result.current.displayPassengers[0].isBoarded).toBe(true)
|
||||
})
|
||||
|
||||
test('passengers do NOT update during route transition (train moving)', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ route, passengers, position }) =>
|
||||
useTrackManagement({
|
||||
currentRoute: route,
|
||||
trainPosition: position,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers,
|
||||
maxCars: 3,
|
||||
carSpacing: 7,
|
||||
}),
|
||||
{ initialProps: { route: 1, passengers: mockPassengers, position: 50 } }
|
||||
)
|
||||
|
||||
// Initially route 1 passengers
|
||||
expect(result.current.displayPassengers).toHaveLength(2)
|
||||
expect(result.current.displayPassengers[0].id).toBe('p1')
|
||||
|
||||
// Generate new passengers for route 2
|
||||
const newPassengers: Passenger[] = [
|
||||
{
|
||||
id: 'p3',
|
||||
name: 'Charlie',
|
||||
avatar: '👴',
|
||||
originStationId: 'station1',
|
||||
destinationStationId: 'station3',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
},
|
||||
]
|
||||
|
||||
// Change route but train still moving
|
||||
rerender({ route: 2, passengers: newPassengers, position: 60 })
|
||||
|
||||
// Should STILL show old passengers (route 1)
|
||||
expect(result.current.displayPassengers).toHaveLength(2)
|
||||
expect(result.current.displayPassengers[0].id).toBe('p1')
|
||||
expect(result.current.displayPassengers[0].name).toBe('Alice')
|
||||
})
|
||||
|
||||
test('passengers update when train resets to start (negative position)', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ route, passengers, position }) =>
|
||||
useTrackManagement({
|
||||
currentRoute: route,
|
||||
trainPosition: position,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers,
|
||||
maxCars: 3,
|
||||
carSpacing: 7,
|
||||
}),
|
||||
{ initialProps: { route: 1, passengers: mockPassengers, position: 50 } }
|
||||
)
|
||||
|
||||
// Initially route 1 passengers
|
||||
expect(result.current.displayPassengers).toHaveLength(2)
|
||||
expect(result.current.displayPassengers[0].id).toBe('p1')
|
||||
|
||||
// Generate new passengers for route 2
|
||||
const newPassengers: Passenger[] = [
|
||||
{
|
||||
id: 'p3',
|
||||
name: 'Charlie',
|
||||
avatar: '👴',
|
||||
originStationId: 'station1',
|
||||
destinationStationId: 'station3',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
},
|
||||
]
|
||||
|
||||
// Change route and train resets
|
||||
rerender({ route: 2, passengers: newPassengers, position: -5 })
|
||||
|
||||
// Should now show NEW passengers (route 2)
|
||||
expect(result.current.displayPassengers).toHaveLength(1)
|
||||
expect(result.current.displayPassengers[0].id).toBe('p3')
|
||||
expect(result.current.displayPassengers[0].name).toBe('Charlie')
|
||||
})
|
||||
|
||||
test('passengers do NOT flash when transitioning through 100%', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ route, passengers, position }) =>
|
||||
useTrackManagement({
|
||||
currentRoute: route,
|
||||
trainPosition: position,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers,
|
||||
maxCars: 3,
|
||||
carSpacing: 7,
|
||||
}),
|
||||
{ initialProps: { route: 1, passengers: mockPassengers, position: 95 } }
|
||||
)
|
||||
|
||||
// At 95% - show route 1 passengers
|
||||
expect(result.current.displayPassengers).toHaveLength(2)
|
||||
expect(result.current.displayPassengers[0].id).toBe('p1')
|
||||
|
||||
// Generate new passengers for route 2
|
||||
const newPassengers: Passenger[] = [
|
||||
{
|
||||
id: 'p3',
|
||||
name: 'Charlie',
|
||||
avatar: '👴',
|
||||
originStationId: 'station1',
|
||||
destinationStationId: 'station3',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
},
|
||||
]
|
||||
|
||||
// Train exits (105%) but route hasn't changed yet
|
||||
rerender({ route: 1, passengers: mockPassengers, position: 105 })
|
||||
|
||||
// Should STILL show route 1 passengers
|
||||
expect(result.current.displayPassengers).toHaveLength(2)
|
||||
expect(result.current.displayPassengers[0].id).toBe('p1')
|
||||
|
||||
// Now route changes to 2, but train still at 105%
|
||||
rerender({ route: 2, passengers: newPassengers, position: 105 })
|
||||
|
||||
// Should STILL show route 1 passengers (old ones)
|
||||
expect(result.current.displayPassengers).toHaveLength(2)
|
||||
expect(result.current.displayPassengers[0].id).toBe('p1')
|
||||
|
||||
// Train resets to start
|
||||
rerender({ route: 2, passengers: newPassengers, position: -5 })
|
||||
|
||||
// NOW should show route 2 passengers
|
||||
expect(result.current.displayPassengers).toHaveLength(1)
|
||||
expect(result.current.displayPassengers[0].id).toBe('p3')
|
||||
})
|
||||
|
||||
test('passengers do NOT update when array reference changes but same route', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ passengers, position }) =>
|
||||
useTrackManagement({
|
||||
currentRoute: 1,
|
||||
trainPosition: position,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers,
|
||||
maxCars: 3,
|
||||
carSpacing: 7,
|
||||
}),
|
||||
{ initialProps: { passengers: mockPassengers, position: 50 } }
|
||||
)
|
||||
|
||||
// Initially route 1 passengers
|
||||
expect(result.current.displayPassengers).toHaveLength(2)
|
||||
expect(result.current.displayPassengers[0].id).toBe('p1')
|
||||
|
||||
// Create new array with same content (different reference)
|
||||
const samePassengersNewRef = mockPassengers.map((p) => ({ ...p }))
|
||||
|
||||
// Update with new reference but same content
|
||||
rerender({ passengers: samePassengersNewRef, position: 50 })
|
||||
|
||||
// Display should update because it's the same route (gameplay update)
|
||||
expect(result.current.displayPassengers).toHaveLength(2)
|
||||
expect(result.current.displayPassengers[0].id).toBe('p1')
|
||||
})
|
||||
|
||||
test('delivered passengers update immediately (same route)', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ passengers, position }) =>
|
||||
useTrackManagement({
|
||||
currentRoute: 1,
|
||||
trainPosition: position,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers,
|
||||
maxCars: 3,
|
||||
carSpacing: 7,
|
||||
}),
|
||||
{ initialProps: { passengers: mockPassengers, position: 25 } }
|
||||
)
|
||||
|
||||
// Initially 2 passengers, neither delivered
|
||||
expect(result.current.displayPassengers).toHaveLength(2)
|
||||
expect(result.current.displayPassengers[0].isDelivered).toBe(false)
|
||||
|
||||
// Deliver first passenger
|
||||
const deliveredPassengers = mockPassengers.map((p) =>
|
||||
p.id === 'p1' ? { ...p, isBoarded: true, isDelivered: true } : p
|
||||
)
|
||||
|
||||
rerender({ passengers: deliveredPassengers, position: 55 })
|
||||
|
||||
// Should show updated passengers immediately
|
||||
expect(result.current.displayPassengers).toHaveLength(2)
|
||||
expect(result.current.displayPassengers[0].isDelivered).toBe(true)
|
||||
})
|
||||
|
||||
test('multiple rapid passenger updates during same route', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ passengers, position }) =>
|
||||
useTrackManagement({
|
||||
currentRoute: 1,
|
||||
trainPosition: position,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers,
|
||||
maxCars: 3,
|
||||
carSpacing: 7,
|
||||
}),
|
||||
{ initialProps: { passengers: mockPassengers, position: 25 } }
|
||||
)
|
||||
|
||||
// Initially 2 passengers
|
||||
expect(result.current.displayPassengers).toHaveLength(2)
|
||||
|
||||
// Board p1
|
||||
let updated = mockPassengers.map((p) => (p.id === 'p1' ? { ...p, isBoarded: true } : p))
|
||||
rerender({ passengers: updated, position: 26 })
|
||||
expect(result.current.displayPassengers[0].isBoarded).toBe(true)
|
||||
|
||||
// Board p2
|
||||
updated = updated.map((p) => (p.id === 'p2' ? { ...p, isBoarded: true } : p))
|
||||
rerender({ passengers: updated, position: 52 })
|
||||
expect(result.current.displayPassengers[1].isBoarded).toBe(true)
|
||||
|
||||
// Deliver p1
|
||||
updated = updated.map((p) => (p.id === 'p1' ? { ...p, isDelivered: true } : p))
|
||||
rerender({ passengers: updated, position: 53 })
|
||||
expect(result.current.displayPassengers[0].isDelivered).toBe(true)
|
||||
|
||||
// All updates should have been reflected
|
||||
expect(result.current.displayPassengers[0].isBoarded).toBe(true)
|
||||
expect(result.current.displayPassengers[0].isDelivered).toBe(true)
|
||||
expect(result.current.displayPassengers[1].isBoarded).toBe(true)
|
||||
expect(result.current.displayPassengers[1].isDelivered).toBe(false)
|
||||
})
|
||||
|
||||
test('EDGE CASE: new passengers at position 0 with old route', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ route, passengers, position }) =>
|
||||
useTrackManagement({
|
||||
currentRoute: route,
|
||||
trainPosition: position,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers,
|
||||
maxCars: 3,
|
||||
carSpacing: 7,
|
||||
}),
|
||||
{ initialProps: { route: 1, passengers: mockPassengers, position: 95 } }
|
||||
)
|
||||
|
||||
// At 95% - route 1 passengers
|
||||
expect(result.current.displayPassengers[0].id).toBe('p1')
|
||||
|
||||
// Train exits tunnel
|
||||
rerender({ route: 1, passengers: mockPassengers, position: 110 })
|
||||
expect(result.current.displayPassengers[0].id).toBe('p1')
|
||||
|
||||
// New passengers generated but route hasn't changed yet, position resets to 0
|
||||
const newPassengers: Passenger[] = [
|
||||
{
|
||||
id: 'p3',
|
||||
name: 'Charlie',
|
||||
avatar: '👴',
|
||||
originStationId: 'station1',
|
||||
destinationStationId: 'station3',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
},
|
||||
]
|
||||
|
||||
// CRITICAL: New passengers, old route, position = 0
|
||||
// This could trigger the second useEffect if not handled carefully
|
||||
rerender({ route: 1, passengers: newPassengers, position: 0 })
|
||||
|
||||
// Should NOT show new passengers yet (route hasn't changed)
|
||||
// But position is 0-100, so second effect might fire
|
||||
expect(result.current.displayPassengers[0].id).toBe('p1')
|
||||
expect(result.current.displayPassengers[0].name).toBe('Alice')
|
||||
})
|
||||
|
||||
test('EDGE CASE: passengers regenerated at position 5%', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ route, passengers, position }) =>
|
||||
useTrackManagement({
|
||||
currentRoute: route,
|
||||
trainPosition: position,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers,
|
||||
maxCars: 3,
|
||||
carSpacing: 7,
|
||||
}),
|
||||
{ initialProps: { route: 1, passengers: mockPassengers, position: 95 } }
|
||||
)
|
||||
|
||||
// At 95% - route 1 passengers
|
||||
expect(result.current.displayPassengers[0].id).toBe('p1')
|
||||
|
||||
// New passengers generated while train is at 5%
|
||||
const newPassengers: Passenger[] = [
|
||||
{
|
||||
id: 'p3',
|
||||
name: 'Charlie',
|
||||
avatar: '👴',
|
||||
originStationId: 'station1',
|
||||
destinationStationId: 'station3',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
},
|
||||
]
|
||||
|
||||
// CRITICAL: New passengers array, same route, position within 0-100
|
||||
rerender({ route: 1, passengers: newPassengers, position: 5 })
|
||||
|
||||
// Should NOT show new passengers (different array reference, route hasn't changed properly)
|
||||
expect(result.current.displayPassengers[0].id).toBe('p1')
|
||||
})
|
||||
|
||||
test('EDGE CASE: rapid route increment with position oscillation', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ route, passengers, position }) =>
|
||||
useTrackManagement({
|
||||
currentRoute: route,
|
||||
trainPosition: position,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers,
|
||||
maxCars: 3,
|
||||
carSpacing: 7,
|
||||
}),
|
||||
{ initialProps: { route: 1, passengers: mockPassengers, position: 50 } }
|
||||
)
|
||||
|
||||
expect(result.current.displayPassengers[0].id).toBe('p1')
|
||||
|
||||
const route2Passengers: Passenger[] = [
|
||||
{
|
||||
id: 'p3',
|
||||
name: 'Charlie',
|
||||
avatar: '👴',
|
||||
originStationId: 'station1',
|
||||
destinationStationId: 'station3',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
},
|
||||
]
|
||||
|
||||
// Route changes, position goes positive briefly before negative
|
||||
rerender({ route: 2, passengers: route2Passengers, position: 2 })
|
||||
|
||||
// Should still show old passengers
|
||||
expect(result.current.displayPassengers[0].id).toBe('p1')
|
||||
|
||||
// Position goes negative
|
||||
rerender({ route: 2, passengers: route2Passengers, position: -3 })
|
||||
|
||||
// NOW should show new passengers
|
||||
expect(result.current.displayPassengers[0].id).toBe('p3')
|
||||
})
|
||||
})
|
||||
@@ -1,362 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
import type { Passenger, Station } from '../../lib/gameTypes'
|
||||
import type { RailroadTrackGenerator } from '../../lib/RailroadTrackGenerator'
|
||||
import { useTrackManagement } from '../useTrackManagement'
|
||||
|
||||
// Mock the landmarks module
|
||||
vi.mock('../../lib/landmarks', () => ({
|
||||
generateLandmarks: vi.fn((_route: number) => [
|
||||
{ emoji: '🌲', position: 30, offset: { x: 0, y: -50 }, size: 24 },
|
||||
{ emoji: '🏔️', position: 70, offset: { x: 0, y: -80 }, size: 32 },
|
||||
]),
|
||||
}))
|
||||
|
||||
describe('useTrackManagement', () => {
|
||||
let mockPathRef: React.RefObject<SVGPathElement>
|
||||
let mockTrackGenerator: RailroadTrackGenerator
|
||||
let mockStations: Station[]
|
||||
let mockPassengers: Passenger[]
|
||||
|
||||
beforeEach(() => {
|
||||
// Create mock path element
|
||||
const mockPath = document.createElementNS('http://www.w3.org/2000/svg', 'path')
|
||||
mockPath.getTotalLength = vi.fn(() => 1000)
|
||||
mockPath.getPointAtLength = vi.fn((distance: number) => ({
|
||||
x: distance,
|
||||
y: 300,
|
||||
}))
|
||||
mockPathRef = { current: mockPath }
|
||||
|
||||
// Mock track generator
|
||||
mockTrackGenerator = {
|
||||
generateTrack: vi.fn((route: number) => ({
|
||||
referencePath: `M 0 300 L ${route * 100} 300`,
|
||||
ballastPath: `M 0 300 L ${route * 100} 300`,
|
||||
})),
|
||||
generateTiesAndRails: vi.fn(() => ({
|
||||
ties: [
|
||||
{ x1: 0, y1: 300, x2: 10, y2: 300 },
|
||||
{ x1: 20, y1: 300, x2: 30, y2: 300 },
|
||||
],
|
||||
leftRailPoints: ['0,295', '100,295'],
|
||||
rightRailPoints: ['0,305', '100,305'],
|
||||
})),
|
||||
} as unknown as RailroadTrackGenerator
|
||||
|
||||
mockStations = [
|
||||
{ id: 'station-1', name: 'Station 1', position: 20, icon: '🏭' },
|
||||
{ id: 'station-2', name: 'Station 2', position: 60, icon: '🏛️' },
|
||||
]
|
||||
|
||||
mockPassengers = [
|
||||
{
|
||||
id: 'passenger-1',
|
||||
avatar: '👨',
|
||||
originStationId: 'station-1',
|
||||
destinationStationId: 'station-2',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
},
|
||||
]
|
||||
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
test('initializes with null trackData', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTrackManagement({
|
||||
currentRoute: 1,
|
||||
trainPosition: 0,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers: mockPassengers,
|
||||
})
|
||||
)
|
||||
|
||||
// Track data should be generated
|
||||
expect(result.current.trackData).toBeDefined()
|
||||
expect(mockTrackGenerator.generateTrack).toHaveBeenCalledWith(1)
|
||||
})
|
||||
|
||||
test('generates landmarks for current route', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTrackManagement({
|
||||
currentRoute: 1,
|
||||
trainPosition: 0,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers: mockPassengers,
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.current.landmarks).toHaveLength(2)
|
||||
expect(result.current.landmarks[0].emoji).toBe('🌲')
|
||||
expect(result.current.landmarks[1].emoji).toBe('🏔️')
|
||||
})
|
||||
|
||||
test('generates ties and rails when path is ready', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTrackManagement({
|
||||
currentRoute: 1,
|
||||
trainPosition: 0,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers: mockPassengers,
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.current.tiesAndRails).toBeDefined()
|
||||
expect(result.current.tiesAndRails?.ties).toHaveLength(2)
|
||||
})
|
||||
|
||||
test('calculates station positions along path', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTrackManagement({
|
||||
currentRoute: 1,
|
||||
trainPosition: 0,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers: mockPassengers,
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.current.stationPositions).toHaveLength(2)
|
||||
// Station 1 at 20% of 1000 = 200
|
||||
expect(result.current.stationPositions[0].x).toBe(200)
|
||||
// Station 2 at 60% of 1000 = 600
|
||||
expect(result.current.stationPositions[1].x).toBe(600)
|
||||
})
|
||||
|
||||
test('calculates landmark positions along path', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTrackManagement({
|
||||
currentRoute: 1,
|
||||
trainPosition: 0,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers: mockPassengers,
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.current.landmarkPositions).toHaveLength(2)
|
||||
// First landmark at 30% + offset
|
||||
expect(result.current.landmarkPositions[0].x).toBe(300) // 30% of 1000
|
||||
expect(result.current.landmarkPositions[0].y).toBe(250) // 300 + (-50)
|
||||
})
|
||||
|
||||
test('delays track update when changing routes mid-journey', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ route, position }) =>
|
||||
useTrackManagement({
|
||||
currentRoute: route,
|
||||
trainPosition: position,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers: mockPassengers,
|
||||
}),
|
||||
{
|
||||
initialProps: { route: 1, position: 0 },
|
||||
}
|
||||
)
|
||||
|
||||
const initialTrackData = result.current.trackData
|
||||
|
||||
// Change route while train is mid-journey (position > 0)
|
||||
rerender({ route: 2, position: 50 })
|
||||
|
||||
// Track should NOT update yet (pending)
|
||||
expect(result.current.trackData).toBe(initialTrackData)
|
||||
expect(mockTrackGenerator.generateTrack).toHaveBeenCalledWith(2)
|
||||
})
|
||||
|
||||
test('applies pending track when train resets to beginning', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ route, position }) =>
|
||||
useTrackManagement({
|
||||
currentRoute: route,
|
||||
trainPosition: position,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers: mockPassengers,
|
||||
}),
|
||||
{
|
||||
initialProps: { route: 1, position: 0 },
|
||||
}
|
||||
)
|
||||
|
||||
// Change route while train is mid-journey
|
||||
rerender({ route: 2, position: 50 })
|
||||
const trackDataBeforeReset = result.current.trackData
|
||||
|
||||
// Train resets to beginning (position < 0)
|
||||
rerender({ route: 2, position: -5 })
|
||||
|
||||
// Track should now update
|
||||
expect(result.current.trackData).not.toBe(trackDataBeforeReset)
|
||||
})
|
||||
|
||||
test('immediately applies new track when train is at start', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ route, position }) =>
|
||||
useTrackManagement({
|
||||
currentRoute: route,
|
||||
trainPosition: position,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers: mockPassengers,
|
||||
}),
|
||||
{
|
||||
initialProps: { route: 1, position: -5 },
|
||||
}
|
||||
)
|
||||
|
||||
const initialTrackData = result.current.trackData
|
||||
|
||||
// Change route while train is at start (position < 0)
|
||||
rerender({ route: 2, position: -5 })
|
||||
|
||||
// Track should update immediately
|
||||
expect(result.current.trackData).not.toBe(initialTrackData)
|
||||
})
|
||||
|
||||
test('delays passenger display update until all cars exit', () => {
|
||||
const newPassengers: Passenger[] = [
|
||||
{
|
||||
id: 'passenger-2',
|
||||
avatar: '👩',
|
||||
originStationId: 'station-1',
|
||||
destinationStationId: 'station-2',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
},
|
||||
]
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ passengers, position }) =>
|
||||
useTrackManagement({
|
||||
currentRoute: 1,
|
||||
trainPosition: position,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers,
|
||||
maxCars: 5,
|
||||
carSpacing: 7,
|
||||
}),
|
||||
{
|
||||
initialProps: { passengers: mockPassengers, position: 50 },
|
||||
}
|
||||
)
|
||||
|
||||
expect(result.current.displayPassengers).toBe(mockPassengers)
|
||||
|
||||
// Change passengers while train is mid-journey
|
||||
// Locomotive at 100%, but last car at 100 - (5*7) = 65%
|
||||
rerender({ passengers: newPassengers, position: 100 })
|
||||
|
||||
// Display passengers should NOT update yet (last car hasn't exited)
|
||||
expect(result.current.displayPassengers).toBe(mockPassengers)
|
||||
})
|
||||
|
||||
test('does not update passenger display until train resets', () => {
|
||||
const newPassengers: Passenger[] = [
|
||||
{
|
||||
id: 'passenger-2',
|
||||
avatar: '👩',
|
||||
originStationId: 'station-1',
|
||||
destinationStationId: 'station-2',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
},
|
||||
]
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ passengers, position }) =>
|
||||
useTrackManagement({
|
||||
currentRoute: 1,
|
||||
trainPosition: position,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers,
|
||||
maxCars: 5,
|
||||
carSpacing: 7,
|
||||
}),
|
||||
{
|
||||
initialProps: { passengers: mockPassengers, position: 50 },
|
||||
}
|
||||
)
|
||||
|
||||
// Change passengers, locomotive at position where all cars have exited
|
||||
// Last car exits at position 97%, so locomotive at 132%
|
||||
rerender({ passengers: newPassengers, position: 132 })
|
||||
|
||||
// Display passengers should NOT update yet (waiting for train reset)
|
||||
expect(result.current.displayPassengers).toBe(mockPassengers)
|
||||
|
||||
// Now train resets to beginning
|
||||
rerender({ passengers: newPassengers, position: -5 })
|
||||
|
||||
// Display passengers should update now (train reset)
|
||||
expect(result.current.displayPassengers).toBe(newPassengers)
|
||||
})
|
||||
|
||||
test('updates passengers immediately during same route', () => {
|
||||
const updatedPassengers: Passenger[] = [{ ...mockPassengers[0], isBoarded: true }]
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ passengers, position }) =>
|
||||
useTrackManagement({
|
||||
currentRoute: 1,
|
||||
trainPosition: position,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers,
|
||||
maxCars: 5,
|
||||
carSpacing: 7,
|
||||
}),
|
||||
{
|
||||
initialProps: { passengers: mockPassengers, position: 50 },
|
||||
}
|
||||
)
|
||||
|
||||
// Update passengers (boarding) during same route
|
||||
rerender({ passengers: updatedPassengers, position: 55 })
|
||||
|
||||
// Display passengers should update immediately (same route, gameplay update)
|
||||
expect(result.current.displayPassengers).toBe(updatedPassengers)
|
||||
})
|
||||
|
||||
test('returns null when no track data', () => {
|
||||
// Create a hook where trackGenerator returns null
|
||||
const nullTrackGenerator = {
|
||||
generateTrack: vi.fn(() => null),
|
||||
} as unknown as RailroadTrackGenerator
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTrackManagement({
|
||||
currentRoute: 1,
|
||||
trainPosition: 0,
|
||||
trackGenerator: nullTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers: mockPassengers,
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.current.trackData).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,302 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
import type { RailroadTrackGenerator } from '../../lib/RailroadTrackGenerator'
|
||||
import { useTrainTransforms } from '../useTrainTransforms'
|
||||
|
||||
describe('useTrainTransforms', () => {
|
||||
let mockPathRef: React.RefObject<SVGPathElement>
|
||||
let mockTrackGenerator: RailroadTrackGenerator
|
||||
|
||||
beforeEach(() => {
|
||||
// Create mock path element
|
||||
const mockPath = document.createElementNS('http://www.w3.org/2000/svg', 'path')
|
||||
mockPathRef = { current: mockPath }
|
||||
|
||||
// Mock track generator
|
||||
mockTrackGenerator = {
|
||||
getTrainTransform: vi.fn((_path: SVGPathElement, position: number) => ({
|
||||
x: position * 10,
|
||||
y: 300,
|
||||
rotation: position / 10,
|
||||
})),
|
||||
} as unknown as RailroadTrackGenerator
|
||||
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
test('returns default transform when pathRef is null', () => {
|
||||
const nullPathRef: React.RefObject<SVGPathElement> = { current: null }
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTrainTransforms({
|
||||
trainPosition: 50,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: nullPathRef,
|
||||
maxCars: 5,
|
||||
carSpacing: 7,
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.current.trainTransform).toEqual({
|
||||
x: 50,
|
||||
y: 300,
|
||||
rotation: 0,
|
||||
})
|
||||
expect(result.current.trainCars).toHaveLength(5)
|
||||
})
|
||||
|
||||
test('calculates train transform at given position', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTrainTransforms({
|
||||
trainPosition: 50,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
maxCars: 5,
|
||||
carSpacing: 7,
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.current.trainTransform).toEqual({
|
||||
x: 500, // 50 * 10
|
||||
y: 300,
|
||||
rotation: 5, // 50 / 10
|
||||
})
|
||||
})
|
||||
|
||||
test('updates transform when train position changes', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ position }) =>
|
||||
useTrainTransforms({
|
||||
trainPosition: position,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
maxCars: 5,
|
||||
carSpacing: 7,
|
||||
}),
|
||||
{ initialProps: { position: 20 } }
|
||||
)
|
||||
|
||||
expect(result.current.trainTransform.x).toBe(200)
|
||||
|
||||
rerender({ position: 60 })
|
||||
expect(result.current.trainTransform.x).toBe(600)
|
||||
})
|
||||
|
||||
test('calculates correct number of train cars', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTrainTransforms({
|
||||
trainPosition: 50,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
maxCars: 5,
|
||||
carSpacing: 7,
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.current.trainCars).toHaveLength(5)
|
||||
})
|
||||
|
||||
test('respects custom maxCars parameter', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTrainTransforms({
|
||||
trainPosition: 50,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
maxCars: 3,
|
||||
carSpacing: 7,
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.current.trainCars).toHaveLength(3)
|
||||
})
|
||||
|
||||
test('respects custom carSpacing parameter', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTrainTransforms({
|
||||
trainPosition: 50,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
maxCars: 5,
|
||||
carSpacing: 10,
|
||||
})
|
||||
)
|
||||
|
||||
// First car should be at position 50 - 10 = 40
|
||||
expect(result.current.trainCars[0].position).toBe(40)
|
||||
})
|
||||
|
||||
test('positions cars behind locomotive with correct spacing', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTrainTransforms({
|
||||
trainPosition: 50,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
maxCars: 3,
|
||||
carSpacing: 10,
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.current.trainCars[0].position).toBe(40) // 50 - 1*10
|
||||
expect(result.current.trainCars[1].position).toBe(30) // 50 - 2*10
|
||||
expect(result.current.trainCars[2].position).toBe(20) // 50 - 3*10
|
||||
})
|
||||
|
||||
test('calculates locomotive opacity correctly during fade in', () => {
|
||||
// Fade in range: 3-8%
|
||||
const { result: result1 } = renderHook(() =>
|
||||
useTrainTransforms({
|
||||
trainPosition: 3,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
maxCars: 5,
|
||||
carSpacing: 7,
|
||||
})
|
||||
)
|
||||
expect(result1.current.locomotiveOpacity).toBe(0)
|
||||
|
||||
const { result: result2 } = renderHook(() =>
|
||||
useTrainTransforms({
|
||||
trainPosition: 5.5, // Midpoint between 3 and 8
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
maxCars: 5,
|
||||
carSpacing: 7,
|
||||
})
|
||||
)
|
||||
expect(result2.current.locomotiveOpacity).toBe(0.5)
|
||||
|
||||
const { result: result3 } = renderHook(() =>
|
||||
useTrainTransforms({
|
||||
trainPosition: 8,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
maxCars: 5,
|
||||
carSpacing: 7,
|
||||
})
|
||||
)
|
||||
expect(result3.current.locomotiveOpacity).toBe(1)
|
||||
})
|
||||
|
||||
test('calculates locomotive opacity correctly during fade out', () => {
|
||||
// Fade out range: 92-97%
|
||||
const { result: result1 } = renderHook(() =>
|
||||
useTrainTransforms({
|
||||
trainPosition: 92,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
maxCars: 5,
|
||||
carSpacing: 7,
|
||||
})
|
||||
)
|
||||
expect(result1.current.locomotiveOpacity).toBe(1)
|
||||
|
||||
const { result: result2 } = renderHook(() =>
|
||||
useTrainTransforms({
|
||||
trainPosition: 94.5, // Midpoint between 92 and 97
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
maxCars: 5,
|
||||
carSpacing: 7,
|
||||
})
|
||||
)
|
||||
expect(result2.current.locomotiveOpacity).toBe(0.5)
|
||||
|
||||
const { result: result3 } = renderHook(() =>
|
||||
useTrainTransforms({
|
||||
trainPosition: 97,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
maxCars: 5,
|
||||
carSpacing: 7,
|
||||
})
|
||||
)
|
||||
expect(result3.current.locomotiveOpacity).toBe(0)
|
||||
})
|
||||
|
||||
test('locomotive is fully visible in middle of track', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTrainTransforms({
|
||||
trainPosition: 50,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
maxCars: 5,
|
||||
carSpacing: 7,
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.current.locomotiveOpacity).toBe(1)
|
||||
})
|
||||
|
||||
test('calculates car opacity independently for each car', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTrainTransforms({
|
||||
trainPosition: 10, // Locomotive at 10%, first car at 3% (fading in)
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
maxCars: 2,
|
||||
carSpacing: 7,
|
||||
})
|
||||
)
|
||||
|
||||
// First car at position 3 should be starting to fade in
|
||||
expect(result.current.trainCars[0].position).toBe(3)
|
||||
expect(result.current.trainCars[0].opacity).toBe(0)
|
||||
|
||||
// Second car at position -4 should be invisible (not yet entered)
|
||||
expect(result.current.trainCars[1].position).toBe(0) // clamped to 0
|
||||
expect(result.current.trainCars[1].opacity).toBe(0)
|
||||
})
|
||||
|
||||
test('car positions cannot go below zero', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTrainTransforms({
|
||||
trainPosition: 5,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
maxCars: 3,
|
||||
carSpacing: 7,
|
||||
})
|
||||
)
|
||||
|
||||
// First car at 5 - 7 = -2, should be clamped to 0
|
||||
expect(result.current.trainCars[0].position).toBe(0)
|
||||
// Second car at 5 - 14 = -9, should be clamped to 0
|
||||
expect(result.current.trainCars[1].position).toBe(0)
|
||||
})
|
||||
|
||||
test('cars fade out completely past 97%', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTrainTransforms({
|
||||
trainPosition: 104, // Last car at 104 - 35 = 69% (5 cars * 7 spacing)
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
maxCars: 5,
|
||||
carSpacing: 7,
|
||||
})
|
||||
)
|
||||
|
||||
const lastCar = result.current.trainCars[4]
|
||||
expect(lastCar.position).toBe(69)
|
||||
expect(lastCar.opacity).toBe(1) // Still visible, not past 97%
|
||||
})
|
||||
|
||||
test('memoizes car transforms to avoid recalculation on same inputs', () => {
|
||||
const { result, rerender } = renderHook(() =>
|
||||
useTrainTransforms({
|
||||
trainPosition: 50,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
maxCars: 5,
|
||||
carSpacing: 7,
|
||||
})
|
||||
)
|
||||
|
||||
const firstCars = result.current.trainCars
|
||||
|
||||
// Rerender with same props
|
||||
rerender()
|
||||
|
||||
// Should be the exact same array reference (memoized)
|
||||
expect(result.current.trainCars).toBe(firstCars)
|
||||
})
|
||||
})
|
||||
@@ -1,126 +0,0 @@
|
||||
import { useEffect } from 'react'
|
||||
import { type CommentaryContext, getAICommentary } from '../components/AISystem/aiCommentary'
|
||||
import { useComplementRace } from '../context/ComplementRaceContext'
|
||||
import { useSoundEffects } from './useSoundEffects'
|
||||
|
||||
export function useAIRacers() {
|
||||
const { state, dispatch } = useComplementRace()
|
||||
const { playSound } = useSoundEffects()
|
||||
|
||||
useEffect(() => {
|
||||
if (!state.isGameActive) return
|
||||
|
||||
// Update AI positions every 200ms (line 11690)
|
||||
const aiUpdateInterval = setInterval(() => {
|
||||
const newPositions = state.aiRacers.map((racer) => {
|
||||
// Base speed with random variance (0.6-1.4 range via Math.random() * 0.8 + 0.6)
|
||||
const variance = Math.random() * 0.8 + 0.6
|
||||
let speed = racer.speed * variance * state.speedMultiplier
|
||||
|
||||
// Rubber-banding: AI speeds up 2x when >10 units behind player (line 11697-11699)
|
||||
const distanceBehind = state.correctAnswers - racer.position
|
||||
if (distanceBehind > 10) {
|
||||
speed *= 2
|
||||
}
|
||||
|
||||
// Update position
|
||||
const newPosition = racer.position + speed
|
||||
|
||||
return {
|
||||
id: racer.id,
|
||||
position: newPosition,
|
||||
}
|
||||
})
|
||||
|
||||
dispatch({ type: 'UPDATE_AI_POSITIONS', positions: newPositions })
|
||||
|
||||
// Check for AI win in practice mode (line 14151)
|
||||
if (state.style === 'practice' && state.isGameActive) {
|
||||
const winningAI = state.aiRacers.find((racer, index) => {
|
||||
const updatedPosition = newPositions[index]?.position || racer.position
|
||||
return updatedPosition >= state.raceGoal
|
||||
})
|
||||
|
||||
if (winningAI) {
|
||||
// Play game over sound (line 14193)
|
||||
playSound('gameOver')
|
||||
// End the game
|
||||
dispatch({ type: 'END_RACE' })
|
||||
// Show results after a short delay
|
||||
setTimeout(() => {
|
||||
dispatch({ type: 'SHOW_RESULTS' })
|
||||
}, 1500)
|
||||
return // Exit early to prevent further updates
|
||||
}
|
||||
}
|
||||
|
||||
// Check for commentary triggers after position updates
|
||||
state.aiRacers.forEach((racer) => {
|
||||
const updatedPosition =
|
||||
newPositions.find((p) => p.id === racer.id)?.position || racer.position
|
||||
const distanceBehind = state.correctAnswers - updatedPosition
|
||||
const distanceAhead = updatedPosition - state.correctAnswers
|
||||
|
||||
// Detect passing events
|
||||
const playerJustPassed =
|
||||
racer.previousPosition > state.correctAnswers && updatedPosition < state.correctAnswers
|
||||
const aiJustPassed =
|
||||
racer.previousPosition < state.correctAnswers && updatedPosition > state.correctAnswers
|
||||
|
||||
// Determine commentary context
|
||||
let context: CommentaryContext | null = null
|
||||
|
||||
if (playerJustPassed) {
|
||||
context = 'player_passed'
|
||||
} else if (aiJustPassed) {
|
||||
context = 'ai_passed'
|
||||
} else if (distanceBehind > 20) {
|
||||
// Player has lapped the AI (more than 20 units behind)
|
||||
context = 'lapped'
|
||||
} else if (distanceBehind > 10) {
|
||||
// AI is desperate to catch up (rubber-banding active)
|
||||
context = 'desperate_catchup'
|
||||
} else if (distanceAhead > 5) {
|
||||
// AI is significantly ahead
|
||||
context = 'ahead'
|
||||
} else if (distanceBehind > 3) {
|
||||
// AI is behind
|
||||
context = 'behind'
|
||||
}
|
||||
|
||||
// Trigger commentary if context is valid
|
||||
if (context) {
|
||||
const message = getAICommentary(racer, context, state.correctAnswers, updatedPosition)
|
||||
if (message) {
|
||||
dispatch({
|
||||
type: 'TRIGGER_AI_COMMENTARY',
|
||||
racerId: racer.id,
|
||||
message,
|
||||
context,
|
||||
})
|
||||
|
||||
// Play special turbo sound when AI goes desperate (line 11941)
|
||||
if (context === 'desperate_catchup') {
|
||||
playSound('ai_turbo', 0.12)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}, 200)
|
||||
|
||||
return () => clearInterval(aiUpdateInterval)
|
||||
}, [
|
||||
state.isGameActive,
|
||||
state.aiRacers,
|
||||
state.correctAnswers,
|
||||
state.speedMultiplier,
|
||||
dispatch, // Play game over sound (line 14193)
|
||||
playSound,
|
||||
state.raceGoal,
|
||||
state.style,
|
||||
])
|
||||
|
||||
return {
|
||||
aiRacers: state.aiRacers,
|
||||
}
|
||||
}
|
||||
@@ -1,348 +0,0 @@
|
||||
import { useComplementRace } from '../context/ComplementRaceContext'
|
||||
import type { PairPerformance } from '../lib/gameTypes'
|
||||
|
||||
export function useAdaptiveDifficulty() {
|
||||
const { state, dispatch } = useComplementRace()
|
||||
|
||||
// Track performance after each answer (lines 14495-14553)
|
||||
const trackPerformance = (isCorrect: boolean, responseTime: number) => {
|
||||
if (!state.currentQuestion) return
|
||||
|
||||
const pairKey = `${state.currentQuestion.number}_${state.currentQuestion.correctAnswer}_${state.currentQuestion.targetSum}`
|
||||
|
||||
// Get or create performance data for this pair
|
||||
const pairData: PairPerformance = state.difficultyTracker.pairPerformance.get(pairKey) || {
|
||||
attempts: 0,
|
||||
correct: 0,
|
||||
avgTime: 0,
|
||||
difficulty: 1,
|
||||
}
|
||||
|
||||
// Update performance data
|
||||
pairData.attempts++
|
||||
if (isCorrect) {
|
||||
pairData.correct++
|
||||
}
|
||||
|
||||
// Update average time (rolling average)
|
||||
const totalTime = pairData.avgTime * (pairData.attempts - 1) + responseTime
|
||||
pairData.avgTime = totalTime / pairData.attempts
|
||||
|
||||
// Calculate pair-specific difficulty (lines 14555-14576)
|
||||
if (pairData.attempts >= 2) {
|
||||
const accuracyRate = pairData.correct / pairData.attempts
|
||||
const avgTime = pairData.avgTime
|
||||
|
||||
let difficulty = 1
|
||||
if (accuracyRate >= 0.9 && avgTime < 1500) {
|
||||
difficulty = 1 // Very easy
|
||||
} else if (accuracyRate >= 0.8 && avgTime < 2000) {
|
||||
difficulty = 2 // Easy
|
||||
} else if (accuracyRate >= 0.7 || avgTime < 2500) {
|
||||
difficulty = 3 // Medium
|
||||
} else if (accuracyRate >= 0.5 || avgTime < 3500) {
|
||||
difficulty = 4 // Hard
|
||||
} else {
|
||||
difficulty = 5 // Very hard
|
||||
}
|
||||
|
||||
pairData.difficulty = difficulty
|
||||
}
|
||||
|
||||
// Update difficulty tracker in state
|
||||
const newPairPerformance = new Map(state.difficultyTracker.pairPerformance)
|
||||
newPairPerformance.set(pairKey, pairData)
|
||||
|
||||
// Update consecutive counters
|
||||
const newTracker = {
|
||||
...state.difficultyTracker,
|
||||
pairPerformance: newPairPerformance,
|
||||
consecutiveCorrect: isCorrect ? state.difficultyTracker.consecutiveCorrect + 1 : 0,
|
||||
consecutiveIncorrect: !isCorrect ? state.difficultyTracker.consecutiveIncorrect + 1 : 0,
|
||||
}
|
||||
|
||||
// Adapt global difficulty (lines 14578-14605)
|
||||
if (newTracker.consecutiveCorrect >= 3) {
|
||||
// Reduce time limit (increase difficulty)
|
||||
newTracker.currentTimeLimit = Math.max(
|
||||
1000,
|
||||
newTracker.currentTimeLimit - newTracker.currentTimeLimit * newTracker.adaptationRate
|
||||
)
|
||||
} else if (newTracker.consecutiveIncorrect >= 2) {
|
||||
// Increase time limit (decrease difficulty)
|
||||
newTracker.currentTimeLimit = Math.min(
|
||||
5000,
|
||||
newTracker.currentTimeLimit + newTracker.baseTimeLimit * newTracker.adaptationRate
|
||||
)
|
||||
}
|
||||
|
||||
// Update overall difficulty level
|
||||
const avgDifficulty =
|
||||
Array.from(newTracker.pairPerformance.values()).reduce(
|
||||
(sum, data) => sum + data.difficulty,
|
||||
0
|
||||
) / Math.max(1, newTracker.pairPerformance.size)
|
||||
|
||||
newTracker.difficultyLevel = Math.round(avgDifficulty)
|
||||
|
||||
// Exit learning mode after sufficient data (lines 14548-14552)
|
||||
if (
|
||||
newTracker.pairPerformance.size >= 5 &&
|
||||
Array.from(newTracker.pairPerformance.values()).some((data) => data.attempts >= 3)
|
||||
) {
|
||||
newTracker.learningMode = false
|
||||
}
|
||||
|
||||
// Dispatch update
|
||||
dispatch({ type: 'UPDATE_DIFFICULTY_TRACKER', tracker: newTracker })
|
||||
|
||||
// Adapt AI speeds based on player performance
|
||||
adaptAISpeeds(newTracker)
|
||||
}
|
||||
|
||||
// Calculate recent success rate (lines 14685-14693)
|
||||
const calculateRecentSuccessRate = (): number => {
|
||||
const recentQuestions = Math.min(10, state.totalQuestions)
|
||||
if (recentQuestions === 0) return 0.5 // Default for first question
|
||||
|
||||
// Use global tracking for recent performance
|
||||
const recentCorrect = Math.max(
|
||||
0,
|
||||
state.correctAnswers - Math.max(0, state.totalQuestions - recentQuestions)
|
||||
)
|
||||
return recentCorrect / recentQuestions
|
||||
}
|
||||
|
||||
// Calculate average response time (lines 14695-14705)
|
||||
const calculateAverageResponseTime = (): number => {
|
||||
const recentPairs = Array.from(state.difficultyTracker.pairPerformance.values())
|
||||
.filter((data) => data.attempts >= 1)
|
||||
.slice(-5) // Last 5 different pairs encountered
|
||||
|
||||
if (recentPairs.length === 0) return 3000 // Default for learning mode
|
||||
|
||||
const totalTime = recentPairs.reduce((sum, data) => sum + data.avgTime, 0)
|
||||
return totalTime / recentPairs.length
|
||||
}
|
||||
|
||||
// Adapt AI speeds based on performance (lines 14607-14683)
|
||||
const adaptAISpeeds = (tracker: typeof state.difficultyTracker) => {
|
||||
// Don't adapt during learning mode
|
||||
if (tracker.learningMode) return
|
||||
|
||||
const playerSuccessRate = calculateRecentSuccessRate()
|
||||
const avgResponseTime = calculateAverageResponseTime()
|
||||
|
||||
// Base speed multipliers for each race mode
|
||||
let baseSpeedMultiplier: number
|
||||
switch (state.style) {
|
||||
case 'practice':
|
||||
baseSpeedMultiplier = 0.7
|
||||
break
|
||||
case 'sprint':
|
||||
baseSpeedMultiplier = 0.9
|
||||
break
|
||||
case 'survival':
|
||||
baseSpeedMultiplier = state.speedMultiplier * state.survivalMultiplier
|
||||
break
|
||||
default:
|
||||
baseSpeedMultiplier = 0.7
|
||||
}
|
||||
|
||||
// Calculate adaptive multiplier based on player performance
|
||||
let adaptiveMultiplier = 1.0
|
||||
|
||||
// Success rate factor (0.5x to 1.6x based on success rate)
|
||||
if (playerSuccessRate > 0.85) {
|
||||
adaptiveMultiplier *= 1.6 // Player doing great - speed up AI significantly
|
||||
} else if (playerSuccessRate > 0.75) {
|
||||
adaptiveMultiplier *= 1.3 // Player doing well - speed up AI moderately
|
||||
} else if (playerSuccessRate > 0.6) {
|
||||
adaptiveMultiplier *= 1.0 // Player doing okay - keep AI at base speed
|
||||
} else if (playerSuccessRate > 0.45) {
|
||||
adaptiveMultiplier *= 0.75 // Player struggling - slow down AI
|
||||
} else {
|
||||
adaptiveMultiplier *= 0.5 // Player really struggling - significantly slow AI
|
||||
}
|
||||
|
||||
// Response time factor - faster players get faster AI
|
||||
if (avgResponseTime < 1500) {
|
||||
adaptiveMultiplier *= 1.2 // Very fast player
|
||||
} else if (avgResponseTime < 2500) {
|
||||
adaptiveMultiplier *= 1.1 // Fast player
|
||||
} else if (avgResponseTime > 4000) {
|
||||
adaptiveMultiplier *= 0.9 // Slow player
|
||||
}
|
||||
|
||||
// Streak bonus - players on hot streaks get more challenge
|
||||
if (state.streak >= 8) {
|
||||
adaptiveMultiplier *= 1.3
|
||||
} else if (state.streak >= 5) {
|
||||
adaptiveMultiplier *= 1.15
|
||||
}
|
||||
|
||||
// Apply bounds to prevent extreme values
|
||||
adaptiveMultiplier = Math.max(0.3, Math.min(2.0, adaptiveMultiplier))
|
||||
|
||||
// Update AI speeds with adaptive multiplier
|
||||
const finalSpeedMultiplier = baseSpeedMultiplier * adaptiveMultiplier
|
||||
|
||||
// Update AI racer speeds
|
||||
const updatedRacers = state.aiRacers.map((racer, index) => {
|
||||
if (index === 0) {
|
||||
// Swift AI (more aggressive)
|
||||
return { ...racer, speed: 0.32 * finalSpeedMultiplier }
|
||||
} else {
|
||||
// Math Bot (more consistent)
|
||||
return { ...racer, speed: 0.2 * finalSpeedMultiplier }
|
||||
}
|
||||
})
|
||||
|
||||
dispatch({ type: 'UPDATE_AI_SPEEDS', racers: updatedRacers })
|
||||
|
||||
// Debug logging for AI adaptation (every 5 questions)
|
||||
if (state.totalQuestions % 5 === 0) {
|
||||
console.log('🤖 AI Speed Adaptation:', {
|
||||
playerSuccessRate: `${Math.round(playerSuccessRate * 100)}%`,
|
||||
avgResponseTime: `${Math.round(avgResponseTime)}ms`,
|
||||
streak: state.streak,
|
||||
adaptiveMultiplier: Math.round(adaptiveMultiplier * 100) / 100,
|
||||
swiftAISpeed: updatedRacers[0] ? Math.round(updatedRacers[0].speed * 1000) / 1000 : 0,
|
||||
mathBotSpeed: updatedRacers[1] ? Math.round(updatedRacers[1].speed * 1000) / 1000 : 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Get adaptive time limit for current question (lines 14740-14763)
|
||||
const getAdaptiveTimeLimit = (): number => {
|
||||
if (!state.currentQuestion) return 3000
|
||||
|
||||
let adaptiveTime: number
|
||||
|
||||
if (state.difficultyTracker.learningMode) {
|
||||
adaptiveTime = Math.max(2000, state.difficultyTracker.currentTimeLimit)
|
||||
} else {
|
||||
const pairKey = `${state.currentQuestion.number}_${state.currentQuestion.correctAnswer}_${state.currentQuestion.targetSum}`
|
||||
const pairData = state.difficultyTracker.pairPerformance.get(pairKey)
|
||||
|
||||
if (pairData && pairData.attempts >= 2) {
|
||||
// Use pair-specific difficulty
|
||||
const baseTime = state.difficultyTracker.baseTimeLimit
|
||||
const difficultyMultiplier = (6 - pairData.difficulty) / 5 // Invert: difficulty 1 = more time
|
||||
adaptiveTime = Math.max(1000, baseTime * difficultyMultiplier)
|
||||
} else {
|
||||
// Default for new pairs
|
||||
adaptiveTime = state.difficultyTracker.currentTimeLimit
|
||||
}
|
||||
}
|
||||
|
||||
// Apply user timeout setting override (lines 14765-14785)
|
||||
return applyTimeoutSetting(adaptiveTime)
|
||||
}
|
||||
|
||||
// Apply timeout setting multiplier (lines 14765-14785)
|
||||
const applyTimeoutSetting = (baseTime: number): number => {
|
||||
switch (state.timeoutSetting) {
|
||||
case 'preschool':
|
||||
return Math.max(baseTime * 4, 20000) // At least 20 seconds
|
||||
case 'kindergarten':
|
||||
return Math.max(baseTime * 3, 15000) // At least 15 seconds
|
||||
case 'relaxed':
|
||||
return Math.max(baseTime * 2.4, 12000) // At least 12 seconds
|
||||
case 'slow':
|
||||
return Math.max(baseTime * 1.6, 8000) // At least 8 seconds
|
||||
case 'normal':
|
||||
return Math.max(baseTime, 5000) // At least 5 seconds
|
||||
case 'fast':
|
||||
return Math.max(baseTime * 0.6, 3000) // At least 3 seconds
|
||||
case 'expert':
|
||||
return Math.max(baseTime * 0.4, 2000) // At least 2 seconds
|
||||
default:
|
||||
return baseTime
|
||||
}
|
||||
}
|
||||
|
||||
// Get adaptive feedback message (lines 11655-11721)
|
||||
const getAdaptiveFeedbackMessage = (
|
||||
pairKey: string,
|
||||
_isCorrect: boolean,
|
||||
_responseTime: number
|
||||
): {
|
||||
message: string
|
||||
type: 'learning' | 'struggling' | 'mastered' | 'adapted'
|
||||
} | null => {
|
||||
const pairData = state.difficultyTracker.pairPerformance.get(pairKey)
|
||||
const [num1, num2, _sum] = pairKey.split('_').map(Number)
|
||||
|
||||
// Learning mode messages
|
||||
if (state.difficultyTracker.learningMode) {
|
||||
const encouragements = [
|
||||
"🧠 I'm learning your style! Keep going!",
|
||||
'📊 Building your skill profile...',
|
||||
'🎯 Every answer helps me understand you better!',
|
||||
'🚀 Analyzing your complement superpowers!',
|
||||
]
|
||||
return {
|
||||
message: encouragements[Math.floor(Math.random() * encouragements.length)],
|
||||
type: 'learning',
|
||||
}
|
||||
}
|
||||
|
||||
// After learning - provide specific feedback
|
||||
if (pairData && pairData.attempts >= 3) {
|
||||
const accuracy = pairData.correct / pairData.attempts
|
||||
const avgTime = pairData.avgTime
|
||||
|
||||
// Struggling pairs (< 60% accuracy)
|
||||
if (accuracy < 0.6) {
|
||||
const strugglingMessages = [
|
||||
`💪 ${num1}+${num2} needs practice - I'm giving you extra time!`,
|
||||
`🎯 Working on ${num1}+${num2} - you've got this!`,
|
||||
`⏰ Taking it slower with ${num1}+${num2} - no rush!`,
|
||||
`🧩 ${num1}+${num2} is getting special attention from me!`,
|
||||
]
|
||||
return {
|
||||
message: strugglingMessages[Math.floor(Math.random() * strugglingMessages.length)],
|
||||
type: 'struggling',
|
||||
}
|
||||
}
|
||||
|
||||
// Mastered pairs (> 85% accuracy and fast)
|
||||
if (accuracy > 0.85 && avgTime < 2000) {
|
||||
const masteredMessages = [
|
||||
`⚡ ${num1}+${num2} = MASTERED! Lightning mode activated!`,
|
||||
`🔥 You've conquered ${num1}+${num2} - speeding it up!`,
|
||||
`🏆 ${num1}+${num2} expert detected! Challenge mode ON!`,
|
||||
`⭐ ${num1}+${num2} is your superpower! Going faster!`,
|
||||
]
|
||||
return {
|
||||
message: masteredMessages[Math.floor(Math.random() * masteredMessages.length)],
|
||||
type: 'mastered',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show adaptation when difficulty changes
|
||||
if (state.difficultyTracker.consecutiveCorrect >= 3) {
|
||||
return {
|
||||
message: "🚀 You're on fire! Increasing the challenge!",
|
||||
type: 'adapted',
|
||||
}
|
||||
} else if (state.difficultyTracker.consecutiveIncorrect >= 2) {
|
||||
return {
|
||||
message: "🤗 Let's slow down a bit - I'm here to help!",
|
||||
type: 'adapted',
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
trackPerformance,
|
||||
getAdaptiveTimeLimit,
|
||||
calculateRecentSuccessRate,
|
||||
calculateAverageResponseTime,
|
||||
getAdaptiveFeedbackMessage,
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useComplementRace } from '../context/ComplementRaceContext'
|
||||
|
||||
export function useGameLoop() {
|
||||
const { state, dispatch } = useComplementRace()
|
||||
|
||||
// Generate first question when game begins
|
||||
useEffect(() => {
|
||||
if (state.gamePhase === 'playing' && !state.currentQuestion) {
|
||||
dispatch({ type: 'NEXT_QUESTION' })
|
||||
}
|
||||
}, [state.gamePhase, state.currentQuestion, dispatch])
|
||||
|
||||
const nextQuestion = useCallback(() => {
|
||||
if (!state.isGameActive) return
|
||||
dispatch({ type: 'NEXT_QUESTION' })
|
||||
}, [state.isGameActive, dispatch])
|
||||
|
||||
const submitAnswer = useCallback(
|
||||
(answer: number) => {
|
||||
if (!state.currentQuestion) return
|
||||
|
||||
const isCorrect = answer === state.currentQuestion.correctAnswer
|
||||
|
||||
if (isCorrect) {
|
||||
// Update score, streak, progress
|
||||
// TODO: Will implement full scoring in next step
|
||||
dispatch({ type: 'SUBMIT_ANSWER', answer })
|
||||
|
||||
// Move to next question
|
||||
dispatch({ type: 'NEXT_QUESTION' })
|
||||
} else {
|
||||
// Reset streak
|
||||
// TODO: Will implement incorrect answer handling
|
||||
dispatch({ type: 'SUBMIT_ANSWER', answer })
|
||||
}
|
||||
},
|
||||
[state.currentQuestion, dispatch]
|
||||
)
|
||||
|
||||
const startCountdown = useCallback(() => {
|
||||
// Trigger countdown phase
|
||||
dispatch({ type: 'START_COUNTDOWN' })
|
||||
|
||||
// Start 3-2-1-GO countdown (lines 11163-11211)
|
||||
let count = 3
|
||||
const countdownInterval = setInterval(() => {
|
||||
if (count > 0) {
|
||||
// TODO: Play countdown sound
|
||||
count--
|
||||
} else {
|
||||
// GO!
|
||||
// TODO: Play start sound
|
||||
clearInterval(countdownInterval)
|
||||
|
||||
// Start the actual game after GO animation (1 second delay)
|
||||
setTimeout(() => {
|
||||
dispatch({ type: 'BEGIN_GAME' })
|
||||
}, 1000)
|
||||
}
|
||||
}, 1000)
|
||||
}, [dispatch])
|
||||
|
||||
return {
|
||||
nextQuestion,
|
||||
submitAnswer,
|
||||
startCountdown,
|
||||
}
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { Passenger, Station } from '../lib/gameTypes'
|
||||
import type { RailroadTrackGenerator } from '../lib/RailroadTrackGenerator'
|
||||
|
||||
export interface BoardingAnimation {
|
||||
passenger: Passenger
|
||||
fromX: number
|
||||
fromY: number
|
||||
toX: number
|
||||
toY: number
|
||||
carIndex: number
|
||||
startTime: number
|
||||
}
|
||||
|
||||
export interface DisembarkingAnimation {
|
||||
passenger: Passenger
|
||||
fromX: number
|
||||
fromY: number
|
||||
toX: number
|
||||
toY: number
|
||||
startTime: number
|
||||
}
|
||||
|
||||
interface UsePassengerAnimationsParams {
|
||||
passengers: Passenger[]
|
||||
stations: Station[]
|
||||
stationPositions: Array<{ x: number; y: number }>
|
||||
trainPosition: number
|
||||
trackGenerator: RailroadTrackGenerator
|
||||
pathRef: React.RefObject<SVGPathElement>
|
||||
}
|
||||
|
||||
export function usePassengerAnimations({
|
||||
passengers,
|
||||
stations,
|
||||
stationPositions,
|
||||
trainPosition,
|
||||
trackGenerator,
|
||||
pathRef,
|
||||
}: UsePassengerAnimationsParams) {
|
||||
const [boardingAnimations, setBoardingAnimations] = useState<Map<string, BoardingAnimation>>(
|
||||
new Map()
|
||||
)
|
||||
const [disembarkingAnimations, setDisembarkingAnimations] = useState<
|
||||
Map<string, DisembarkingAnimation>
|
||||
>(new Map())
|
||||
const previousPassengersRef = useRef<Passenger[]>(passengers)
|
||||
|
||||
// Detect passengers boarding/disembarking and start animations
|
||||
useEffect(() => {
|
||||
if (!pathRef.current || stationPositions.length === 0) return
|
||||
|
||||
const previousPassengers = previousPassengersRef.current
|
||||
const currentPassengers = passengers
|
||||
|
||||
// Find newly boarded passengers
|
||||
const newlyBoarded = currentPassengers.filter((curr) => {
|
||||
const prev = previousPassengers.find((p) => p.id === curr.id)
|
||||
return curr.isBoarded && prev && !prev.isBoarded
|
||||
})
|
||||
|
||||
// Find newly delivered passengers
|
||||
const newlyDelivered = currentPassengers.filter((curr) => {
|
||||
const prev = previousPassengers.find((p) => p.id === curr.id)
|
||||
return curr.isDelivered && prev && !prev.isDelivered
|
||||
})
|
||||
|
||||
// Start animation for each newly boarded passenger
|
||||
newlyBoarded.forEach((passenger) => {
|
||||
// Find origin station
|
||||
const originStation = stations.find((s) => s.id === passenger.originStationId)
|
||||
if (!originStation) return
|
||||
|
||||
const stationIndex = stations.indexOf(originStation)
|
||||
const stationPos = stationPositions[stationIndex]
|
||||
if (!stationPos) return
|
||||
|
||||
// Find which car this passenger will be in
|
||||
const boardedPassengers = currentPassengers.filter((p) => p.isBoarded && !p.isDelivered)
|
||||
const carIndex = boardedPassengers.indexOf(passenger)
|
||||
|
||||
// Calculate train car position
|
||||
const carPosition = Math.max(0, trainPosition - (carIndex + 1) * 7) // 7% spacing
|
||||
const carTransform = trackGenerator.getTrainTransform(pathRef.current!, carPosition)
|
||||
|
||||
// Create boarding animation
|
||||
const animation: BoardingAnimation = {
|
||||
passenger,
|
||||
fromX: stationPos.x,
|
||||
fromY: stationPos.y - 30,
|
||||
toX: carTransform.x,
|
||||
toY: carTransform.y,
|
||||
carIndex,
|
||||
startTime: Date.now(),
|
||||
}
|
||||
|
||||
setBoardingAnimations((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(passenger.id, animation)
|
||||
return next
|
||||
})
|
||||
|
||||
// Remove animation after 800ms
|
||||
setTimeout(() => {
|
||||
setBoardingAnimations((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.delete(passenger.id)
|
||||
return next
|
||||
})
|
||||
}, 800)
|
||||
})
|
||||
|
||||
// Start animation for each newly delivered passenger
|
||||
newlyDelivered.forEach((passenger) => {
|
||||
// Find destination station
|
||||
const destinationStation = stations.find((s) => s.id === passenger.destinationStationId)
|
||||
if (!destinationStation) return
|
||||
|
||||
const stationIndex = stations.indexOf(destinationStation)
|
||||
const stationPos = stationPositions[stationIndex]
|
||||
if (!stationPos) return
|
||||
|
||||
// Find which car this passenger was in (before delivery)
|
||||
const prevBoardedPassengers = previousPassengers.filter((p) => p.isBoarded && !p.isDelivered)
|
||||
const carIndex = prevBoardedPassengers.findIndex((p) => p.id === passenger.id)
|
||||
if (carIndex === -1) return
|
||||
|
||||
// Calculate train car position at time of disembarking
|
||||
const carPosition = Math.max(0, trainPosition - (carIndex + 1) * 7) // 7% spacing
|
||||
const carTransform = trackGenerator.getTrainTransform(pathRef.current!, carPosition)
|
||||
|
||||
// Create disembarking animation (from car to station)
|
||||
const animation: DisembarkingAnimation = {
|
||||
passenger,
|
||||
fromX: carTransform.x,
|
||||
fromY: carTransform.y,
|
||||
toX: stationPos.x,
|
||||
toY: stationPos.y - 30,
|
||||
startTime: Date.now(),
|
||||
}
|
||||
|
||||
setDisembarkingAnimations((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(passenger.id, animation)
|
||||
return next
|
||||
})
|
||||
|
||||
// Remove animation after 800ms
|
||||
setTimeout(() => {
|
||||
setDisembarkingAnimations((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.delete(passenger.id)
|
||||
return next
|
||||
})
|
||||
}, 800)
|
||||
})
|
||||
|
||||
// Update ref
|
||||
previousPassengersRef.current = currentPassengers
|
||||
}, [passengers, stations, stationPositions, trainPosition, trackGenerator, pathRef])
|
||||
|
||||
return {
|
||||
boardingAnimations,
|
||||
disembarkingAnimations,
|
||||
}
|
||||
}
|
||||
@@ -1,468 +0,0 @@
|
||||
import { useCallback, useRef } from 'react'
|
||||
|
||||
/**
|
||||
* Web Audio API sound effects system
|
||||
* Generates retro 90s-style arcade sounds programmatically
|
||||
*
|
||||
* Based on original implementation from web_generator.py lines 14218-14490
|
||||
*/
|
||||
|
||||
interface Note {
|
||||
freq: number
|
||||
time: number
|
||||
duration: number
|
||||
}
|
||||
|
||||
export function useSoundEffects() {
|
||||
const audioContextsRef = useRef<AudioContext[]>([])
|
||||
|
||||
/**
|
||||
* Helper function to play multi-note 90s arcade sounds
|
||||
*/
|
||||
const play90sSound = useCallback(
|
||||
(
|
||||
audioContext: AudioContext,
|
||||
notes: Note[],
|
||||
volume: number = 0.15,
|
||||
waveType: OscillatorType = 'sine'
|
||||
) => {
|
||||
notes.forEach((note) => {
|
||||
const oscillator = audioContext.createOscillator()
|
||||
const gainNode = audioContext.createGain()
|
||||
const filterNode = audioContext.createBiquadFilter()
|
||||
|
||||
// Create that classic 90s arcade sound chain
|
||||
oscillator.connect(filterNode)
|
||||
filterNode.connect(gainNode)
|
||||
gainNode.connect(audioContext.destination)
|
||||
|
||||
// Set wave type for that retro flavor
|
||||
oscillator.type = waveType
|
||||
|
||||
// Add some 90s-style filtering
|
||||
filterNode.type = 'lowpass'
|
||||
filterNode.frequency.setValueAtTime(2000, audioContext.currentTime + note.time)
|
||||
filterNode.Q.setValueAtTime(1, audioContext.currentTime + note.time)
|
||||
|
||||
// Set frequency and add vibrato for that classic arcade wobble
|
||||
oscillator.frequency.setValueAtTime(note.freq, audioContext.currentTime + note.time)
|
||||
if (waveType === 'sawtooth' || waveType === 'square') {
|
||||
// Add slight vibrato for extra 90s flavor
|
||||
oscillator.frequency.exponentialRampToValueAtTime(
|
||||
note.freq * 1.02,
|
||||
audioContext.currentTime + note.time + note.duration * 0.5
|
||||
)
|
||||
oscillator.frequency.exponentialRampToValueAtTime(
|
||||
note.freq,
|
||||
audioContext.currentTime + note.time + note.duration
|
||||
)
|
||||
}
|
||||
|
||||
// Classic arcade envelope - quick attack, moderate decay
|
||||
gainNode.gain.setValueAtTime(0, audioContext.currentTime + note.time)
|
||||
gainNode.gain.exponentialRampToValueAtTime(
|
||||
volume,
|
||||
audioContext.currentTime + note.time + 0.01
|
||||
)
|
||||
gainNode.gain.exponentialRampToValueAtTime(
|
||||
volume * 0.7,
|
||||
audioContext.currentTime + note.time + note.duration * 0.7
|
||||
)
|
||||
gainNode.gain.exponentialRampToValueAtTime(
|
||||
0.001,
|
||||
audioContext.currentTime + note.time + note.duration
|
||||
)
|
||||
|
||||
oscillator.start(audioContext.currentTime + note.time)
|
||||
oscillator.stop(audioContext.currentTime + note.time + note.duration)
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
/**
|
||||
* Play a sound effect
|
||||
* @param type - Sound type (correct, incorrect, countdown, etc.)
|
||||
* @param volume - Volume level (0-1), default 0.15
|
||||
*/
|
||||
const playSound = useCallback(
|
||||
(
|
||||
type:
|
||||
| 'correct'
|
||||
| 'incorrect'
|
||||
| 'timeout'
|
||||
| 'countdown'
|
||||
| 'race_start'
|
||||
| 'celebration'
|
||||
| 'lap_celebration'
|
||||
| 'gameOver'
|
||||
| 'ai_turbo'
|
||||
| 'milestone'
|
||||
| 'streak'
|
||||
| 'combo'
|
||||
| 'whoosh'
|
||||
| 'train_chuff'
|
||||
| 'train_whistle'
|
||||
| 'coal_spill'
|
||||
| 'steam_hiss',
|
||||
volume: number = 0.15
|
||||
) => {
|
||||
try {
|
||||
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()
|
||||
|
||||
// Track audio contexts for cleanup
|
||||
audioContextsRef.current.push(audioContext)
|
||||
|
||||
switch (type) {
|
||||
case 'correct':
|
||||
// Classic 90s "power-up" sound - ascending beeps
|
||||
play90sSound(
|
||||
audioContext,
|
||||
[
|
||||
{ freq: 523, time: 0, duration: 0.08 }, // C5
|
||||
{ freq: 659, time: 0.08, duration: 0.08 }, // E5
|
||||
{ freq: 784, time: 0.16, duration: 0.12 }, // G5
|
||||
],
|
||||
volume,
|
||||
'sawtooth'
|
||||
)
|
||||
break
|
||||
|
||||
case 'incorrect':
|
||||
// Classic arcade "error" sound - descending buzz
|
||||
play90sSound(
|
||||
audioContext,
|
||||
[
|
||||
{ freq: 400, time: 0, duration: 0.15 },
|
||||
{ freq: 300, time: 0.05, duration: 0.15 },
|
||||
{ freq: 200, time: 0.1, duration: 0.2 },
|
||||
],
|
||||
volume * 0.8,
|
||||
'square'
|
||||
)
|
||||
break
|
||||
|
||||
case 'timeout':
|
||||
// Classic "time's up" alarm
|
||||
play90sSound(
|
||||
audioContext,
|
||||
[
|
||||
{ freq: 800, time: 0, duration: 0.1 },
|
||||
{ freq: 600, time: 0.1, duration: 0.1 },
|
||||
{ freq: 800, time: 0.2, duration: 0.1 },
|
||||
{ freq: 600, time: 0.3, duration: 0.15 },
|
||||
],
|
||||
volume,
|
||||
'square'
|
||||
)
|
||||
break
|
||||
|
||||
case 'countdown':
|
||||
// Classic arcade countdown beep
|
||||
play90sSound(
|
||||
audioContext,
|
||||
[{ freq: 800, time: 0, duration: 0.15 }],
|
||||
volume * 0.6,
|
||||
'sine'
|
||||
)
|
||||
break
|
||||
|
||||
case 'race_start':
|
||||
// Epic race start fanfare
|
||||
play90sSound(
|
||||
audioContext,
|
||||
[
|
||||
{ freq: 523, time: 0, duration: 0.1 }, // C5
|
||||
{ freq: 659, time: 0.1, duration: 0.1 }, // E5
|
||||
{ freq: 784, time: 0.2, duration: 0.1 }, // G5
|
||||
{ freq: 1046, time: 0.3, duration: 0.3 }, // C6 - triumphant!
|
||||
],
|
||||
volume * 1.2,
|
||||
'sawtooth'
|
||||
)
|
||||
break
|
||||
|
||||
case 'celebration':
|
||||
// Classic victory fanfare - like completing a level
|
||||
play90sSound(
|
||||
audioContext,
|
||||
[
|
||||
{ freq: 523, time: 0, duration: 0.12 }, // C5
|
||||
{ freq: 659, time: 0.12, duration: 0.12 }, // E5
|
||||
{ freq: 784, time: 0.24, duration: 0.12 }, // G5
|
||||
{ freq: 1046, time: 0.36, duration: 0.24 }, // C6
|
||||
{ freq: 1318, time: 0.6, duration: 0.3 }, // E6 - epic finish!
|
||||
],
|
||||
volume * 1.5,
|
||||
'sawtooth'
|
||||
)
|
||||
break
|
||||
|
||||
case 'lap_celebration':
|
||||
// Radical "bonus achieved" sound
|
||||
play90sSound(
|
||||
audioContext,
|
||||
[
|
||||
{ freq: 1046, time: 0, duration: 0.08 }, // C6
|
||||
{ freq: 1318, time: 0.08, duration: 0.08 }, // E6
|
||||
{ freq: 1568, time: 0.16, duration: 0.08 }, // G6
|
||||
{ freq: 2093, time: 0.24, duration: 0.15 }, // C7 - totally rad!
|
||||
],
|
||||
volume * 1.3,
|
||||
'sawtooth'
|
||||
)
|
||||
break
|
||||
|
||||
case 'gameOver':
|
||||
// Classic "game over" descending tones
|
||||
play90sSound(
|
||||
audioContext,
|
||||
[
|
||||
{ freq: 400, time: 0, duration: 0.2 },
|
||||
{ freq: 350, time: 0.2, duration: 0.2 },
|
||||
{ freq: 300, time: 0.4, duration: 0.2 },
|
||||
{ freq: 250, time: 0.6, duration: 0.3 },
|
||||
{ freq: 200, time: 0.9, duration: 0.4 },
|
||||
],
|
||||
volume,
|
||||
'triangle'
|
||||
)
|
||||
break
|
||||
|
||||
case 'ai_turbo':
|
||||
// Sound when AI goes into turbo mode
|
||||
play90sSound(
|
||||
audioContext,
|
||||
[
|
||||
{ freq: 200, time: 0, duration: 0.05 },
|
||||
{ freq: 400, time: 0.05, duration: 0.05 },
|
||||
{ freq: 600, time: 0.1, duration: 0.05 },
|
||||
{ freq: 800, time: 0.15, duration: 0.1 },
|
||||
],
|
||||
volume * 0.7,
|
||||
'sawtooth'
|
||||
)
|
||||
break
|
||||
|
||||
case 'milestone':
|
||||
// Rad milestone sound - like collecting a power-up
|
||||
play90sSound(
|
||||
audioContext,
|
||||
[
|
||||
{ freq: 659, time: 0, duration: 0.1 }, // E5
|
||||
{ freq: 784, time: 0.1, duration: 0.1 }, // G5
|
||||
{ freq: 880, time: 0.2, duration: 0.1 }, // A5
|
||||
{ freq: 1046, time: 0.3, duration: 0.15 }, // C6 - awesome!
|
||||
],
|
||||
volume * 1.1,
|
||||
'sawtooth'
|
||||
)
|
||||
break
|
||||
|
||||
case 'streak':
|
||||
// Epic streak sound - getting hot!
|
||||
play90sSound(
|
||||
audioContext,
|
||||
[
|
||||
{ freq: 880, time: 0, duration: 0.06 }, // A5
|
||||
{ freq: 1046, time: 0.06, duration: 0.06 }, // C6
|
||||
{ freq: 1318, time: 0.12, duration: 0.08 }, // E6
|
||||
{ freq: 1760, time: 0.2, duration: 0.1 }, // A6 - on fire!
|
||||
],
|
||||
volume * 1.2,
|
||||
'sawtooth'
|
||||
)
|
||||
break
|
||||
|
||||
case 'combo':
|
||||
// Gnarly combo sound - for rapid correct answers
|
||||
play90sSound(
|
||||
audioContext,
|
||||
[
|
||||
{ freq: 1046, time: 0, duration: 0.04 }, // C6
|
||||
{ freq: 1175, time: 0.04, duration: 0.04 }, // D6
|
||||
{ freq: 1318, time: 0.08, duration: 0.04 }, // E6
|
||||
{ freq: 1480, time: 0.12, duration: 0.06 }, // F#6
|
||||
],
|
||||
volume * 0.9,
|
||||
'square'
|
||||
)
|
||||
break
|
||||
|
||||
case 'whoosh': {
|
||||
// Cool whoosh sound for fast responses
|
||||
const whooshOsc = audioContext.createOscillator()
|
||||
const whooshGain = audioContext.createGain()
|
||||
const whooshFilter = audioContext.createBiquadFilter()
|
||||
|
||||
whooshOsc.connect(whooshFilter)
|
||||
whooshFilter.connect(whooshGain)
|
||||
whooshGain.connect(audioContext.destination)
|
||||
|
||||
whooshOsc.type = 'sawtooth'
|
||||
whooshFilter.type = 'highpass'
|
||||
whooshFilter.frequency.setValueAtTime(1000, audioContext.currentTime)
|
||||
whooshFilter.frequency.exponentialRampToValueAtTime(100, audioContext.currentTime + 0.3)
|
||||
|
||||
whooshOsc.frequency.setValueAtTime(400, audioContext.currentTime)
|
||||
whooshOsc.frequency.exponentialRampToValueAtTime(800, audioContext.currentTime + 0.15)
|
||||
whooshOsc.frequency.exponentialRampToValueAtTime(200, audioContext.currentTime + 0.3)
|
||||
|
||||
whooshGain.gain.setValueAtTime(0, audioContext.currentTime)
|
||||
whooshGain.gain.exponentialRampToValueAtTime(
|
||||
volume * 0.6,
|
||||
audioContext.currentTime + 0.02
|
||||
)
|
||||
whooshGain.gain.exponentialRampToValueAtTime(0.001, audioContext.currentTime + 0.3)
|
||||
|
||||
whooshOsc.start(audioContext.currentTime)
|
||||
whooshOsc.stop(audioContext.currentTime + 0.3)
|
||||
break
|
||||
}
|
||||
|
||||
case 'train_chuff': {
|
||||
// Realistic steam train chuffing sound
|
||||
const chuffOsc = audioContext.createOscillator()
|
||||
const chuffGain = audioContext.createGain()
|
||||
const chuffFilter = audioContext.createBiquadFilter()
|
||||
|
||||
chuffOsc.connect(chuffFilter)
|
||||
chuffFilter.connect(chuffGain)
|
||||
chuffGain.connect(audioContext.destination)
|
||||
|
||||
chuffOsc.type = 'sawtooth'
|
||||
chuffFilter.type = 'bandpass'
|
||||
chuffFilter.frequency.setValueAtTime(150, audioContext.currentTime)
|
||||
chuffFilter.Q.setValueAtTime(5, audioContext.currentTime)
|
||||
|
||||
chuffOsc.frequency.setValueAtTime(80, audioContext.currentTime)
|
||||
chuffOsc.frequency.exponentialRampToValueAtTime(120, audioContext.currentTime + 0.05)
|
||||
chuffOsc.frequency.exponentialRampToValueAtTime(60, audioContext.currentTime + 0.2)
|
||||
|
||||
chuffGain.gain.setValueAtTime(0, audioContext.currentTime)
|
||||
chuffGain.gain.exponentialRampToValueAtTime(
|
||||
volume * 0.8,
|
||||
audioContext.currentTime + 0.01
|
||||
)
|
||||
chuffGain.gain.exponentialRampToValueAtTime(0.001, audioContext.currentTime + 0.2)
|
||||
|
||||
chuffOsc.start(audioContext.currentTime)
|
||||
chuffOsc.stop(audioContext.currentTime + 0.2)
|
||||
break
|
||||
}
|
||||
|
||||
case 'train_whistle':
|
||||
// Classic steam train whistle
|
||||
play90sSound(
|
||||
audioContext,
|
||||
[
|
||||
{ freq: 523, time: 0, duration: 0.3 }, // C5 - long whistle
|
||||
{ freq: 659, time: 0.1, duration: 0.4 }, // E5 - harmony
|
||||
{ freq: 523, time: 0.3, duration: 0.2 }, // C5 - fade out
|
||||
],
|
||||
volume * 1.2,
|
||||
'sine'
|
||||
)
|
||||
break
|
||||
|
||||
case 'coal_spill': {
|
||||
// Coal chunks spilling sound effect
|
||||
const coalOsc = audioContext.createOscillator()
|
||||
const coalGain = audioContext.createGain()
|
||||
const coalFilter = audioContext.createBiquadFilter()
|
||||
|
||||
coalOsc.connect(coalFilter)
|
||||
coalFilter.connect(coalGain)
|
||||
coalGain.connect(audioContext.destination)
|
||||
|
||||
coalOsc.type = 'square'
|
||||
coalFilter.type = 'lowpass'
|
||||
coalFilter.frequency.setValueAtTime(300, audioContext.currentTime)
|
||||
|
||||
// Simulate coal chunks falling with random frequency bursts
|
||||
coalOsc.frequency.setValueAtTime(200 + Math.random() * 100, audioContext.currentTime)
|
||||
coalOsc.frequency.exponentialRampToValueAtTime(
|
||||
100 + Math.random() * 50,
|
||||
audioContext.currentTime + 0.1
|
||||
)
|
||||
coalOsc.frequency.exponentialRampToValueAtTime(
|
||||
80 + Math.random() * 40,
|
||||
audioContext.currentTime + 0.3
|
||||
)
|
||||
|
||||
coalGain.gain.setValueAtTime(0, audioContext.currentTime)
|
||||
coalGain.gain.exponentialRampToValueAtTime(
|
||||
volume * 0.6,
|
||||
audioContext.currentTime + 0.01
|
||||
)
|
||||
coalGain.gain.exponentialRampToValueAtTime(
|
||||
volume * 0.3,
|
||||
audioContext.currentTime + 0.15
|
||||
)
|
||||
coalGain.gain.exponentialRampToValueAtTime(0.001, audioContext.currentTime + 0.4)
|
||||
|
||||
coalOsc.start(audioContext.currentTime)
|
||||
coalOsc.stop(audioContext.currentTime + 0.4)
|
||||
break
|
||||
}
|
||||
|
||||
case 'steam_hiss': {
|
||||
// Steam hissing sound for locomotive
|
||||
const steamOsc = audioContext.createOscillator()
|
||||
const steamGain = audioContext.createGain()
|
||||
const steamFilter = audioContext.createBiquadFilter()
|
||||
|
||||
steamOsc.connect(steamFilter)
|
||||
steamFilter.connect(steamGain)
|
||||
steamGain.connect(audioContext.destination)
|
||||
|
||||
steamOsc.type = 'triangle'
|
||||
steamFilter.type = 'highpass'
|
||||
steamFilter.frequency.setValueAtTime(2000, audioContext.currentTime)
|
||||
|
||||
steamOsc.frequency.setValueAtTime(4000 + Math.random() * 1000, audioContext.currentTime)
|
||||
|
||||
steamGain.gain.setValueAtTime(0, audioContext.currentTime)
|
||||
steamGain.gain.exponentialRampToValueAtTime(
|
||||
volume * 0.4,
|
||||
audioContext.currentTime + 0.02
|
||||
)
|
||||
steamGain.gain.exponentialRampToValueAtTime(0.001, audioContext.currentTime + 0.6)
|
||||
|
||||
steamOsc.start(audioContext.currentTime)
|
||||
steamOsc.stop(audioContext.currentTime + 0.6)
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (_e) {
|
||||
console.log('🎵 Web Audio not supported - missing out on rad 90s sounds!')
|
||||
}
|
||||
},
|
||||
[play90sSound]
|
||||
)
|
||||
|
||||
/**
|
||||
* Stop all currently playing sounds
|
||||
*/
|
||||
const stopAllSounds = useCallback(() => {
|
||||
try {
|
||||
if (audioContextsRef.current.length > 0) {
|
||||
audioContextsRef.current.forEach((context) => {
|
||||
try {
|
||||
context.close()
|
||||
} catch (_e) {
|
||||
// Ignore errors
|
||||
}
|
||||
})
|
||||
audioContextsRef.current = []
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('🔇 Sound cleanup error:', e)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
playSound,
|
||||
stopAllSounds,
|
||||
}
|
||||
}
|
||||
@@ -1,457 +0,0 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useComplementRace } from '../context/ComplementRaceContext'
|
||||
import { calculateMaxConcurrentPassengers, generatePassengers } from '../lib/passengerGenerator'
|
||||
import { useSoundEffects } from './useSoundEffects'
|
||||
|
||||
/**
|
||||
* Steam Sprint momentum system (Infinite Mode)
|
||||
*
|
||||
* Momentum mechanics:
|
||||
* - Each correct answer adds momentum (builds up steam pressure)
|
||||
* - Momentum decays over time based on skill level
|
||||
* - Train automatically advances to next route upon completion
|
||||
* - Game continues indefinitely until player quits
|
||||
* - Time-of-day cycle repeats every 60 seconds
|
||||
*
|
||||
* Skill level decay rates (momentum lost per second):
|
||||
* - Preschool: 2.0/s (very slow decay)
|
||||
* - Kindergarten: 3.5/s
|
||||
* - Relaxed: 5.0/s
|
||||
* - Slow: 7.0/s
|
||||
* - Normal: 9.0/s
|
||||
* - Fast: 11.0/s
|
||||
* - Expert: 13.0/s (rapid decay)
|
||||
*/
|
||||
|
||||
const MOMENTUM_DECAY_RATES = {
|
||||
preschool: 2.0,
|
||||
kindergarten: 3.5,
|
||||
relaxed: 5.0,
|
||||
slow: 7.0,
|
||||
normal: 9.0,
|
||||
fast: 11.0,
|
||||
expert: 13.0,
|
||||
}
|
||||
|
||||
const MOMENTUM_GAIN_PER_CORRECT = 15 // Momentum added for each correct answer
|
||||
const SPEED_MULTIPLIER = 0.15 // Convert momentum to speed (% per second at momentum=100)
|
||||
const UPDATE_INTERVAL = 50 // Update every 50ms (~20 fps)
|
||||
const GAME_DURATION = 60000 // 60 seconds in milliseconds
|
||||
|
||||
export function useSteamJourney() {
|
||||
const { state, dispatch } = useComplementRace()
|
||||
const { playSound } = useSoundEffects()
|
||||
const gameStartTimeRef = useRef<number>(0)
|
||||
const lastUpdateRef = useRef<number>(0)
|
||||
const routeExitThresholdRef = useRef<number>(107) // Default for 1 car: 100 + 7
|
||||
|
||||
// Initialize game start time and generate initial passengers
|
||||
useEffect(() => {
|
||||
if (state.isGameActive && state.style === 'sprint' && gameStartTimeRef.current === 0) {
|
||||
gameStartTimeRef.current = Date.now()
|
||||
lastUpdateRef.current = Date.now()
|
||||
|
||||
// Generate initial passengers if none exist
|
||||
if (state.passengers.length === 0) {
|
||||
const newPassengers = generatePassengers(state.stations)
|
||||
dispatch({ type: 'GENERATE_PASSENGERS', passengers: newPassengers })
|
||||
|
||||
// Calculate and store exit threshold for this route
|
||||
const CAR_SPACING = 7
|
||||
const maxPassengers = calculateMaxConcurrentPassengers(newPassengers, state.stations)
|
||||
const maxCars = Math.max(1, maxPassengers)
|
||||
routeExitThresholdRef.current = 100 + maxCars * CAR_SPACING
|
||||
}
|
||||
}
|
||||
}, [state.isGameActive, state.style, state.stations, state.passengers.length, dispatch])
|
||||
|
||||
// Momentum decay and position update loop
|
||||
useEffect(() => {
|
||||
if (!state.isGameActive || state.style !== 'sprint') return
|
||||
|
||||
const interval = setInterval(() => {
|
||||
const now = Date.now()
|
||||
const elapsed = now - gameStartTimeRef.current
|
||||
const deltaTime = now - lastUpdateRef.current
|
||||
lastUpdateRef.current = now
|
||||
|
||||
// Steam Sprint is infinite - no time limit
|
||||
|
||||
// Get decay rate based on timeout setting (skill level)
|
||||
const decayRate = MOMENTUM_DECAY_RATES[state.timeoutSetting] || MOMENTUM_DECAY_RATES.normal
|
||||
|
||||
// Calculate momentum decay for this frame
|
||||
const momentumLoss = (decayRate * deltaTime) / 1000
|
||||
|
||||
// Update momentum (don't go below 0)
|
||||
const newMomentum = Math.max(0, state.momentum - momentumLoss)
|
||||
|
||||
// Calculate speed from momentum (% per second)
|
||||
const speed = newMomentum * SPEED_MULTIPLIER
|
||||
|
||||
// Update train position (accumulate, never go backward)
|
||||
// Allow position to go past 100% so entire train (including cars) can exit tunnel
|
||||
const positionDelta = (speed * deltaTime) / 1000
|
||||
const trainPosition = state.trainPosition + positionDelta
|
||||
|
||||
// Calculate pressure (0-150 PSI) - based on momentum as percentage of max
|
||||
const maxMomentum = 100 // Theoretical max momentum
|
||||
const pressure = Math.min(150, (newMomentum / maxMomentum) * 150)
|
||||
|
||||
// Update state
|
||||
dispatch({
|
||||
type: 'UPDATE_STEAM_JOURNEY',
|
||||
momentum: newMomentum,
|
||||
trainPosition,
|
||||
pressure,
|
||||
elapsedTime: elapsed,
|
||||
})
|
||||
|
||||
// Check for passengers that should board
|
||||
// Passengers board when an EMPTY car reaches their station
|
||||
const CAR_SPACING = 7 // Must match SteamTrainJourney component
|
||||
const maxPassengers = calculateMaxConcurrentPassengers(state.passengers, state.stations)
|
||||
const maxCars = Math.max(1, maxPassengers)
|
||||
const currentBoardedPassengers = state.passengers.filter((p) => p.isBoarded && !p.isDelivered)
|
||||
|
||||
// Debug logging flag - enable when debugging passenger boarding issues
|
||||
// TO ENABLE: Change this to true, save, and the logs will appear in the browser console
|
||||
// When you see passengers getting left behind, copy the entire console log and paste into Claude Code
|
||||
const DEBUG_PASSENGER_BOARDING = true
|
||||
|
||||
if (DEBUG_PASSENGER_BOARDING) {
|
||||
console.log('\n'.repeat(3))
|
||||
console.log('='.repeat(80))
|
||||
console.log('🚂 PASSENGER BOARDING DEBUG LOG')
|
||||
console.log('='.repeat(80))
|
||||
console.log('ISSUE: Passengers are getting left behind at stations')
|
||||
console.log('PURPOSE: This log captures all state during boarding/delivery logic')
|
||||
console.log('USAGE: Copy this entire log and paste into Claude Code for debugging')
|
||||
console.log('='.repeat(80))
|
||||
console.log('\n📊 CURRENT FRAME STATE:')
|
||||
console.log(` Train Position: ${trainPosition.toFixed(2)}`)
|
||||
console.log(` Speed: ${speed.toFixed(2)}% per second`)
|
||||
console.log(` Momentum: ${newMomentum.toFixed(2)}`)
|
||||
console.log(` Max Cars: ${maxCars}`)
|
||||
console.log(` Car Spacing: ${CAR_SPACING}`)
|
||||
console.log(` Distance Tolerance: 5`)
|
||||
|
||||
console.log('\n🚉 STATIONS:')
|
||||
state.stations.forEach((station) => {
|
||||
console.log(` ${station.emoji} ${station.name} (ID: ${station.id})`)
|
||||
console.log(` Position: ${station.position}`)
|
||||
})
|
||||
|
||||
console.log('\n👥 ALL PASSENGERS:')
|
||||
state.passengers.forEach((p, idx) => {
|
||||
const origin = state.stations.find((s) => s.id === p.originStationId)
|
||||
const dest = state.stations.find((s) => s.id === p.destinationStationId)
|
||||
console.log(` [${idx}] ${p.name} (ID: ${p.id})`)
|
||||
console.log(
|
||||
` Status: ${p.isDelivered ? 'DELIVERED' : p.isBoarded ? 'BOARDED' : 'WAITING'}`
|
||||
)
|
||||
console.log(
|
||||
` Route: ${origin?.emoji} ${origin?.name} (pos ${origin?.position}) → ${dest?.emoji} ${dest?.name} (pos ${dest?.position})`
|
||||
)
|
||||
console.log(` Urgent: ${p.isUrgent}`)
|
||||
})
|
||||
|
||||
console.log('\n🚃 CAR POSITIONS:')
|
||||
for (let i = 0; i < maxCars; i++) {
|
||||
const carPos = Math.max(0, trainPosition - (i + 1) * CAR_SPACING)
|
||||
console.log(` Car ${i}: position ${carPos.toFixed(2)}`)
|
||||
}
|
||||
|
||||
console.log('\n🔍 CURRENTLY BOARDED PASSENGERS:')
|
||||
currentBoardedPassengers.forEach((p, carIndex) => {
|
||||
const carPos = Math.max(0, trainPosition - (carIndex + 1) * CAR_SPACING)
|
||||
const dest = state.stations.find((s) => s.id === p.destinationStationId)
|
||||
const distToDest = Math.abs(carPos - (dest?.position || 0))
|
||||
console.log(` Car ${carIndex}: ${p.name}`)
|
||||
console.log(` Car position: ${carPos.toFixed(2)}`)
|
||||
console.log(` Destination: ${dest?.emoji} ${dest?.name} (pos ${dest?.position})`)
|
||||
console.log(` Distance to dest: ${distToDest.toFixed(2)}`)
|
||||
console.log(` Will deliver: ${distToDest < 5 ? 'YES' : 'NO'}`)
|
||||
})
|
||||
}
|
||||
|
||||
// FIRST: Identify which passengers will be delivered in this frame
|
||||
const passengersToDeliver = new Set<string>()
|
||||
currentBoardedPassengers.forEach((passenger, carIndex) => {
|
||||
if (!passenger || passenger.isDelivered) return
|
||||
|
||||
const station = state.stations.find((s) => s.id === passenger.destinationStationId)
|
||||
if (!station) return
|
||||
|
||||
// Calculate this passenger's car position
|
||||
const carPosition = Math.max(0, trainPosition - (carIndex + 1) * CAR_SPACING)
|
||||
const distance = Math.abs(carPosition - station.position)
|
||||
|
||||
// If this car is at the destination station (within 5% tolerance), mark for delivery
|
||||
if (distance < 5) {
|
||||
passengersToDeliver.add(passenger.id)
|
||||
}
|
||||
})
|
||||
|
||||
// Build a map of which cars are occupied (excluding passengers being delivered this frame)
|
||||
const occupiedCars = new Map<number, (typeof currentBoardedPassengers)[0]>()
|
||||
currentBoardedPassengers.forEach((passenger, arrayIndex) => {
|
||||
// Don't count a car as occupied if its passenger is being delivered this frame
|
||||
if (!passengersToDeliver.has(passenger.id)) {
|
||||
occupiedCars.set(arrayIndex, passenger)
|
||||
}
|
||||
})
|
||||
|
||||
if (DEBUG_PASSENGER_BOARDING) {
|
||||
console.log('\n📦 PASSENGERS TO DELIVER THIS FRAME:')
|
||||
if (passengersToDeliver.size === 0) {
|
||||
console.log(' None')
|
||||
} else {
|
||||
passengersToDeliver.forEach((id) => {
|
||||
const p = state.passengers.find((passenger) => passenger.id === id)
|
||||
console.log(` - ${p?.name} (ID: ${id})`)
|
||||
})
|
||||
}
|
||||
|
||||
console.log('\n🚗 OCCUPIED CARS (after excluding deliveries):')
|
||||
if (occupiedCars.size === 0) {
|
||||
console.log(' All cars are empty')
|
||||
} else {
|
||||
occupiedCars.forEach((passenger, carIndex) => {
|
||||
console.log(` Car ${carIndex}: ${passenger.name}`)
|
||||
})
|
||||
}
|
||||
|
||||
console.log('\n🔄 BOARDING ATTEMPTS:')
|
||||
}
|
||||
|
||||
// Track which cars are assigned in THIS frame to prevent double-boarding
|
||||
const carsAssignedThisFrame = new Set<number>()
|
||||
|
||||
// Find waiting passengers whose origin station has an empty car nearby
|
||||
state.passengers.forEach((passenger) => {
|
||||
if (passenger.isBoarded || passenger.isDelivered) return
|
||||
|
||||
const station = state.stations.find((s) => s.id === passenger.originStationId)
|
||||
if (!station) return
|
||||
|
||||
if (DEBUG_PASSENGER_BOARDING) {
|
||||
console.log(
|
||||
`\n Passenger: ${passenger.name} waiting at ${station.emoji} ${station.name} (pos ${station.position})`
|
||||
)
|
||||
}
|
||||
|
||||
// Check if any empty car is at this station
|
||||
// Cars are at positions: trainPosition - 7, trainPosition - 14, etc.
|
||||
let boarded = false
|
||||
for (let carIndex = 0; carIndex < maxCars; carIndex++) {
|
||||
const carPosition = Math.max(0, trainPosition - (carIndex + 1) * CAR_SPACING)
|
||||
const distance = Math.abs(carPosition - station.position)
|
||||
|
||||
if (DEBUG_PASSENGER_BOARDING) {
|
||||
const isOccupied = occupiedCars.has(carIndex)
|
||||
const isAssigned = carsAssignedThisFrame.has(carIndex)
|
||||
const inRange = distance < 5
|
||||
const occupant = occupiedCars.get(carIndex)
|
||||
|
||||
console.log(` Car ${carIndex} @ pos ${carPosition.toFixed(2)}:`)
|
||||
console.log(` Distance to station: ${distance.toFixed(2)}`)
|
||||
console.log(` In range (<5): ${inRange}`)
|
||||
console.log(
|
||||
` Occupied: ${isOccupied}${isOccupied ? ` (by ${occupant?.name})` : ''}`
|
||||
)
|
||||
console.log(` Assigned this frame: ${isAssigned}`)
|
||||
console.log(` Can board: ${!isOccupied && !isAssigned && inRange}`)
|
||||
}
|
||||
|
||||
// Skip if this car already has a passenger OR was assigned this frame
|
||||
if (occupiedCars.has(carIndex) || carsAssignedThisFrame.has(carIndex)) continue
|
||||
|
||||
const distance2 = Math.abs(carPosition - station.position)
|
||||
|
||||
// If car is at or near station (within 5% tolerance for fast trains), board this passenger
|
||||
// Increased tolerance to ensure fast-moving trains don't miss passengers
|
||||
if (distance2 < 5) {
|
||||
if (DEBUG_PASSENGER_BOARDING) {
|
||||
console.log(` ✅ BOARDING ${passenger.name} onto Car ${carIndex}`)
|
||||
}
|
||||
dispatch({
|
||||
type: 'BOARD_PASSENGER',
|
||||
passengerId: passenger.id,
|
||||
})
|
||||
// Mark this car as assigned in this frame
|
||||
carsAssignedThisFrame.add(carIndex)
|
||||
boarded = true
|
||||
return // Board this passenger and move on
|
||||
}
|
||||
}
|
||||
|
||||
if (DEBUG_PASSENGER_BOARDING && !boarded) {
|
||||
console.log(` ❌ ${passenger.name} NOT BOARDED - no suitable car found`)
|
||||
}
|
||||
})
|
||||
|
||||
if (DEBUG_PASSENGER_BOARDING) {
|
||||
console.log('\n🎯 DELIVERY ATTEMPTS:')
|
||||
}
|
||||
|
||||
// Check for deliverable passengers
|
||||
// Passengers disembark when THEIR car reaches their destination
|
||||
currentBoardedPassengers.forEach((passenger, carIndex) => {
|
||||
if (!passenger || passenger.isDelivered) return
|
||||
|
||||
const station = state.stations.find((s) => s.id === passenger.destinationStationId)
|
||||
if (!station) return
|
||||
|
||||
// Calculate this passenger's car position
|
||||
const carPosition = Math.max(0, trainPosition - (carIndex + 1) * CAR_SPACING)
|
||||
const distance = Math.abs(carPosition - station.position)
|
||||
|
||||
// If this car is at the destination station (within 5% tolerance), deliver
|
||||
if (distance < 5) {
|
||||
if (DEBUG_PASSENGER_BOARDING) {
|
||||
console.log(
|
||||
` ✅ DELIVERING ${passenger.name} from Car ${carIndex} to ${station.emoji} ${station.name}`
|
||||
)
|
||||
console.log(
|
||||
` Car position: ${carPosition.toFixed(2)}, Station: ${station.position}, Distance: ${distance.toFixed(2)}`
|
||||
)
|
||||
}
|
||||
const points = passenger.isUrgent ? 20 : 10
|
||||
dispatch({
|
||||
type: 'DELIVER_PASSENGER',
|
||||
passengerId: passenger.id,
|
||||
points,
|
||||
})
|
||||
} else if (DEBUG_PASSENGER_BOARDING) {
|
||||
console.log(
|
||||
` ⏳ ${passenger.name} in Car ${carIndex} heading to ${station.emoji} ${station.name}`
|
||||
)
|
||||
console.log(
|
||||
` Car position: ${carPosition.toFixed(2)}, Station: ${station.position}, Distance: ${distance.toFixed(2)}`
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
if (DEBUG_PASSENGER_BOARDING) {
|
||||
console.log(`\n${'='.repeat(80)}`)
|
||||
console.log('END OF DEBUG LOG')
|
||||
console.log('='.repeat(80))
|
||||
}
|
||||
|
||||
// Check for route completion (entire train exits tunnel)
|
||||
// Use stored threshold (stable for entire route)
|
||||
const ENTIRE_TRAIN_EXIT_THRESHOLD = routeExitThresholdRef.current
|
||||
|
||||
if (
|
||||
trainPosition >= ENTIRE_TRAIN_EXIT_THRESHOLD &&
|
||||
state.trainPosition < ENTIRE_TRAIN_EXIT_THRESHOLD
|
||||
) {
|
||||
// Play celebration whistle
|
||||
playSound('train_whistle', 0.6)
|
||||
setTimeout(() => {
|
||||
playSound('celebration', 0.4)
|
||||
}, 800)
|
||||
|
||||
// Auto-advance to next route
|
||||
const nextRoute = state.currentRoute + 1
|
||||
dispatch({
|
||||
type: 'START_NEW_ROUTE',
|
||||
routeNumber: nextRoute,
|
||||
stations: state.stations,
|
||||
})
|
||||
|
||||
// Generate new passengers
|
||||
const newPassengers = generatePassengers(state.stations)
|
||||
dispatch({ type: 'GENERATE_PASSENGERS', passengers: newPassengers })
|
||||
|
||||
// Calculate and store new exit threshold for next route
|
||||
const newMaxPassengers = calculateMaxConcurrentPassengers(newPassengers, state.stations)
|
||||
const newMaxCars = Math.max(1, newMaxPassengers)
|
||||
routeExitThresholdRef.current = 100 + newMaxCars * CAR_SPACING
|
||||
}
|
||||
}, UPDATE_INTERVAL)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [
|
||||
state.isGameActive,
|
||||
state.style,
|
||||
state.momentum,
|
||||
state.trainPosition,
|
||||
state.timeoutSetting,
|
||||
state.passengers,
|
||||
state.stations,
|
||||
state.currentRoute,
|
||||
dispatch,
|
||||
playSound,
|
||||
])
|
||||
|
||||
// Auto-regenerate passengers when all are delivered
|
||||
useEffect(() => {
|
||||
if (!state.isGameActive || state.style !== 'sprint') return
|
||||
|
||||
// Check if all passengers are delivered
|
||||
const allDelivered = state.passengers.length > 0 && state.passengers.every((p) => p.isDelivered)
|
||||
|
||||
if (allDelivered) {
|
||||
// Generate new passengers after a short delay
|
||||
setTimeout(() => {
|
||||
const newPassengers = generatePassengers(state.stations)
|
||||
dispatch({ type: 'GENERATE_PASSENGERS', passengers: newPassengers })
|
||||
}, 1000)
|
||||
}
|
||||
}, [state.isGameActive, state.style, state.passengers, state.stations, dispatch])
|
||||
|
||||
// Add momentum on correct answer
|
||||
useEffect(() => {
|
||||
// Only for sprint mode
|
||||
if (state.style !== 'sprint') return
|
||||
|
||||
// This effect triggers when correctAnswers increases
|
||||
// We use a ref to track previous value to detect changes
|
||||
}, [state.style])
|
||||
|
||||
// Function to boost momentum (called when answer is correct)
|
||||
const boostMomentum = () => {
|
||||
if (state.style !== 'sprint') return
|
||||
|
||||
const newMomentum = Math.min(100, state.momentum + MOMENTUM_GAIN_PER_CORRECT)
|
||||
dispatch({
|
||||
type: 'UPDATE_STEAM_JOURNEY',
|
||||
momentum: newMomentum,
|
||||
trainPosition: state.trainPosition, // Keep current position
|
||||
pressure: state.pressure,
|
||||
elapsedTime: state.elapsedTime,
|
||||
})
|
||||
}
|
||||
|
||||
// Calculate time of day period (0-5 for 6 periods, cycles infinitely)
|
||||
const getTimeOfDayPeriod = (): number => {
|
||||
if (state.elapsedTime === 0) return 0
|
||||
const periodDuration = GAME_DURATION / 6
|
||||
return Math.floor(state.elapsedTime / periodDuration) % 6
|
||||
}
|
||||
|
||||
// Get sky gradient colors based on time of day
|
||||
const getSkyGradient = (): { top: string; bottom: string } => {
|
||||
const period = getTimeOfDayPeriod()
|
||||
|
||||
// 6 periods over 60 seconds: dawn → morning → midday → afternoon → dusk → night
|
||||
const gradients = [
|
||||
{ top: '#1e3a8a', bottom: '#f59e0b' }, // Dawn - deep blue to orange
|
||||
{ top: '#3b82f6', bottom: '#fbbf24' }, // Morning - blue to yellow
|
||||
{ top: '#60a5fa', bottom: '#93c5fd' }, // Midday - bright blue
|
||||
{ top: '#3b82f6', bottom: '#f59e0b' }, // Afternoon - blue to orange
|
||||
{ top: '#7c3aed', bottom: '#f97316' }, // Dusk - purple to orange
|
||||
{ top: '#1e1b4b', bottom: '#312e81' }, // Night - dark purple
|
||||
]
|
||||
|
||||
return gradients[period] || gradients[0]
|
||||
}
|
||||
|
||||
return {
|
||||
boostMomentum,
|
||||
getTimeOfDayPeriod,
|
||||
getSkyGradient,
|
||||
}
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { Passenger, Station } from '../lib/gameTypes'
|
||||
import { generateLandmarks, type Landmark } from '../lib/landmarks'
|
||||
import type { RailroadTrackGenerator } from '../lib/RailroadTrackGenerator'
|
||||
|
||||
interface UseTrackManagementParams {
|
||||
currentRoute: number
|
||||
trainPosition: number
|
||||
trackGenerator: RailroadTrackGenerator
|
||||
pathRef: React.RefObject<SVGPathElement>
|
||||
stations: Station[]
|
||||
passengers: Passenger[]
|
||||
maxCars: number
|
||||
carSpacing: number
|
||||
}
|
||||
|
||||
export function useTrackManagement({
|
||||
currentRoute,
|
||||
trainPosition,
|
||||
trackGenerator,
|
||||
pathRef,
|
||||
stations,
|
||||
passengers,
|
||||
maxCars: _maxCars,
|
||||
carSpacing: _carSpacing,
|
||||
}: UseTrackManagementParams) {
|
||||
const [trackData, setTrackData] = useState<ReturnType<
|
||||
typeof trackGenerator.generateTrack
|
||||
> | null>(null)
|
||||
const [tiesAndRails, setTiesAndRails] = useState<{
|
||||
ties: Array<{ x1: number; y1: number; x2: number; y2: number }>
|
||||
leftRailPath: string
|
||||
rightRailPath: string
|
||||
} | null>(null)
|
||||
const [stationPositions, setStationPositions] = useState<Array<{ x: number; y: number }>>([])
|
||||
const [landmarks, setLandmarks] = useState<Landmark[]>([])
|
||||
const [landmarkPositions, setLandmarkPositions] = useState<Array<{ x: number; y: number }>>([])
|
||||
const [displayPassengers, setDisplayPassengers] = useState<Passenger[]>(passengers)
|
||||
|
||||
// Track previous route data to maintain visuals during transition
|
||||
const previousRouteRef = useRef(currentRoute)
|
||||
const [pendingTrackData, setPendingTrackData] = useState<ReturnType<
|
||||
typeof trackGenerator.generateTrack
|
||||
> | null>(null)
|
||||
const displayRouteRef = useRef(currentRoute) // Track which route's passengers are being displayed
|
||||
|
||||
// Generate landmarks when route changes
|
||||
useEffect(() => {
|
||||
const newLandmarks = generateLandmarks(currentRoute)
|
||||
setLandmarks(newLandmarks)
|
||||
}, [currentRoute])
|
||||
|
||||
// Generate track on mount and when route changes
|
||||
useEffect(() => {
|
||||
const track = trackGenerator.generateTrack(currentRoute)
|
||||
|
||||
// If we're in the middle of a route (position > 0), store as pending
|
||||
// Only apply new track when position resets to beginning (< 0)
|
||||
if (trainPosition > 0 && previousRouteRef.current !== currentRoute) {
|
||||
setPendingTrackData(track)
|
||||
} else {
|
||||
setTrackData(track)
|
||||
previousRouteRef.current = currentRoute
|
||||
setPendingTrackData(null)
|
||||
}
|
||||
}, [trackGenerator, currentRoute, trainPosition])
|
||||
|
||||
// Apply pending track when train resets to beginning
|
||||
useEffect(() => {
|
||||
if (pendingTrackData && trainPosition < 0) {
|
||||
setTrackData(pendingTrackData)
|
||||
previousRouteRef.current = currentRoute
|
||||
setPendingTrackData(null)
|
||||
}
|
||||
}, [pendingTrackData, trainPosition, currentRoute])
|
||||
|
||||
// Manage passenger display during route transitions
|
||||
useEffect(() => {
|
||||
// Only switch to new passengers when:
|
||||
// 1. Train has reset to start position (< 0) - track has changed, OR
|
||||
// 2. Same route AND train is in middle of track (10-90%) - gameplay updates like boarding/delivering
|
||||
const trainReset = trainPosition < 0
|
||||
const sameRoute = currentRoute === displayRouteRef.current
|
||||
const inMiddleOfTrack = trainPosition >= 10 && trainPosition < 90 // Avoid start/end transition zones
|
||||
|
||||
if (trainReset) {
|
||||
// Train reset - update to new route's passengers
|
||||
setDisplayPassengers(passengers)
|
||||
displayRouteRef.current = currentRoute
|
||||
} else if (sameRoute && inMiddleOfTrack) {
|
||||
// Same route and train in middle of track - update passengers for gameplay changes (boarding/delivery)
|
||||
setDisplayPassengers(passengers)
|
||||
}
|
||||
// Otherwise, keep displaying old passengers until train resets
|
||||
}, [passengers, trainPosition, currentRoute])
|
||||
|
||||
// Generate ties and rails when path is ready
|
||||
useEffect(() => {
|
||||
if (pathRef.current && trackData) {
|
||||
const result = trackGenerator.generateTiesAndRails(pathRef.current)
|
||||
setTiesAndRails(result)
|
||||
}
|
||||
}, [trackData, trackGenerator, pathRef])
|
||||
|
||||
// Calculate station positions when path is ready
|
||||
useEffect(() => {
|
||||
if (pathRef.current) {
|
||||
const positions = stations.map((station) => {
|
||||
const pathLength = pathRef.current!.getTotalLength()
|
||||
const distance = (station.position / 100) * pathLength
|
||||
const point = pathRef.current!.getPointAtLength(distance)
|
||||
return { x: point.x, y: point.y }
|
||||
})
|
||||
setStationPositions(positions)
|
||||
}
|
||||
}, [stations, pathRef])
|
||||
|
||||
// Calculate landmark positions when path is ready
|
||||
useEffect(() => {
|
||||
if (pathRef.current && landmarks.length > 0) {
|
||||
const positions = landmarks.map((landmark) => {
|
||||
const pathLength = pathRef.current!.getTotalLength()
|
||||
const distance = (landmark.position / 100) * pathLength
|
||||
const point = pathRef.current!.getPointAtLength(distance)
|
||||
return {
|
||||
x: point.x + landmark.offset.x,
|
||||
y: point.y + landmark.offset.y,
|
||||
}
|
||||
})
|
||||
setLandmarkPositions(positions)
|
||||
}
|
||||
}, [landmarks, pathRef])
|
||||
|
||||
return {
|
||||
trackData,
|
||||
tiesAndRails,
|
||||
stationPositions,
|
||||
landmarks,
|
||||
landmarkPositions,
|
||||
displayPassengers,
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import type { RailroadTrackGenerator } from '../lib/RailroadTrackGenerator'
|
||||
|
||||
interface TrainTransform {
|
||||
x: number
|
||||
y: number
|
||||
rotation: number
|
||||
}
|
||||
|
||||
interface TrainCarTransform extends TrainTransform {
|
||||
position: number
|
||||
opacity: number
|
||||
}
|
||||
|
||||
interface UseTrainTransformsParams {
|
||||
trainPosition: number
|
||||
trackGenerator: RailroadTrackGenerator
|
||||
pathRef: React.RefObject<SVGPathElement>
|
||||
maxCars: number
|
||||
carSpacing: number
|
||||
}
|
||||
|
||||
export function useTrainTransforms({
|
||||
trainPosition,
|
||||
trackGenerator,
|
||||
pathRef,
|
||||
maxCars,
|
||||
carSpacing,
|
||||
}: UseTrainTransformsParams) {
|
||||
const [trainTransform, setTrainTransform] = useState<TrainTransform>({
|
||||
x: 50,
|
||||
y: 300,
|
||||
rotation: 0,
|
||||
})
|
||||
|
||||
// Update train position and rotation
|
||||
useEffect(() => {
|
||||
if (pathRef.current) {
|
||||
const transform = trackGenerator.getTrainTransform(pathRef.current, trainPosition)
|
||||
setTrainTransform(transform)
|
||||
}
|
||||
}, [trainPosition, trackGenerator, pathRef])
|
||||
|
||||
// Calculate train car transforms (each car follows behind the locomotive)
|
||||
const trainCars = useMemo((): TrainCarTransform[] => {
|
||||
if (!pathRef.current) {
|
||||
return Array.from({ length: maxCars }, () => ({
|
||||
x: 0,
|
||||
y: 0,
|
||||
rotation: 0,
|
||||
position: 0,
|
||||
opacity: 0,
|
||||
}))
|
||||
}
|
||||
|
||||
return Array.from({ length: maxCars }).map((_, carIndex) => {
|
||||
// Calculate position for this car (behind the locomotive)
|
||||
const carPosition = Math.max(0, trainPosition - (carIndex + 1) * carSpacing)
|
||||
|
||||
// Calculate opacity: fade in at left tunnel (3-8%), fade out at right tunnel (92-97%)
|
||||
const fadeInStart = 3
|
||||
const fadeInEnd = 8
|
||||
const fadeOutStart = 92
|
||||
const fadeOutEnd = 97
|
||||
|
||||
let opacity = 1 // Default to fully visible
|
||||
|
||||
// Fade in from left tunnel
|
||||
if (carPosition <= fadeInStart) {
|
||||
opacity = 0
|
||||
} else if (carPosition < fadeInEnd) {
|
||||
opacity = (carPosition - fadeInStart) / (fadeInEnd - fadeInStart)
|
||||
}
|
||||
// Fade out into right tunnel
|
||||
else if (carPosition >= fadeOutEnd) {
|
||||
opacity = 0
|
||||
} else if (carPosition > fadeOutStart) {
|
||||
opacity = 1 - (carPosition - fadeOutStart) / (fadeOutEnd - fadeOutStart)
|
||||
}
|
||||
|
||||
return {
|
||||
...trackGenerator.getTrainTransform(pathRef.current!, carPosition),
|
||||
position: carPosition,
|
||||
opacity,
|
||||
}
|
||||
})
|
||||
}, [trainPosition, trackGenerator, pathRef, maxCars, carSpacing])
|
||||
|
||||
// Calculate locomotive opacity (fade in/out through tunnels)
|
||||
const locomotiveOpacity = useMemo(() => {
|
||||
const fadeInStart = 3
|
||||
const fadeInEnd = 8
|
||||
const fadeOutStart = 92
|
||||
const fadeOutEnd = 97
|
||||
|
||||
// Fade in from left tunnel
|
||||
if (trainPosition <= fadeInStart) {
|
||||
return 0
|
||||
} else if (trainPosition < fadeInEnd) {
|
||||
return (trainPosition - fadeInStart) / (fadeInEnd - fadeInStart)
|
||||
}
|
||||
// Fade out into right tunnel
|
||||
else if (trainPosition >= fadeOutEnd) {
|
||||
return 0
|
||||
} else if (trainPosition > fadeOutStart) {
|
||||
return 1 - (trainPosition - fadeOutStart) / (fadeOutEnd - fadeOutStart)
|
||||
}
|
||||
|
||||
return 1 // Default to fully visible
|
||||
}, [trainPosition])
|
||||
|
||||
return {
|
||||
trainTransform,
|
||||
trainCars,
|
||||
locomotiveOpacity,
|
||||
}
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
/**
|
||||
* Railroad Track Generator
|
||||
*
|
||||
* Generates dynamic curved railroad tracks with proper ballast, ties, and rails.
|
||||
* Based on the original Python implementation with SVG path generation.
|
||||
*/
|
||||
|
||||
export interface Waypoint {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface TrackElements {
|
||||
ballastPath: string
|
||||
referencePath: string
|
||||
ties: Array<{ x1: number; y1: number; x2: number; y2: number }>
|
||||
leftRailPath: string
|
||||
rightRailPath: string
|
||||
}
|
||||
|
||||
export class RailroadTrackGenerator {
|
||||
private viewWidth: number
|
||||
private viewHeight: number
|
||||
|
||||
constructor(viewWidth = 800, viewHeight = 600) {
|
||||
this.viewWidth = viewWidth
|
||||
this.viewHeight = viewHeight
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate complete track elements for rendering
|
||||
*/
|
||||
generateTrack(routeNumber: number = 1): TrackElements {
|
||||
const waypoints = this.generateTrackWaypoints(routeNumber)
|
||||
const pathData = this.generateSmoothPath(waypoints)
|
||||
|
||||
return {
|
||||
ballastPath: pathData,
|
||||
referencePath: pathData,
|
||||
ties: [],
|
||||
leftRailPath: '',
|
||||
rightRailPath: '',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeded random number generator for deterministic randomness
|
||||
*/
|
||||
private seededRandom(seed: number): number {
|
||||
const x = Math.sin(seed) * 10000
|
||||
return x - Math.floor(x)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate waypoints for track with controlled randomness
|
||||
* Based on route number for variety across different routes
|
||||
*/
|
||||
private generateTrackWaypoints(routeNumber: number): Waypoint[] {
|
||||
// Base waypoints - tracks span from left tunnel (x=20) to right tunnel (x=780)
|
||||
// viewBox is "-50 -50 900 700", so x ranges from -50 to 850
|
||||
const baseWaypoints: Waypoint[] = [
|
||||
{ x: 20, y: 300 }, // Start at left tunnel center
|
||||
{ x: 120, y: 260 }, // Emerging from left tunnel
|
||||
{ x: 240, y: 200 }, // Climb into hills
|
||||
{ x: 380, y: 170 }, // Mountain pass
|
||||
{ x: 520, y: 220 }, // Descent to valley
|
||||
{ x: 660, y: 160 }, // Bridge over canyon
|
||||
{ x: 780, y: 300 }, // Enter right tunnel center
|
||||
]
|
||||
|
||||
// Add deterministic randomness based on route number (but keep start/end fixed)
|
||||
return baseWaypoints.map((point, index) => {
|
||||
if (index === 0 || index === baseWaypoints.length - 1) {
|
||||
return point // Keep start/end points fixed
|
||||
}
|
||||
|
||||
// Use seeded randomness for consistent track per route
|
||||
const seed1 = routeNumber * 12.9898 + index * 78.233
|
||||
const seed2 = routeNumber * 43.789 + index * 67.123
|
||||
const randomX = (this.seededRandom(seed1) - 0.5) * 60 // ±30 pixels
|
||||
const randomY = (this.seededRandom(seed2) - 0.5) * 80 // ±40 pixels
|
||||
|
||||
return {
|
||||
x: point.x + randomX,
|
||||
y: point.y + randomY,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate smooth cubic bezier curves through waypoints
|
||||
*/
|
||||
private generateSmoothPath(waypoints: Waypoint[]): string {
|
||||
if (waypoints.length < 2) return ''
|
||||
|
||||
let pathData = `M ${waypoints[0].x} ${waypoints[0].y}`
|
||||
|
||||
for (let i = 1; i < waypoints.length; i++) {
|
||||
const current = waypoints[i]
|
||||
const previous = waypoints[i - 1]
|
||||
|
||||
// Calculate control points for smooth curves
|
||||
const dx = current.x - previous.x
|
||||
const dy = current.y - previous.y
|
||||
|
||||
const cp1x = previous.x + dx * 0.3
|
||||
const cp1y = previous.y + dy * 0.2
|
||||
const cp2x = current.x - dx * 0.3
|
||||
const cp2y = current.y - dy * 0.2
|
||||
|
||||
pathData += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${current.x} ${current.y}`
|
||||
}
|
||||
|
||||
return pathData
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate gentle curves through densely sampled waypoints
|
||||
* Uses very gentle control points to avoid wobbles in straight sections
|
||||
*/
|
||||
private generateGentlePath(waypoints: Waypoint[]): string {
|
||||
if (waypoints.length < 2) return ''
|
||||
|
||||
let pathData = `M ${waypoints[0].x} ${waypoints[0].y}`
|
||||
|
||||
for (let i = 1; i < waypoints.length; i++) {
|
||||
const current = waypoints[i]
|
||||
const previous = waypoints[i - 1]
|
||||
|
||||
// Use extremely gentle control points for very dense sampling
|
||||
const dx = current.x - previous.x
|
||||
const dy = current.y - previous.y
|
||||
|
||||
const cp1x = previous.x + dx * 0.33
|
||||
const cp1y = previous.y + dy * 0.33
|
||||
const cp2x = current.x - dx * 0.33
|
||||
const cp2y = current.y - dy * 0.33
|
||||
|
||||
pathData += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${current.x} ${current.y}`
|
||||
}
|
||||
|
||||
return pathData
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate railroad ties and rails along the path
|
||||
* This requires an SVG path element to measure
|
||||
*/
|
||||
generateTiesAndRails(pathElement: SVGPathElement): {
|
||||
ties: Array<{ x1: number; y1: number; x2: number; y2: number }>
|
||||
leftRailPath: string
|
||||
rightRailPath: string
|
||||
} {
|
||||
const pathLength = pathElement.getTotalLength()
|
||||
const tieSpacing = 12 // Distance between ties in pixels
|
||||
const gaugeWidth = 15 // Standard gauge (tie extends 15px each side)
|
||||
const tieCount = Math.floor(pathLength / tieSpacing)
|
||||
|
||||
const ties: Array<{ x1: number; y1: number; x2: number; y2: number }> = []
|
||||
|
||||
// Generate ties at normal spacing
|
||||
for (let i = 0; i < tieCount; i++) {
|
||||
const distance = i * tieSpacing
|
||||
const point = pathElement.getPointAtLength(distance)
|
||||
|
||||
// Calculate perpendicular angle for tie orientation
|
||||
const nextDistance = Math.min(distance + 2, pathLength)
|
||||
const nextPoint = pathElement.getPointAtLength(nextDistance)
|
||||
const angle = Math.atan2(nextPoint.y - point.y, nextPoint.x - point.x)
|
||||
const perpAngle = angle + Math.PI / 2
|
||||
|
||||
// Calculate tie end points
|
||||
const leftX = point.x + Math.cos(perpAngle) * gaugeWidth
|
||||
const leftY = point.y + Math.sin(perpAngle) * gaugeWidth
|
||||
const rightX = point.x - Math.cos(perpAngle) * gaugeWidth
|
||||
const rightY = point.y - Math.sin(perpAngle) * gaugeWidth
|
||||
|
||||
// Store tie
|
||||
ties.push({ x1: leftX, y1: leftY, x2: rightX, y2: rightY })
|
||||
}
|
||||
|
||||
// Generate rail paths as smooth curves (not polylines)
|
||||
// Sample points along the path and create offset waypoints
|
||||
const railSampling = 2 // Sample every 2 pixels for waypoints (very dense sampling for smooth curves)
|
||||
const sampleCount = Math.floor(pathLength / railSampling)
|
||||
|
||||
const leftRailWaypoints: Waypoint[] = []
|
||||
const rightRailWaypoints: Waypoint[] = []
|
||||
|
||||
for (let i = 0; i <= sampleCount; i++) {
|
||||
const distance = Math.min(i * railSampling, pathLength)
|
||||
const point = pathElement.getPointAtLength(distance)
|
||||
|
||||
// Calculate perpendicular angle with longer lookahead for smoother curves
|
||||
const nextDistance = Math.min(distance + 8, pathLength)
|
||||
const nextPoint = pathElement.getPointAtLength(nextDistance)
|
||||
const angle = Math.atan2(nextPoint.y - point.y, nextPoint.x - point.x)
|
||||
const perpAngle = angle + Math.PI / 2
|
||||
|
||||
// Calculate offset positions for rails
|
||||
const leftX = point.x + Math.cos(perpAngle) * gaugeWidth
|
||||
const leftY = point.y + Math.sin(perpAngle) * gaugeWidth
|
||||
const rightX = point.x - Math.cos(perpAngle) * gaugeWidth
|
||||
const rightY = point.y - Math.sin(perpAngle) * gaugeWidth
|
||||
|
||||
leftRailWaypoints.push({ x: leftX, y: leftY })
|
||||
rightRailWaypoints.push({ x: rightX, y: rightY })
|
||||
}
|
||||
|
||||
// Generate smooth curved paths through the rail waypoints with gentle control points
|
||||
const leftRailPath = this.generateGentlePath(leftRailWaypoints)
|
||||
const rightRailPath = this.generateGentlePath(rightRailWaypoints)
|
||||
|
||||
return { ties, leftRailPath, rightRailPath }
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate train position and rotation along path
|
||||
*/
|
||||
getTrainTransform(
|
||||
pathElement: SVGPathElement,
|
||||
progress: number // 0-100%
|
||||
): { x: number; y: number; rotation: number } {
|
||||
const pathLength = pathElement.getTotalLength()
|
||||
const targetLength = (progress / 100) * pathLength
|
||||
|
||||
// Get exact point on curved path
|
||||
const point = pathElement.getPointAtLength(targetLength)
|
||||
|
||||
// Calculate rotation based on path direction
|
||||
const lookAheadDistance = Math.min(5, pathLength - targetLength)
|
||||
const nextPoint = pathElement.getPointAtLength(targetLength + lookAheadDistance)
|
||||
|
||||
// Calculate angle between current and next point
|
||||
const deltaX = nextPoint.x - point.x
|
||||
const deltaY = nextPoint.y - point.y
|
||||
const angleRadians = Math.atan2(deltaY, deltaX)
|
||||
const angleDegrees = angleRadians * (180 / Math.PI)
|
||||
|
||||
return {
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
rotation: angleDegrees,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
export type GameMode = 'friends5' | 'friends10' | 'mixed'
|
||||
export type GameStyle = 'practice' | 'sprint' | 'survival'
|
||||
export type TimeoutSetting =
|
||||
| 'preschool'
|
||||
| 'kindergarten'
|
||||
| 'relaxed'
|
||||
| 'slow'
|
||||
| 'normal'
|
||||
| 'fast'
|
||||
| 'expert'
|
||||
export type ComplementDisplay = 'number' | 'abacus' | 'random' // How to display the complement number
|
||||
|
||||
export interface ComplementQuestion {
|
||||
number: number
|
||||
targetSum: number
|
||||
correctAnswer: number
|
||||
showAsAbacus: boolean // For random mode, this is decided once per question
|
||||
}
|
||||
|
||||
export interface AIRacer {
|
||||
id: string
|
||||
position: number
|
||||
speed: number
|
||||
name: string
|
||||
personality: 'competitive' | 'analytical'
|
||||
icon: string
|
||||
lastComment: number
|
||||
commentCooldown: number
|
||||
previousPosition: number
|
||||
}
|
||||
|
||||
export interface DifficultyTracker {
|
||||
pairPerformance: Map<string, PairPerformance>
|
||||
baseTimeLimit: number
|
||||
currentTimeLimit: number
|
||||
difficultyLevel: number
|
||||
consecutiveCorrect: number
|
||||
consecutiveIncorrect: number
|
||||
learningMode: boolean
|
||||
adaptationRate: number
|
||||
}
|
||||
|
||||
export interface PairPerformance {
|
||||
attempts: number
|
||||
correct: number
|
||||
avgTime: number
|
||||
difficulty: number
|
||||
}
|
||||
|
||||
export interface Station {
|
||||
id: string
|
||||
name: string
|
||||
position: number // 0-100% along track
|
||||
icon: string
|
||||
}
|
||||
|
||||
export interface Passenger {
|
||||
id: string
|
||||
name: string
|
||||
avatar: string
|
||||
originStationId: string
|
||||
destinationStationId: string
|
||||
isUrgent: boolean
|
||||
isBoarded: boolean
|
||||
isDelivered: boolean
|
||||
}
|
||||
|
||||
export interface GameState {
|
||||
// Game configuration
|
||||
mode: GameMode
|
||||
style: GameStyle
|
||||
timeoutSetting: TimeoutSetting
|
||||
complementDisplay: ComplementDisplay // How to display the complement number
|
||||
|
||||
// Current question
|
||||
currentQuestion: ComplementQuestion | null
|
||||
previousQuestion: ComplementQuestion | null
|
||||
|
||||
// Game progress
|
||||
score: number
|
||||
streak: number
|
||||
bestStreak: number
|
||||
totalQuestions: number
|
||||
correctAnswers: number
|
||||
|
||||
// Game status
|
||||
isGameActive: boolean
|
||||
isPaused: boolean
|
||||
gamePhase: 'intro' | 'controls' | 'countdown' | 'playing' | 'results'
|
||||
|
||||
// Timing
|
||||
gameStartTime: number | null
|
||||
questionStartTime: number
|
||||
|
||||
// Race mechanics
|
||||
raceGoal: number
|
||||
timeLimit: number | null
|
||||
speedMultiplier: number
|
||||
aiRacers: AIRacer[]
|
||||
|
||||
// Adaptive difficulty
|
||||
difficultyTracker: DifficultyTracker
|
||||
|
||||
// Survival mode specific
|
||||
playerLap: number
|
||||
aiLaps: Map<string, number>
|
||||
survivalMultiplier: number
|
||||
|
||||
// Sprint mode specific
|
||||
momentum: number
|
||||
trainPosition: number
|
||||
pressure: number // 0-150 PSI
|
||||
elapsedTime: number // milliseconds elapsed in 60-second journey
|
||||
lastCorrectAnswerTime: number
|
||||
currentRoute: number
|
||||
stations: Station[]
|
||||
passengers: Passenger[]
|
||||
deliveredPassengers: number
|
||||
cumulativeDistance: number // Total distance across all routes
|
||||
showRouteCelebration: boolean
|
||||
|
||||
// Input
|
||||
currentInput: string
|
||||
|
||||
// UI state
|
||||
showScoreModal: boolean
|
||||
activeSpeechBubbles: Map<string, string> // racerId -> message
|
||||
adaptiveFeedback: { message: string; type: string } | null
|
||||
}
|
||||
|
||||
export type GameAction =
|
||||
| { type: 'SET_MODE'; mode: GameMode }
|
||||
| { type: 'SET_STYLE'; style: GameStyle }
|
||||
| { type: 'SET_TIMEOUT'; timeout: TimeoutSetting }
|
||||
| { type: 'SET_COMPLEMENT_DISPLAY'; display: ComplementDisplay }
|
||||
| { type: 'SHOW_CONTROLS' }
|
||||
| { type: 'START_COUNTDOWN' }
|
||||
| { type: 'BEGIN_GAME' }
|
||||
| { type: 'NEXT_QUESTION' }
|
||||
| { type: 'SUBMIT_ANSWER'; answer: number }
|
||||
| { type: 'UPDATE_INPUT'; input: string }
|
||||
| {
|
||||
type: 'UPDATE_AI_POSITIONS'
|
||||
positions: Array<{ id: string; position: number }>
|
||||
}
|
||||
| {
|
||||
type: 'TRIGGER_AI_COMMENTARY'
|
||||
racerId: string
|
||||
message: string
|
||||
context: string
|
||||
}
|
||||
| { type: 'CLEAR_AI_COMMENT'; racerId: string }
|
||||
| { type: 'UPDATE_DIFFICULTY_TRACKER'; tracker: DifficultyTracker }
|
||||
| { type: 'UPDATE_AI_SPEEDS'; racers: AIRacer[] }
|
||||
| {
|
||||
type: 'SHOW_ADAPTIVE_FEEDBACK'
|
||||
feedback: { message: string; type: string }
|
||||
}
|
||||
| { type: 'CLEAR_ADAPTIVE_FEEDBACK' }
|
||||
| { type: 'UPDATE_MOMENTUM'; momentum: number }
|
||||
| { type: 'UPDATE_TRAIN_POSITION'; position: number }
|
||||
| {
|
||||
type: 'UPDATE_STEAM_JOURNEY'
|
||||
momentum: number
|
||||
trainPosition: number
|
||||
pressure: number
|
||||
elapsedTime: number
|
||||
}
|
||||
| { type: 'COMPLETE_LAP'; racerId: string }
|
||||
| { type: 'PAUSE_RACE' }
|
||||
| { type: 'RESUME_RACE' }
|
||||
| { type: 'END_RACE' }
|
||||
| { type: 'SHOW_RESULTS' }
|
||||
| { type: 'RESET_GAME' }
|
||||
| { type: 'GENERATE_PASSENGERS'; passengers: Passenger[] }
|
||||
| { type: 'BOARD_PASSENGER'; passengerId: string }
|
||||
| { type: 'DELIVER_PASSENGER'; passengerId: string; points: number }
|
||||
| { type: 'START_NEW_ROUTE'; routeNumber: number; stations: Station[] }
|
||||
| { type: 'COMPLETE_ROUTE' }
|
||||
| { type: 'HIDE_ROUTE_CELEBRATION' }
|
||||
@@ -1,103 +0,0 @@
|
||||
/**
|
||||
* Geographic landmarks for Steam Train Journey
|
||||
* Landmarks add visual variety to the landscape based on route themes
|
||||
*/
|
||||
|
||||
export interface Landmark {
|
||||
emoji: string
|
||||
position: number // 0-100% along track
|
||||
offset: { x: number; y: number } // Offset from track position
|
||||
size: number // Font size multiplier
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate landmarks for a specific route
|
||||
* Different route themes have different landmark types
|
||||
*/
|
||||
export function generateLandmarks(routeNumber: number): Landmark[] {
|
||||
const seed = routeNumber * 456.789
|
||||
|
||||
// Deterministic randomness for landmark placement
|
||||
const random = (index: number) => {
|
||||
return Math.abs(Math.sin(seed + index * 2.7))
|
||||
}
|
||||
|
||||
const landmarks: Landmark[] = []
|
||||
|
||||
// Route theme determines landmark types
|
||||
const themeIndex = (routeNumber - 1) % 10
|
||||
|
||||
// Generate 4-6 landmarks along the route
|
||||
const landmarkCount = Math.floor(random(0) * 3) + 4
|
||||
|
||||
for (let i = 0; i < landmarkCount; i++) {
|
||||
const position = (i + 1) * (100 / (landmarkCount + 1))
|
||||
const offsetSide = random(i) > 0.5 ? 1 : -1
|
||||
const offsetDistance = 30 + random(i + 10) * 40
|
||||
|
||||
let emoji = '🌳' // Default tree
|
||||
let size = 24
|
||||
|
||||
// Choose emoji based on theme and position
|
||||
switch (themeIndex) {
|
||||
case 0: // Prairie Express
|
||||
emoji = random(i) > 0.6 ? '🌾' : '🌻'
|
||||
size = 20
|
||||
break
|
||||
case 1: // Mountain Climb
|
||||
emoji = random(i) > 0.5 ? '⛰️' : '🗻'
|
||||
size = 32
|
||||
break
|
||||
case 2: // Coastal Run
|
||||
emoji = random(i) > 0.7 ? '🌊' : random(i) > 0.4 ? '🏖️' : '⛵'
|
||||
size = 24
|
||||
break
|
||||
case 3: // Desert Crossing
|
||||
emoji = random(i) > 0.6 ? '🌵' : '🏜️'
|
||||
size = 28
|
||||
break
|
||||
case 4: // Forest Trail
|
||||
emoji = random(i) > 0.7 ? '🌲' : random(i) > 0.4 ? '🌳' : '🦌'
|
||||
size = 26
|
||||
break
|
||||
case 5: // Canyon Route
|
||||
emoji = random(i) > 0.5 ? '🏞️' : '🪨'
|
||||
size = 30
|
||||
break
|
||||
case 6: // River Valley
|
||||
emoji = random(i) > 0.6 ? '🌊' : random(i) > 0.3 ? '🌳' : '🦆'
|
||||
size = 24
|
||||
break
|
||||
case 7: // Highland Pass
|
||||
emoji = random(i) > 0.6 ? '🗻' : '☁️'
|
||||
size = 28
|
||||
break
|
||||
case 8: // Lakeside Journey
|
||||
emoji = random(i) > 0.7 ? '🏞️' : random(i) > 0.4 ? '🌳' : '🦢'
|
||||
size = 26
|
||||
break
|
||||
case 9: // Grand Circuit
|
||||
emoji = random(i) > 0.7 ? '🎪' : random(i) > 0.4 ? '🎡' : '🎠'
|
||||
size = 28
|
||||
break
|
||||
}
|
||||
|
||||
// Add bridges at specific positions (around 40-60%)
|
||||
if (position > 40 && position < 60 && random(i + 20) > 0.7) {
|
||||
emoji = '🌉'
|
||||
size = 36
|
||||
}
|
||||
|
||||
landmarks.push({
|
||||
emoji,
|
||||
position,
|
||||
offset: {
|
||||
x: offsetSide * offsetDistance,
|
||||
y: random(i + 5) * 20 - 10,
|
||||
},
|
||||
size,
|
||||
})
|
||||
}
|
||||
|
||||
return landmarks
|
||||
}
|
||||
@@ -1,335 +0,0 @@
|
||||
import type { Passenger, Station } from './gameTypes'
|
||||
|
||||
// Names and avatars organized by gender presentation
|
||||
const MASCULINE_NAMES = [
|
||||
'Ahmed',
|
||||
'Bob',
|
||||
'Carlos',
|
||||
'Elias',
|
||||
'Ethan',
|
||||
'George',
|
||||
'Ian',
|
||||
'Kevin',
|
||||
'Marcus',
|
||||
'Oliver',
|
||||
'Victor',
|
||||
'Xavier',
|
||||
'Raj',
|
||||
'David',
|
||||
'Miguel',
|
||||
'Jin',
|
||||
]
|
||||
|
||||
const FEMININE_NAMES = [
|
||||
'Alice',
|
||||
'Bella',
|
||||
'Diana',
|
||||
'Devi',
|
||||
'Fatima',
|
||||
'Fiona',
|
||||
'Hannah',
|
||||
'Julia',
|
||||
'Laura',
|
||||
'Nina',
|
||||
'Petra',
|
||||
'Rosa',
|
||||
'Tessa',
|
||||
'Uma',
|
||||
'Wendy',
|
||||
'Zara',
|
||||
'Yuki',
|
||||
]
|
||||
|
||||
const GENDER_NEUTRAL_NAMES = [
|
||||
'Alex',
|
||||
'Charlie',
|
||||
'Jordan',
|
||||
'Morgan',
|
||||
'Quinn',
|
||||
'Riley',
|
||||
'Sam',
|
||||
'Taylor',
|
||||
]
|
||||
|
||||
// Masculine-presenting avatars
|
||||
const MASCULINE_AVATARS = [
|
||||
'👨',
|
||||
'👨🏻',
|
||||
'👨🏼',
|
||||
'👨🏽',
|
||||
'👨🏾',
|
||||
'👨🏿',
|
||||
'👴',
|
||||
'👴🏻',
|
||||
'👴🏼',
|
||||
'👴🏽',
|
||||
'👴🏾',
|
||||
'👴🏿',
|
||||
'👦',
|
||||
'👦🏻',
|
||||
'👦🏼',
|
||||
'👦🏽',
|
||||
'👦🏾',
|
||||
'👦🏿',
|
||||
'🧔',
|
||||
'🧔🏻',
|
||||
'🧔🏼',
|
||||
'🧔🏽',
|
||||
'🧔🏾',
|
||||
'🧔🏿',
|
||||
'👨🦱',
|
||||
'👨🏻🦱',
|
||||
'👨🏼🦱',
|
||||
'👨🏽🦱',
|
||||
'👨🏾🦱',
|
||||
'👨🏿🦱',
|
||||
'👨🦰',
|
||||
'👨🏻🦰',
|
||||
'👨🏼🦰',
|
||||
'👨🏽🦰',
|
||||
'👨🏾🦰',
|
||||
'👨🏿🦰',
|
||||
'👱',
|
||||
'👱🏻',
|
||||
'👱🏼',
|
||||
'👱🏽',
|
||||
'👱🏾',
|
||||
'👱🏿',
|
||||
]
|
||||
|
||||
// Feminine-presenting avatars
|
||||
const FEMININE_AVATARS = [
|
||||
'👩',
|
||||
'👩🏻',
|
||||
'👩🏼',
|
||||
'👩🏽',
|
||||
'👩🏾',
|
||||
'👩🏿',
|
||||
'👵',
|
||||
'👵🏻',
|
||||
'👵🏼',
|
||||
'👵🏽',
|
||||
'👵🏾',
|
||||
'👵🏿',
|
||||
'👧',
|
||||
'👧🏻',
|
||||
'👧🏼',
|
||||
'👧🏽',
|
||||
'👧🏾',
|
||||
'👧🏿',
|
||||
'👩🦱',
|
||||
'👩🏻🦱',
|
||||
'👩🏼🦱',
|
||||
'👩🏽🦱',
|
||||
'👩🏾🦱',
|
||||
'👩🏿🦱',
|
||||
'👩🦰',
|
||||
'👩🏻🦰',
|
||||
'👩🏼🦰',
|
||||
'👩🏽🦰',
|
||||
'👩🏾🦰',
|
||||
'👩🏿🦰',
|
||||
'👱♀️',
|
||||
'👱🏻♀️',
|
||||
'👱🏼♀️',
|
||||
'👱🏽♀️',
|
||||
'👱🏾♀️',
|
||||
'👱🏿♀️',
|
||||
]
|
||||
|
||||
// Gender-neutral avatars
|
||||
const NEUTRAL_AVATARS = ['🧑', '🧑🏻', '🧑🏼', '🧑🏽', '🧑🏾', '🧑🏿']
|
||||
|
||||
/**
|
||||
* Generate 3-5 passengers with random names and destinations
|
||||
* 30% chance of urgent passengers
|
||||
*/
|
||||
export function generatePassengers(stations: Station[]): Passenger[] {
|
||||
const count = Math.floor(Math.random() * 3) + 3 // 3-5 passengers
|
||||
const passengers: Passenger[] = []
|
||||
const usedCombos = new Set<string>()
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
let name: string
|
||||
let avatar: string
|
||||
let comboKey: string
|
||||
|
||||
// Keep trying until we get a unique name/avatar combo
|
||||
do {
|
||||
// Randomly choose a gender category
|
||||
const genderRoll = Math.random()
|
||||
let namePool: string[]
|
||||
let avatarPool: string[]
|
||||
|
||||
if (genderRoll < 0.45) {
|
||||
// 45% masculine
|
||||
namePool = MASCULINE_NAMES
|
||||
avatarPool = MASCULINE_AVATARS
|
||||
} else if (genderRoll < 0.9) {
|
||||
// 45% feminine
|
||||
namePool = FEMININE_NAMES
|
||||
avatarPool = FEMININE_AVATARS
|
||||
} else {
|
||||
// 10% neutral
|
||||
namePool = GENDER_NEUTRAL_NAMES
|
||||
avatarPool = NEUTRAL_AVATARS
|
||||
}
|
||||
|
||||
// Pick from the chosen category
|
||||
name = namePool[Math.floor(Math.random() * namePool.length)]
|
||||
avatar = avatarPool[Math.floor(Math.random() * avatarPool.length)]
|
||||
comboKey = `${name}-${avatar}`
|
||||
} while (usedCombos.has(comboKey) && usedCombos.size < 100) // Prevent infinite loop
|
||||
|
||||
usedCombos.add(comboKey)
|
||||
|
||||
// Pick random origin and destination stations (must be different)
|
||||
// Destination must be ahead of origin (higher position on track)
|
||||
// 40% chance to start at depot, 60% chance to start at other stations
|
||||
let originStation: Station
|
||||
let destination: Station
|
||||
|
||||
if (Math.random() < 0.4 || stations.length < 3) {
|
||||
// Start at depot (first station)
|
||||
originStation = stations[0]
|
||||
// Pick any station ahead as destination
|
||||
const stationsAhead = stations.slice(1)
|
||||
destination = stationsAhead[Math.floor(Math.random() * stationsAhead.length)]
|
||||
} else {
|
||||
// Start at a random non-depot, non-final station
|
||||
const nonDepotStations = stations.slice(1, -1) // Exclude depot and final station
|
||||
originStation = nonDepotStations[Math.floor(Math.random() * nonDepotStations.length)]
|
||||
|
||||
// Pick a station ahead of origin (higher position)
|
||||
const stationsAhead = stations.filter((s) => s.position > originStation.position)
|
||||
destination = stationsAhead[Math.floor(Math.random() * stationsAhead.length)]
|
||||
}
|
||||
|
||||
// 30% chance of urgent
|
||||
const isUrgent = Math.random() < 0.3
|
||||
|
||||
passengers.push({
|
||||
id: `passenger-${Date.now()}-${i}`,
|
||||
name,
|
||||
avatar,
|
||||
originStationId: originStation.id,
|
||||
destinationStationId: destination.id,
|
||||
isUrgent,
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
})
|
||||
}
|
||||
|
||||
return passengers
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if train is at a station (within 3% tolerance)
|
||||
*/
|
||||
export function isTrainAtStation(trainPosition: number, stationPosition: number): boolean {
|
||||
return Math.abs(trainPosition - stationPosition) < 3
|
||||
}
|
||||
|
||||
/**
|
||||
* Find passengers that should board at current position
|
||||
*/
|
||||
export function findBoardablePassengers(
|
||||
passengers: Passenger[],
|
||||
stations: Station[],
|
||||
trainPosition: number
|
||||
): Passenger[] {
|
||||
const boardable: Passenger[] = []
|
||||
|
||||
for (const passenger of passengers) {
|
||||
// Skip if already boarded or delivered
|
||||
if (passenger.isBoarded || passenger.isDelivered) continue
|
||||
|
||||
const station = stations.find((s) => s.id === passenger.originStationId)
|
||||
if (!station) continue
|
||||
|
||||
if (isTrainAtStation(trainPosition, station.position)) {
|
||||
boardable.push(passenger)
|
||||
}
|
||||
}
|
||||
|
||||
return boardable
|
||||
}
|
||||
|
||||
/**
|
||||
* Find passengers that should be delivered at current position
|
||||
*/
|
||||
export function findDeliverablePassengers(
|
||||
passengers: Passenger[],
|
||||
stations: Station[],
|
||||
trainPosition: number
|
||||
): Array<{ passenger: Passenger; station: Station; points: number }> {
|
||||
const deliverable: Array<{
|
||||
passenger: Passenger
|
||||
station: Station
|
||||
points: number
|
||||
}> = []
|
||||
|
||||
for (const passenger of passengers) {
|
||||
// Only check boarded passengers
|
||||
if (!passenger.isBoarded || passenger.isDelivered) continue
|
||||
|
||||
const station = stations.find((s) => s.id === passenger.destinationStationId)
|
||||
if (!station) continue
|
||||
|
||||
if (isTrainAtStation(trainPosition, station.position)) {
|
||||
const points = passenger.isUrgent ? 20 : 10
|
||||
deliverable.push({ passenger, station, points })
|
||||
}
|
||||
}
|
||||
|
||||
return deliverable
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the maximum number of passengers that will be on the train
|
||||
* concurrently at any given moment during the route
|
||||
*/
|
||||
export function calculateMaxConcurrentPassengers(
|
||||
passengers: Passenger[],
|
||||
stations: Station[]
|
||||
): number {
|
||||
// Create events for boarding and delivery
|
||||
interface StationEvent {
|
||||
position: number
|
||||
isBoarding: boolean // true = board, false = delivery
|
||||
}
|
||||
|
||||
const events: StationEvent[] = []
|
||||
|
||||
for (const passenger of passengers) {
|
||||
const originStation = stations.find((s) => s.id === passenger.originStationId)
|
||||
const destStation = stations.find((s) => s.id === passenger.destinationStationId)
|
||||
|
||||
if (originStation && destStation) {
|
||||
events.push({ position: originStation.position, isBoarding: true })
|
||||
events.push({ position: destStation.position, isBoarding: false })
|
||||
}
|
||||
}
|
||||
|
||||
// Sort events by position, with deliveries before boardings at the same position
|
||||
events.sort((a, b) => {
|
||||
if (a.position !== b.position) return a.position - b.position
|
||||
// At same position, deliveries happen before boarding
|
||||
return a.isBoarding ? 1 : -1
|
||||
})
|
||||
|
||||
// Track current passenger count and maximum
|
||||
let currentCount = 0
|
||||
let maxCount = 0
|
||||
|
||||
for (const event of events) {
|
||||
if (event.isBoarding) {
|
||||
currentCount++
|
||||
maxCount = Math.max(maxCount, currentCount)
|
||||
} else {
|
||||
currentCount--
|
||||
}
|
||||
}
|
||||
|
||||
return maxCount
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
/**
|
||||
* Route themes for Steam Train Journey
|
||||
* Each route has a unique name and emoji to make the journey feel varied
|
||||
*/
|
||||
|
||||
export const ROUTE_THEMES = [
|
||||
{ name: 'Prairie Express', emoji: '🌾' },
|
||||
{ name: 'Mountain Climb', emoji: '⛰️' },
|
||||
{ name: 'Coastal Run', emoji: '🌊' },
|
||||
{ name: 'Desert Crossing', emoji: '🏜️' },
|
||||
{ name: 'Forest Trail', emoji: '🌲' },
|
||||
{ name: 'Canyon Route', emoji: '🏞️' },
|
||||
{ name: 'River Valley', emoji: '🏞️' },
|
||||
{ name: 'Highland Pass', emoji: '🗻' },
|
||||
{ name: 'Lakeside Journey', emoji: '🏔️' },
|
||||
{ name: 'Grand Circuit', emoji: '🎪' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Get route theme for a given route number
|
||||
* Cycles through themes if route number exceeds available themes
|
||||
*/
|
||||
export function getRouteTheme(routeNumber: number): {
|
||||
name: string
|
||||
emoji: string
|
||||
} {
|
||||
const index = (routeNumber - 1) % ROUTE_THEMES.length
|
||||
return ROUTE_THEMES[index]
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { ComplementRaceGame } from './components/ComplementRaceGame'
|
||||
import { ComplementRaceProvider } from './context/ComplementRaceContext'
|
||||
|
||||
export default function ComplementRacePage() {
|
||||
return (
|
||||
<PageWithNav navTitle="Speed Complement Race" navEmoji="🏁" gameName="complement-race">
|
||||
<ComplementRaceProvider>
|
||||
<ComplementRaceGame />
|
||||
</ComplementRaceProvider>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { ComplementRaceGame } from '../components/ComplementRaceGame'
|
||||
import { ComplementRaceProvider } from '../context/ComplementRaceContext'
|
||||
|
||||
export default function PracticeModePage() {
|
||||
return (
|
||||
<PageWithNav navTitle="Practice Mode" navEmoji="🏁" gameName="complement-race">
|
||||
<ComplementRaceProvider initialStyle="practice">
|
||||
<ComplementRaceGame />
|
||||
</ComplementRaceProvider>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { ComplementRaceGame } from '../components/ComplementRaceGame'
|
||||
import { ComplementRaceProvider } from '../context/ComplementRaceContext'
|
||||
|
||||
export default function SprintModePage() {
|
||||
return (
|
||||
<PageWithNav navTitle="Steam Sprint" navEmoji="🚂" gameName="complement-race">
|
||||
<ComplementRaceProvider initialStyle="sprint">
|
||||
<ComplementRaceGame />
|
||||
</ComplementRaceProvider>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { ComplementRaceGame } from '../components/ComplementRaceGame'
|
||||
import { ComplementRaceProvider } from '../context/ComplementRaceContext'
|
||||
|
||||
export default function SurvivalModePage() {
|
||||
return (
|
||||
<PageWithNav navTitle="Survival Mode" navEmoji="🔄" gameName="complement-race">
|
||||
<ComplementRaceProvider initialStyle="survival">
|
||||
<ComplementRaceGame />
|
||||
</ComplementRaceProvider>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,16 +16,11 @@ function GamesPageContent() {
|
||||
const router = useRouter()
|
||||
|
||||
// Get all players sorted by creation time
|
||||
const allPlayers = getAllPlayers().sort((a, b) => a.createdAt - b.createdAt)
|
||||
|
||||
const _handleGameClick = (gameType: string) => {
|
||||
// Navigate directly to games using the centralized game mode with Next.js router
|
||||
// Note: battle-arena has been removed - now handled by game registry as "matching"
|
||||
console.log('🔄 GamesPage: Navigating with Next.js router (no page reload)')
|
||||
if (gameType === 'memory-quiz') {
|
||||
router.push('/games/memory-quiz')
|
||||
}
|
||||
}
|
||||
const allPlayers = getAllPlayers().sort((a, b) => {
|
||||
const aTime = a.createdAt instanceof Date ? a.createdAt.getTime() : a.createdAt
|
||||
const bTime = b.createdAt instanceof Date ? b.createdAt.getTime() : b.createdAt
|
||||
return aTime - bTime
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -231,7 +231,7 @@ export default function JoinRoomPage({ params }: { params: { code: string } }) {
|
||||
password: roomPassword,
|
||||
})
|
||||
// Navigate to the game
|
||||
router.push('/arcade/room')
|
||||
router.push('/arcade')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to join room')
|
||||
setIsJoining(false)
|
||||
@@ -261,7 +261,7 @@ export default function JoinRoomPage({ params }: { params: { code: string } }) {
|
||||
|
||||
// If user is already in this exact room, just navigate to game
|
||||
if (roomData && roomData.id === room.id) {
|
||||
router.push('/arcade/room')
|
||||
router.push('/arcade')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -313,7 +313,7 @@ export default function JoinRoomPage({ params }: { params: { code: string } }) {
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
router.push('/arcade/room') // Stay in current room
|
||||
router.push('/arcade') // Stay in current room
|
||||
}
|
||||
|
||||
const handlePasswordSubmit = () => {
|
||||
|
||||
465
apps/web/src/app/levels/page.tsx
Normal file
465
apps/web/src/app/levels/page.tsx
Normal file
@@ -0,0 +1,465 @@
|
||||
'use client'
|
||||
|
||||
import { useRef, useState } from 'react'
|
||||
import { useSpring, animated } from '@react-spring/web'
|
||||
import { AbacusReact } from '@soroban/abacus-react'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { css } from '../../../styled-system/css'
|
||||
import { container, stack } from '../../../styled-system/patterns'
|
||||
|
||||
// Combine all levels into one array for the slider
|
||||
const allLevels = [
|
||||
{ level: '10th Kyu', emoji: '🧒', color: 'green', digits: 2, type: 'kyu' as const },
|
||||
{ level: '9th Kyu', emoji: '🧒', color: 'green', digits: 2, type: 'kyu' as const },
|
||||
{ level: '8th Kyu', emoji: '🧒', color: 'green', digits: 3, type: 'kyu' as const },
|
||||
{ level: '7th Kyu', emoji: '🧒', color: 'green', digits: 4, type: 'kyu' as const },
|
||||
{ level: '6th Kyu', emoji: '🧑', color: 'blue', digits: 5, type: 'kyu' as const },
|
||||
{ level: '5th Kyu', emoji: '🧑', color: 'blue', digits: 6, type: 'kyu' as const },
|
||||
{ level: '4th Kyu', emoji: '🧑', color: 'blue', digits: 7, type: 'kyu' as const },
|
||||
{ level: '3rd Kyu', emoji: '🧔', color: 'violet', digits: 8, type: 'kyu' as const },
|
||||
{ level: '2nd Kyu', emoji: '🧔', color: 'violet', digits: 9, type: 'kyu' as const },
|
||||
{ level: '1st Kyu', emoji: '🧔', color: 'violet', digits: 10, type: 'kyu' as const },
|
||||
{
|
||||
level: 'Pre-1st Dan',
|
||||
name: 'Jun-Shodan',
|
||||
minScore: 90,
|
||||
emoji: '🧙',
|
||||
color: 'amber',
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
},
|
||||
{
|
||||
level: '1st Dan',
|
||||
name: 'Shodan',
|
||||
minScore: 100,
|
||||
emoji: '🧙',
|
||||
color: 'amber',
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
},
|
||||
{
|
||||
level: '2nd Dan',
|
||||
name: 'Nidan',
|
||||
minScore: 120,
|
||||
emoji: '🧙♂️',
|
||||
color: 'amber',
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
},
|
||||
{
|
||||
level: '3rd Dan',
|
||||
name: 'Sandan',
|
||||
minScore: 140,
|
||||
emoji: '🧙♂️',
|
||||
color: 'amber',
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
},
|
||||
{
|
||||
level: '4th Dan',
|
||||
name: 'Yondan',
|
||||
minScore: 160,
|
||||
emoji: '🧙♀️',
|
||||
color: 'amber',
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
},
|
||||
{
|
||||
level: '5th Dan',
|
||||
name: 'Godan',
|
||||
minScore: 180,
|
||||
emoji: '🧙♀️',
|
||||
color: 'amber',
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
},
|
||||
{
|
||||
level: '6th Dan',
|
||||
name: 'Rokudan',
|
||||
minScore: 200,
|
||||
emoji: '🧝',
|
||||
color: 'amber',
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
},
|
||||
{
|
||||
level: '7th Dan',
|
||||
name: 'Nanadan',
|
||||
minScore: 220,
|
||||
emoji: '🧝',
|
||||
color: 'amber',
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
},
|
||||
{
|
||||
level: '8th Dan',
|
||||
name: 'Hachidan',
|
||||
minScore: 250,
|
||||
emoji: '🧝♂️',
|
||||
color: 'amber',
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
},
|
||||
{
|
||||
level: '9th Dan',
|
||||
name: 'Kudan',
|
||||
minScore: 270,
|
||||
emoji: '🧝♀️',
|
||||
color: 'amber',
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
},
|
||||
{
|
||||
level: '10th Dan',
|
||||
name: 'Judan',
|
||||
minScore: 290,
|
||||
emoji: '👑',
|
||||
color: 'amber',
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
},
|
||||
] as const
|
||||
|
||||
export default function LevelsPage() {
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const sliderRef = useRef<HTMLInputElement>(null)
|
||||
const currentLevel = allLevels[currentIndex]
|
||||
|
||||
// Calculate scale factor based on number of columns to fit the page
|
||||
// Smaller scale for more columns (Dan levels with 30 columns)
|
||||
const scaleFactor = Math.min(2.5, 20 / currentLevel.digits)
|
||||
|
||||
// Animate scale factor with React Spring for smooth transitions
|
||||
const animatedProps = useSpring({
|
||||
scaleFactor,
|
||||
config: { tension: 280, friction: 60 },
|
||||
})
|
||||
|
||||
// Handle mouse/touch move over slider for hover interaction
|
||||
const handleSliderHover = (
|
||||
e: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>
|
||||
) => {
|
||||
if (!sliderRef.current) return
|
||||
|
||||
const rect = sliderRef.current.getBoundingClientRect()
|
||||
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX
|
||||
const x = clientX - rect.left
|
||||
const percentage = Math.max(0, Math.min(1, x / rect.width))
|
||||
const newIndex = Math.round(percentage * (allLevels.length - 1))
|
||||
|
||||
if (newIndex !== currentIndex) {
|
||||
setCurrentIndex(newIndex)
|
||||
}
|
||||
}
|
||||
|
||||
// Generate an interesting non-zero number to display on the abacus
|
||||
// Use a suffix pattern so rightmost digits stay constant as columns increase
|
||||
// This prevents beads from shifting: ones always 9, tens always 8, etc.
|
||||
const digitPattern = '123456789'
|
||||
// Use BigInt for numbers > 15 digits (Dan levels with 30 columns)
|
||||
const repeatedPattern = digitPattern.repeat(Math.ceil(currentLevel.digits / digitPattern.length))
|
||||
const digitString = repeatedPattern.slice(-currentLevel.digits)
|
||||
|
||||
// Use BigInt for large numbers to get full 30-digit precision
|
||||
const displayValue =
|
||||
currentLevel.digits > 15 ? BigInt(digitString) : Number.parseInt(digitString, 10)
|
||||
|
||||
// Dark theme styles matching the homepage
|
||||
const darkStyles = {
|
||||
columnPosts: {
|
||||
fill: 'rgba(255, 255, 255, 0.3)',
|
||||
stroke: 'rgba(255, 255, 255, 0.2)',
|
||||
strokeWidth: 2,
|
||||
},
|
||||
reckoningBar: {
|
||||
fill: 'rgba(255, 255, 255, 0.4)',
|
||||
stroke: 'rgba(255, 255, 255, 0.25)',
|
||||
strokeWidth: 3,
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<PageWithNav navTitle="Kyu & Dan Levels" navEmoji="📊">
|
||||
<div className={css({ bg: 'gray.900', minHeight: '100vh', pb: '12' })}>
|
||||
{/* Hero Section */}
|
||||
<div
|
||||
className={css({
|
||||
background:
|
||||
'linear-gradient(135deg, rgba(17, 24, 39, 1) 0%, rgba(124, 58, 237, 0.3) 50%, rgba(17, 24, 39, 1) 100%)',
|
||||
color: 'white',
|
||||
py: { base: '12', md: '16' },
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
opacity: 0.1,
|
||||
backgroundImage:
|
||||
'radial-gradient(circle at 2px 2px, rgba(255, 255, 255, 0.15) 1px, transparent 0)',
|
||||
backgroundSize: '40px 40px',
|
||||
})}
|
||||
/>
|
||||
|
||||
<div className={container({ maxW: '6xl', px: '4', position: 'relative' })}>
|
||||
<div className={css({ textAlign: 'center', maxW: '5xl', mx: 'auto' })}>
|
||||
<h1
|
||||
className={css({
|
||||
fontSize: { base: '3xl', md: '5xl', lg: '6xl' },
|
||||
fontWeight: 'bold',
|
||||
mb: '4',
|
||||
lineHeight: 'tight',
|
||||
background: 'linear-gradient(135deg, #fbbf24 0%, #f59e0b 50%, #fbbf24 100%)',
|
||||
backgroundClip: 'text',
|
||||
color: 'transparent',
|
||||
})}
|
||||
>
|
||||
Understanding Kyu & Dan Levels
|
||||
</h1>
|
||||
|
||||
<p
|
||||
className={css({
|
||||
fontSize: { base: 'lg', md: 'xl' },
|
||||
color: 'gray.300',
|
||||
mb: '6',
|
||||
maxW: '3xl',
|
||||
mx: 'auto',
|
||||
lineHeight: '1.6',
|
||||
})}
|
||||
>
|
||||
Slide through the complete progression from beginner to master
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className={container({ maxW: '6xl', px: '4', py: '12' })}>
|
||||
<section className={stack({ gap: '8' })}>
|
||||
{/* Current Level Display */}
|
||||
<div
|
||||
className={css({
|
||||
bg: 'rgba(0, 0, 0, 0.4)',
|
||||
border: '2px solid',
|
||||
borderColor:
|
||||
currentLevel.color === 'green'
|
||||
? 'green.500'
|
||||
: currentLevel.color === 'blue'
|
||||
? 'blue.500'
|
||||
: currentLevel.color === 'violet'
|
||||
? 'violet.500'
|
||||
: 'amber.500',
|
||||
rounded: 'xl',
|
||||
p: { base: '6', md: '8' },
|
||||
height: { base: 'auto', md: '900px' },
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
})}
|
||||
>
|
||||
{/* Level Info */}
|
||||
<div
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
mb: '4',
|
||||
height: '160px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
})}
|
||||
>
|
||||
<div className={css({ fontSize: '5xl', mb: '3' })}>{currentLevel.emoji}</div>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: { base: '2xl', md: '3xl' },
|
||||
fontWeight: 'bold',
|
||||
color:
|
||||
currentLevel.color === 'green'
|
||||
? 'green.400'
|
||||
: currentLevel.color === 'blue'
|
||||
? 'blue.400'
|
||||
: currentLevel.color === 'violet'
|
||||
? 'violet.400'
|
||||
: 'amber.400',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
{currentLevel.level}
|
||||
</h2>
|
||||
{'name' in currentLevel && (
|
||||
<div className={css({ fontSize: 'md', color: 'gray.400', mb: '1' })}>
|
||||
{currentLevel.name}
|
||||
</div>
|
||||
)}
|
||||
{'minScore' in currentLevel && (
|
||||
<div className={css({ fontSize: 'sm', color: 'gray.500' })}>
|
||||
Minimum Score: {currentLevel.minScore} points
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Range Slider with hover support */}
|
||||
<div className={css({ mb: '6', px: { base: '2', md: '8' } })}>
|
||||
<div className={css({ mb: '3', textAlign: 'center' })}>
|
||||
<p className={css({ fontSize: 'sm', color: 'gray.400' })}>
|
||||
Hover, drag, or touch the slider to explore all levels
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
onMouseMove={handleSliderHover}
|
||||
onTouchMove={handleSliderHover}
|
||||
className={css({ position: 'relative' })}
|
||||
>
|
||||
<input
|
||||
ref={sliderRef}
|
||||
type="range"
|
||||
min="0"
|
||||
max={allLevels.length - 1}
|
||||
value={currentIndex}
|
||||
onChange={(e) => setCurrentIndex(Number(e.target.value))}
|
||||
className={css({
|
||||
w: '100%',
|
||||
h: '2',
|
||||
bg: 'gray.700',
|
||||
rounded: 'full',
|
||||
outline: 'none',
|
||||
cursor: 'pointer',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Level Markers */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
mt: '3',
|
||||
fontSize: 'xs',
|
||||
color: 'gray.500',
|
||||
})}
|
||||
>
|
||||
<span>10th Kyu</span>
|
||||
<span>1st Kyu</span>
|
||||
<span>10th Dan</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Abacus Display */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
p: '6',
|
||||
bg: 'rgba(0, 0, 0, 0.3)',
|
||||
rounded: 'lg',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.700',
|
||||
overflow: 'hidden',
|
||||
flex: 1,
|
||||
})}
|
||||
>
|
||||
<animated.div
|
||||
style={{
|
||||
transform: animatedProps.scaleFactor.to((s) => `scale(${s / scaleFactor})`),
|
||||
}}
|
||||
>
|
||||
<AbacusReact
|
||||
value={displayValue}
|
||||
columns={currentLevel.digits}
|
||||
scaleFactor={scaleFactor}
|
||||
showNumbers={true}
|
||||
customStyles={darkStyles}
|
||||
/>
|
||||
</animated.div>
|
||||
</div>
|
||||
|
||||
{/* Digit Count */}
|
||||
<div className={css({ textAlign: 'center', color: 'gray.400', fontSize: 'sm' })}>
|
||||
Requires mastery of <strong>{currentLevel.digits}-digit</strong> calculations
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '6',
|
||||
justifyContent: 'center',
|
||||
p: '6',
|
||||
bg: 'rgba(0, 0, 0, 0.3)',
|
||||
rounded: 'lg',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.700',
|
||||
})}
|
||||
>
|
||||
<div className={css({ display: 'flex', alignItems: 'center', gap: '2' })}>
|
||||
<div className={css({ w: '4', h: '4', bg: 'green.500', rounded: 'sm' })} />
|
||||
<span className={css({ fontSize: 'sm', color: 'gray.300' })}>
|
||||
Beginner (10-7 Kyu)
|
||||
</span>
|
||||
</div>
|
||||
<div className={css({ display: 'flex', alignItems: 'center', gap: '2' })}>
|
||||
<div className={css({ w: '4', h: '4', bg: 'blue.500', rounded: 'sm' })} />
|
||||
<span className={css({ fontSize: 'sm', color: 'gray.300' })}>
|
||||
Intermediate (6-4 Kyu)
|
||||
</span>
|
||||
</div>
|
||||
<div className={css({ display: 'flex', alignItems: 'center', gap: '2' })}>
|
||||
<div className={css({ w: '4', h: '4', bg: 'violet.500', rounded: 'sm' })} />
|
||||
<span className={css({ fontSize: 'sm', color: 'gray.300' })}>
|
||||
Advanced (3-1 Kyu)
|
||||
</span>
|
||||
</div>
|
||||
<div className={css({ display: 'flex', alignItems: 'center', gap: '2' })}>
|
||||
<div className={css({ w: '4', h: '4', bg: 'amber.500', rounded: 'sm' })} />
|
||||
<span className={css({ fontSize: 'sm', color: 'gray.300' })}>
|
||||
Master (Dan ranks)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Section */}
|
||||
<div
|
||||
className={css({
|
||||
bg: 'rgba(0, 0, 0, 0.4)',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.700',
|
||||
rounded: 'xl',
|
||||
p: { base: '6', md: '8' },
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: { base: 'xl', md: '2xl' },
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
mb: '4',
|
||||
})}
|
||||
>
|
||||
About This Ranking System
|
||||
</h3>
|
||||
<div className={stack({ gap: '4' })}>
|
||||
<p className={css({ color: 'gray.300', lineHeight: '1.6' })}>
|
||||
This ranking system is based on the official examination structure used by the{' '}
|
||||
<strong className={css({ color: 'white' })}>Japan Abacus Federation</strong>. It
|
||||
represents a standardized progression from beginner (10th Kyu) to master level
|
||||
(10th Dan), used internationally for soroban proficiency assessment.
|
||||
</p>
|
||||
<p className={css({ color: 'gray.300', lineHeight: '1.6' })}>
|
||||
The system is designed to gradually increase in difficulty. Kyu levels progress
|
||||
from 2-digit calculations at 10th Kyu to 10-digit calculations at 1st Kyu. Dan
|
||||
levels all require mastery of 30-digit calculations, with ranks awarded based on
|
||||
exam scores.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
@@ -1,185 +1,867 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { AbacusReact, useAbacusConfig } from '@soroban/abacus-react'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { TutorialPlayer } from '@/components/tutorial/TutorialPlayer'
|
||||
import { getTutorialForEditor } from '@/utils/tutorialConverter'
|
||||
import { css } from '../../styled-system/css'
|
||||
import { container, hstack, stack } from '../../styled-system/patterns'
|
||||
import { container, grid, hstack, stack } from '../../styled-system/patterns'
|
||||
import { token } from '../../styled-system/tokens'
|
||||
|
||||
// Mini abacus that cycles through random 3-digit numbers
|
||||
function MiniAbacus() {
|
||||
const [currentValue, setCurrentValue] = useState(123)
|
||||
const appConfig = useAbacusConfig()
|
||||
|
||||
useEffect(() => {
|
||||
// Cycle through random 3-digit numbers every 2.5 seconds
|
||||
const interval = setInterval(() => {
|
||||
const randomNum = Math.floor(Math.random() * 1000) // 0-999
|
||||
setCurrentValue(randomNum)
|
||||
}, 2500)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
// Dark theme styles for the abacus
|
||||
const darkStyles = {
|
||||
columnPosts: {
|
||||
fill: 'rgba(255, 255, 255, 0.3)',
|
||||
stroke: 'rgba(255, 255, 255, 0.2)',
|
||||
strokeWidth: 2,
|
||||
},
|
||||
reckoningBar: {
|
||||
fill: 'rgba(255, 255, 255, 0.4)',
|
||||
stroke: 'rgba(255, 255, 255, 0.25)',
|
||||
strokeWidth: 3,
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
width: '75px',
|
||||
height: '80px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
})}
|
||||
>
|
||||
<div className={css({ transform: 'scale(0.6)', transformOrigin: 'center center' })}>
|
||||
<AbacusReact
|
||||
value={currentValue}
|
||||
columns={3}
|
||||
beadShape={appConfig.beadShape}
|
||||
customStyles={darkStyles}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
// Extract just the "Friends of 5" step (2+3=5) for homepage demo
|
||||
const fullTutorial = getTutorialForEditor()
|
||||
const friendsOf5Tutorial = {
|
||||
...fullTutorial,
|
||||
id: 'friends-of-5-demo',
|
||||
title: 'Friends of 5',
|
||||
description: 'Learn the "Friends of 5" technique: adding 3 to make 5',
|
||||
steps: fullTutorial.steps.filter((step) => step.id === 'complement-2'),
|
||||
}
|
||||
|
||||
return (
|
||||
<PageWithNav navTitle="Soroban Flashcards" navEmoji="🧮">
|
||||
<div
|
||||
className={css({
|
||||
minHeight: '100vh',
|
||||
bg: 'gradient-to-br from-brand.50 to-brand.100',
|
||||
})}
|
||||
>
|
||||
<PageWithNav navTitle="Soroban Learning Platform" navEmoji="🧮">
|
||||
<div className={css({ bg: 'gray.900', minHeight: '100vh' })}>
|
||||
{/* Hero Section */}
|
||||
<main className={container({ maxW: '6xl', px: '4' })}>
|
||||
<div
|
||||
className={css({
|
||||
background:
|
||||
'linear-gradient(135deg, rgba(17, 24, 39, 1) 0%, rgba(88, 28, 135, 0.3) 50%, rgba(17, 24, 39, 1) 100%)',
|
||||
color: 'white',
|
||||
py: { base: '12', md: '16' },
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
})}
|
||||
>
|
||||
{/* Background pattern */}
|
||||
<div
|
||||
className={stack({
|
||||
gap: '12',
|
||||
py: '16',
|
||||
align: 'center',
|
||||
textAlign: 'center',
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
opacity: 0.1,
|
||||
backgroundImage:
|
||||
'radial-gradient(circle at 2px 2px, rgba(255, 255, 255, 0.15) 1px, transparent 0)',
|
||||
backgroundSize: '40px 40px',
|
||||
})}
|
||||
>
|
||||
{/* Hero Content */}
|
||||
<div className={stack({ gap: '6', maxW: '4xl' })}>
|
||||
/>
|
||||
|
||||
<div className={container({ maxW: '6xl', px: '4', position: 'relative' })}>
|
||||
<div className={css({ textAlign: 'center', maxW: '5xl', mx: 'auto' })}>
|
||||
{/* Main headline */}
|
||||
<h1
|
||||
className={css({
|
||||
fontSize: { base: '4xl', md: '6xl' },
|
||||
fontSize: { base: '3xl', md: '5xl', lg: '6xl' },
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.900',
|
||||
mb: '4',
|
||||
lineHeight: 'tight',
|
||||
background: 'linear-gradient(135deg, #fbbf24 0%, #f59e0b 50%, #fbbf24 100%)',
|
||||
backgroundClip: 'text',
|
||||
color: 'transparent',
|
||||
})}
|
||||
>
|
||||
Beautiful Soroban <span className={css({ color: 'brand.600' })}>Flashcards</span>
|
||||
A structured path to soroban fluency
|
||||
</h1>
|
||||
|
||||
{/* Subtitle */}
|
||||
<p
|
||||
className={css({
|
||||
fontSize: { base: 'lg', md: 'xl' },
|
||||
color: 'gray.600',
|
||||
maxW: '2xl',
|
||||
color: 'gray.300',
|
||||
mb: '6',
|
||||
maxW: '3xl',
|
||||
mx: 'auto',
|
||||
lineHeight: '1.6',
|
||||
})}
|
||||
>
|
||||
Create stunning, educational flashcards with authentic Japanese abacus
|
||||
representations. Perfect for teachers, students, and mental math enthusiasts.
|
||||
Designed for self-directed learning. Start where you are, practice the skills you
|
||||
need, play games that reinforce concepts.
|
||||
</p>
|
||||
|
||||
<div className={hstack({ gap: '4', justify: 'center', mt: '8' })}>
|
||||
<Link
|
||||
href="/create"
|
||||
className={css({
|
||||
px: '8',
|
||||
py: '4',
|
||||
bg: 'brand.600',
|
||||
color: 'white',
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'semibold',
|
||||
rounded: 'xl',
|
||||
shadow: 'card',
|
||||
transition: 'all',
|
||||
_hover: {
|
||||
bg: 'brand.700',
|
||||
transform: 'translateY(-2px)',
|
||||
shadow: 'modal',
|
||||
},
|
||||
})}
|
||||
>
|
||||
✨ Start Creating →
|
||||
</Link>
|
||||
{/* Dev status badge */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'inline-block',
|
||||
px: '4',
|
||||
py: '2',
|
||||
bg: 'rgba(139, 92, 246, 0.15)',
|
||||
border: '1px solid rgba(139, 92, 246, 0.3)',
|
||||
borderRadius: 'full',
|
||||
fontSize: 'sm',
|
||||
color: 'purple.300',
|
||||
mb: '8',
|
||||
})}
|
||||
>
|
||||
🏗️ Curriculum system in active development
|
||||
</div>
|
||||
|
||||
{/* Visual learning journey */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: '2',
|
||||
mb: '8',
|
||||
flexWrap: 'wrap',
|
||||
})}
|
||||
>
|
||||
{[
|
||||
{ icon: '📖', label: 'Learn' },
|
||||
{ icon: '→', label: '', isArrow: true },
|
||||
{ icon: '✏️', label: 'Practice' },
|
||||
{ icon: '→', label: '', isArrow: true },
|
||||
{ icon: '🎮', label: 'Play' },
|
||||
{ icon: '→', label: '', isArrow: true },
|
||||
{ icon: '🎯', label: 'Master' },
|
||||
].map((step, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '1',
|
||||
opacity: step.isArrow ? 0.5 : 1,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: step.isArrow ? 'xl' : '2xl',
|
||||
color: step.isArrow ? 'gray.500' : 'yellow.400',
|
||||
})}
|
||||
>
|
||||
{step.icon}
|
||||
</div>
|
||||
{step.label && (
|
||||
<div className={css({ fontSize: 'xs', color: 'gray.400' })}>{step.label}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Primary CTAs */}
|
||||
<div className={hstack({ gap: '4', justify: 'center', flexWrap: 'wrap' })}>
|
||||
<Link
|
||||
href="/guide"
|
||||
className={css({
|
||||
px: '8',
|
||||
py: '4',
|
||||
bg: 'white',
|
||||
color: 'brand.700',
|
||||
bg: 'linear-gradient(135deg, #fbbf24, #f59e0b)',
|
||||
color: 'gray.900',
|
||||
fontWeight: 'bold',
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'semibold',
|
||||
rounded: 'xl',
|
||||
shadow: 'card',
|
||||
border: '2px solid',
|
||||
borderColor: 'brand.200',
|
||||
transition: 'all',
|
||||
shadow: '0 10px 40px rgba(251, 191, 36, 0.3)',
|
||||
_hover: {
|
||||
borderColor: 'brand.400',
|
||||
transform: 'translateY(-2px)',
|
||||
shadow: '0 20px 50px rgba(251, 191, 36, 0.4)',
|
||||
},
|
||||
transition: 'all 0.3s ease',
|
||||
})}
|
||||
>
|
||||
📚 Learn Soroban
|
||||
📚 Start Learning
|
||||
</Link>
|
||||
<Link
|
||||
href="/games"
|
||||
className={css({
|
||||
px: '8',
|
||||
py: '4',
|
||||
bg: 'rgba(139, 92, 246, 0.2)',
|
||||
color: 'white',
|
||||
fontWeight: 'bold',
|
||||
fontSize: 'lg',
|
||||
rounded: 'xl',
|
||||
border: '2px solid',
|
||||
borderColor: 'purple.500',
|
||||
_hover: {
|
||||
bg: 'rgba(139, 92, 246, 0.3)',
|
||||
transform: 'translateY(-2px)',
|
||||
},
|
||||
transition: 'all 0.3s ease',
|
||||
})}
|
||||
>
|
||||
🎮 Practice Through Games
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features Grid */}
|
||||
{/* Main content container */}
|
||||
<div className={container({ maxW: '7xl', px: '4', py: '12' })}>
|
||||
{/* Learn by Doing Section - with inline tutorial demo */}
|
||||
<section className={stack({ gap: '8', mb: '16' })}>
|
||||
<div className={css({ textAlign: 'center' })}>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: { base: '2xl', md: '3xl' },
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
Learn by Doing
|
||||
</h2>
|
||||
<p className={css({ color: 'gray.400', fontSize: 'md', maxW: '2xl', mx: 'auto' })}>
|
||||
Interactive tutorials teach you step-by-step. Try this example right now:
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Live demo and learning objectives */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { base: '1', md: '3' },
|
||||
gap: '8',
|
||||
mt: '16',
|
||||
w: 'full',
|
||||
bg: 'rgba(0, 0, 0, 0.4)',
|
||||
rounded: 'xl',
|
||||
p: '8',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.700',
|
||||
shadow: 'lg',
|
||||
maxW: '1200px',
|
||||
mx: 'auto',
|
||||
})}
|
||||
>
|
||||
<FeatureCard
|
||||
icon="🎨"
|
||||
title="Beautiful Design"
|
||||
description="Vector graphics, color schemes, authentic bead positioning"
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: { base: 'column', md: 'row' },
|
||||
gap: '8',
|
||||
alignItems: { base: 'center', md: 'flex-start' },
|
||||
})}
|
||||
>
|
||||
{/* Tutorial on the left */}
|
||||
<div className={css({ flex: '1' })}>
|
||||
<TutorialPlayer
|
||||
tutorial={friendsOf5Tutorial}
|
||||
isDebugMode={false}
|
||||
showDebugPanel={false}
|
||||
hideNavigation={true}
|
||||
hideTooltip={true}
|
||||
silentErrors={true}
|
||||
abacusColumns={1}
|
||||
theme="dark"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* What you'll learn on the right */}
|
||||
<div
|
||||
className={css({
|
||||
flex: '0 0 auto',
|
||||
minW: '340px',
|
||||
maxW: { base: '100%', md: '420px' },
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: '2xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
mb: '6',
|
||||
})}
|
||||
>
|
||||
What You'll Learn
|
||||
</h3>
|
||||
<div className={stack({ gap: '5' })}>
|
||||
{[
|
||||
{
|
||||
icon: '🔢',
|
||||
title: 'Read and set numbers',
|
||||
desc: 'Master abacus number representation from zero to thousands',
|
||||
example: '0-9999',
|
||||
badge: 'Foundation',
|
||||
},
|
||||
{
|
||||
icon: '🤝',
|
||||
title: 'Friends techniques',
|
||||
desc: 'Add and subtract using complement pairs and mental shortcuts',
|
||||
example: '5 = 2+3',
|
||||
badge: 'Core',
|
||||
},
|
||||
{
|
||||
icon: '✖️➗',
|
||||
title: 'Multiply & divide',
|
||||
desc: 'Fluent multi-digit calculations with advanced techniques',
|
||||
example: '12×34',
|
||||
badge: 'Advanced',
|
||||
},
|
||||
{
|
||||
icon: '🧠',
|
||||
title: 'Mental calculation',
|
||||
desc: 'Visualize and compute without the physical tool (Anzan)',
|
||||
example: 'Speed math',
|
||||
badge: 'Expert',
|
||||
},
|
||||
].map((skill, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={css({
|
||||
bg: 'linear-gradient(135deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.03))',
|
||||
borderRadius: 'xl',
|
||||
p: '4',
|
||||
border: '1px solid',
|
||||
borderColor: 'rgba(255, 255, 255, 0.15)',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
bg: 'linear-gradient(135deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.05))',
|
||||
borderColor: 'rgba(255, 255, 255, 0.25)',
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 6px 16px rgba(0, 0, 0, 0.4)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div className={hstack({ gap: '3', alignItems: 'flex-start' })}>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '3xl',
|
||||
width: '75px',
|
||||
height: '115px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
textAlign: 'center',
|
||||
bg: 'rgba(255, 255, 255, 0.1)',
|
||||
borderRadius: 'lg',
|
||||
})}
|
||||
>
|
||||
{i === 0 ? <MiniAbacus /> : skill.icon}
|
||||
</div>
|
||||
<div className={stack({ gap: '2', flex: '1' })}>
|
||||
<div className={hstack({ gap: '2', alignItems: 'center' })}>
|
||||
<div
|
||||
className={css({
|
||||
color: 'white',
|
||||
fontSize: 'md',
|
||||
fontWeight: 'bold',
|
||||
})}
|
||||
>
|
||||
{skill.title}
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
bg: 'rgba(250, 204, 21, 0.2)',
|
||||
color: 'yellow.400',
|
||||
fontSize: '2xs',
|
||||
fontWeight: 'semibold',
|
||||
px: '2',
|
||||
py: '0.5',
|
||||
borderRadius: 'md',
|
||||
})}
|
||||
>
|
||||
{skill.badge}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
color: 'gray.300',
|
||||
fontSize: 'xs',
|
||||
lineHeight: '1.5',
|
||||
})}
|
||||
>
|
||||
{skill.desc}
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
color: 'yellow.400',
|
||||
fontSize: 'xs',
|
||||
fontFamily: 'mono',
|
||||
fontWeight: 'semibold',
|
||||
mt: '1',
|
||||
bg: 'rgba(250, 204, 21, 0.1)',
|
||||
px: '2',
|
||||
py: '1',
|
||||
borderRadius: 'md',
|
||||
w: 'fit-content',
|
||||
})}
|
||||
>
|
||||
{skill.example}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Current Offerings Section */}
|
||||
<section className={stack({ gap: '6', mb: '16' })}>
|
||||
<div className={css({ textAlign: 'center' })}>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: { base: '2xl', md: '3xl' },
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
Available Now
|
||||
</h2>
|
||||
<p className={css({ color: 'gray.400', fontSize: 'md' })}>
|
||||
Foundation tutorials and reinforcement games ready to use
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={grid({ columns: { base: 1, sm: 2, lg: 4 }, gap: '5' })}>
|
||||
<GameCard
|
||||
icon="🧠"
|
||||
title="Memory Lightning"
|
||||
description="Memorize soroban numbers"
|
||||
players="1-8 players"
|
||||
tags={['Memory', 'Pattern Recognition']}
|
||||
gradient="linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
|
||||
href="/games"
|
||||
/>
|
||||
<FeatureCard
|
||||
icon="⚡"
|
||||
title="Instant Generation"
|
||||
description="Create PDFs, interactive HTML, PNGs, and SVGs in seconds"
|
||||
<GameCard
|
||||
icon="⚔️"
|
||||
title="Matching Pairs"
|
||||
description="Match complement numbers"
|
||||
players="1-4 players"
|
||||
tags={['Friends of 5', 'Friends of 10']}
|
||||
gradient="linear-gradient(135deg, #f093fb 0%, #f5576c 100%)"
|
||||
href="/games"
|
||||
/>
|
||||
<FeatureCard
|
||||
icon="🎯"
|
||||
title="Educational Focus"
|
||||
description="Perfect for teachers, students, and soroban enthusiasts"
|
||||
<GameCard
|
||||
icon="🏁"
|
||||
title="Complement Race"
|
||||
description="Race against time"
|
||||
players="1-4 players"
|
||||
tags={['Speed', 'Practice', 'Survival']}
|
||||
gradient="linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)"
|
||||
href="/games"
|
||||
/>
|
||||
<GameCard
|
||||
icon="🔢"
|
||||
title="Card Sorting"
|
||||
description="Arrange numbers visually"
|
||||
players="Solo challenge"
|
||||
tags={['Visual Literacy', 'Ordering']}
|
||||
gradient="linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)"
|
||||
href="/games"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</section>
|
||||
|
||||
{/* For Kids & Families Section */}
|
||||
<section className={stack({ gap: '6', mb: '16' })}>
|
||||
<div className={css({ textAlign: 'center' })}>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: { base: '2xl', md: '3xl' },
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
For Kids & Families
|
||||
</h2>
|
||||
<p className={css({ color: 'gray.400', fontSize: 'md', maxW: '2xl', mx: 'auto' })}>
|
||||
Simple enough for kids to start on their own, structured enough for parents to trust
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={grid({ columns: { base: 1, lg: 2 }, gap: '8' })}>
|
||||
<FeaturePanel
|
||||
icon="🧒"
|
||||
title="Self-Directed for Children"
|
||||
features={[
|
||||
'Big, obvious buttons and clear instructions',
|
||||
'Progress at your own pace',
|
||||
'Works with or without a physical abacus',
|
||||
]}
|
||||
accentColor="purple"
|
||||
/>
|
||||
<FeaturePanel
|
||||
icon="👨👩👧"
|
||||
title="Trusted by Parents"
|
||||
features={[
|
||||
'Structured curriculum following Japanese methods',
|
||||
'Traditional kyu/dan progression levels',
|
||||
'Track progress and celebrate achievements',
|
||||
]}
|
||||
accentColor="blue"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Progression Visualization */}
|
||||
<section className={stack({ gap: '6', mb: '16' })}>
|
||||
<div className={css({ textAlign: 'center' })}>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: { base: '2xl', md: '3xl' },
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
Your Journey
|
||||
</h2>
|
||||
<p style={{ color: '#e5e7eb', fontSize: '16px' }}>Progress from beginner to master</p>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/levels"
|
||||
className={css({
|
||||
bg: 'rgba(0, 0, 0, 0.4)',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.700',
|
||||
rounded: 'xl',
|
||||
p: '8',
|
||||
display: 'block',
|
||||
transition: 'all 0.2s',
|
||||
cursor: 'pointer',
|
||||
position: 'relative',
|
||||
_hover: {
|
||||
bg: 'rgba(0, 0, 0, 0.5)',
|
||||
borderColor: 'violet.500',
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 8px 16px rgba(124, 58, 237, 0.2)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{/* Subtle arrow indicator */}
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: '4',
|
||||
right: '4',
|
||||
fontSize: 'xl',
|
||||
color: 'gray.500',
|
||||
transition: 'all 0.2s',
|
||||
_groupHover: {
|
||||
color: 'violet.400',
|
||||
transform: 'translateX(4px)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
→
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: '4',
|
||||
flexWrap: 'wrap',
|
||||
})}
|
||||
>
|
||||
{(
|
||||
[
|
||||
{ level: '10 Kyu', label: 'Beginner', color: 'colors.green.400', emoji: '🧒' },
|
||||
{
|
||||
level: '5 Kyu',
|
||||
label: 'Intermediate',
|
||||
color: 'colors.blue.400',
|
||||
emoji: '🧑',
|
||||
},
|
||||
{ level: '1 Kyu', label: 'Advanced', color: 'colors.violet.400', emoji: '🧔' },
|
||||
{ level: 'Dan', label: 'Master', color: 'colors.amber.400', emoji: '🧙' },
|
||||
] as const
|
||||
).map((stage, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={stack({
|
||||
gap: '0',
|
||||
textAlign: 'center',
|
||||
flex: '1',
|
||||
position: 'relative',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '5xl',
|
||||
mb: '0',
|
||||
})}
|
||||
>
|
||||
{stage.emoji}
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
mt: '-2',
|
||||
})}
|
||||
style={{ color: token(stage.color) }}
|
||||
>
|
||||
{stage.level}
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'gray.300',
|
||||
})}
|
||||
>
|
||||
{stage.label}
|
||||
</div>
|
||||
{i < 3 && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '100%',
|
||||
marginLeft: '0.5rem',
|
||||
top: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
fontSize: '20px',
|
||||
color: '#9ca3af',
|
||||
}}
|
||||
className={css({
|
||||
display: { base: 'none', md: 'block' },
|
||||
})}
|
||||
>
|
||||
→
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
fontSize: '14px',
|
||||
color: '#d1d5db',
|
||||
fontStyle: 'italic',
|
||||
}}
|
||||
className={css({
|
||||
mt: '6',
|
||||
})}
|
||||
>
|
||||
Click to learn about the official Japanese ranking system →
|
||||
</div>
|
||||
</Link>
|
||||
</section>
|
||||
|
||||
{/* Additional Tools Section */}
|
||||
<section className={stack({ gap: '6' })}>
|
||||
<div className={css({ textAlign: 'center' })}>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: { base: '2xl', md: '3xl' },
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
Additional Tools
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className={grid({ columns: { base: 1, lg: 2 }, gap: '8' })}>
|
||||
<FeaturePanel
|
||||
icon="🎨"
|
||||
title="Flashcard Creator"
|
||||
features={[
|
||||
'Multiple formats: PDF, PNG, SVG, HTML',
|
||||
'Custom bead shapes, colors, and layouts',
|
||||
'All paper sizes: A3, A4, A5, US Letter',
|
||||
]}
|
||||
accentColor="blue"
|
||||
ctaText="Create Flashcards →"
|
||||
ctaHref="/create"
|
||||
/>
|
||||
<FeaturePanel
|
||||
icon="🧮"
|
||||
title="Interactive Abacus"
|
||||
features={[
|
||||
'Practice anytime in your browser',
|
||||
'Multiple color schemes and bead styles',
|
||||
'Sound effects and animations',
|
||||
]}
|
||||
accentColor="purple"
|
||||
ctaText="Try the Abacus →"
|
||||
ctaHref="/guide"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
|
||||
function FeatureCard({
|
||||
function GameCard({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
players,
|
||||
tags,
|
||||
gradient,
|
||||
href,
|
||||
}: {
|
||||
icon: string
|
||||
title: string
|
||||
description: string
|
||||
players: string
|
||||
tags: string[]
|
||||
gradient: string
|
||||
href: string
|
||||
}) {
|
||||
return (
|
||||
<Link href={href}>
|
||||
<div
|
||||
className={css({
|
||||
background: gradient,
|
||||
rounded: 'xl',
|
||||
p: '6',
|
||||
shadow: 'lg',
|
||||
transition: 'all 0.3s ease',
|
||||
cursor: 'pointer',
|
||||
_hover: {
|
||||
transform: 'translateY(-6px) scale(1.02)',
|
||||
shadow: '0 25px 50px rgba(0, 0, 0, 0.3)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div className={css({ fontSize: '3xl', mb: '3' })}>{icon}</div>
|
||||
<h3 className={css({ fontSize: 'lg', fontWeight: 'bold', color: 'white', mb: '2' })}>
|
||||
{title}
|
||||
</h3>
|
||||
<p className={css({ fontSize: 'sm', color: 'rgba(255, 255, 255, 0.9)', mb: '2' })}>
|
||||
{description}
|
||||
</p>
|
||||
<p className={css({ fontSize: 'xs', color: 'rgba(255, 255, 255, 0.7)', mb: '3' })}>
|
||||
{players}
|
||||
</p>
|
||||
<div className={hstack({ gap: '2', flexWrap: 'wrap' })}>
|
||||
{tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className={css({
|
||||
fontSize: 'xs',
|
||||
px: '2',
|
||||
py: '1',
|
||||
bg: 'rgba(255, 255, 255, 0.2)',
|
||||
color: 'white',
|
||||
rounded: 'full',
|
||||
fontWeight: 'semibold',
|
||||
})}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function FeaturePanel({
|
||||
icon,
|
||||
title,
|
||||
features,
|
||||
accentColor,
|
||||
ctaText,
|
||||
ctaHref,
|
||||
}: {
|
||||
icon: string
|
||||
title: string
|
||||
features: string[]
|
||||
accentColor: 'purple' | 'blue'
|
||||
ctaText?: string
|
||||
ctaHref?: string
|
||||
}) {
|
||||
const borderColor = accentColor === 'purple' ? 'purple.500/30' : 'blue.500/30'
|
||||
const bgColor = accentColor === 'purple' ? 'purple.500/10' : 'blue.500/10'
|
||||
const hoverBg = accentColor === 'purple' ? 'purple.500/20' : 'blue.500/20'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
bg: 'rgba(0, 0, 0, 0.4)',
|
||||
rounded: 'xl',
|
||||
p: '8',
|
||||
bg: 'white',
|
||||
rounded: '2xl',
|
||||
shadow: 'card',
|
||||
textAlign: 'center',
|
||||
transition: 'all',
|
||||
_hover: {
|
||||
transform: 'translateY(-4px)',
|
||||
shadow: 'modal',
|
||||
},
|
||||
border: '2px solid',
|
||||
borderColor,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '4xl',
|
||||
mb: '4',
|
||||
})}
|
||||
>
|
||||
{icon}
|
||||
<div className={hstack({ gap: '3', mb: '4' })}>
|
||||
<span className={css({ fontSize: '3xl' })}>{icon}</span>
|
||||
<h2 className={css({ fontSize: '2xl', fontWeight: 'bold', color: 'white' })}>{title}</h2>
|
||||
</div>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.900',
|
||||
mb: '3',
|
||||
})}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
<p
|
||||
className={css({
|
||||
color: 'gray.600',
|
||||
lineHeight: 'relaxed',
|
||||
})}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
<div className={stack({ gap: '3', mb: ctaText ? '6' : '0' })}>
|
||||
{features.map((feature, i) => (
|
||||
<div key={i} className={hstack({ gap: '3' })}>
|
||||
<span className={css({ color: 'yellow.400', fontSize: 'lg' })}>✓</span>
|
||||
<span className={css({ color: 'gray.300', fontSize: 'sm' })}>{feature}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{ctaText && ctaHref && (
|
||||
<Link
|
||||
href={ctaHref}
|
||||
className={css({
|
||||
display: 'block',
|
||||
textAlign: 'center',
|
||||
py: '3',
|
||||
px: '6',
|
||||
bg: bgColor,
|
||||
color: 'white',
|
||||
fontWeight: 'bold',
|
||||
rounded: 'lg',
|
||||
border: '2px solid',
|
||||
borderColor,
|
||||
_hover: { bg: hoverBg },
|
||||
transition: 'all 0.2s ease',
|
||||
})}
|
||||
>
|
||||
{ctaText}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
569
apps/web/src/arcade-games/card-sorting/Provider.tsx
Normal file
569
apps/web/src/arcade-games/card-sorting/Provider.tsx
Normal file
@@ -0,0 +1,569 @@
|
||||
'use client'
|
||||
|
||||
import { type ReactNode, useCallback, useMemo, createContext, useContext, useState } from 'react'
|
||||
import { useArcadeSession } from '@/hooks/useArcadeSession'
|
||||
import { useRoomData, useUpdateGameConfig } from '@/hooks/useRoomData'
|
||||
import { useViewerId } from '@/hooks/useViewerId'
|
||||
import { buildPlayerMetadata as buildPlayerMetadataUtil } from '@/lib/arcade/player-ownership.client'
|
||||
import type { GameMove } from '@/lib/arcade/validation'
|
||||
import { useGameMode } from '@/contexts/GameModeContext'
|
||||
import { generateRandomCards, shuffleCards } from './utils/cardGeneration'
|
||||
import type { CardSortingState, CardSortingMove, SortingCard, CardSortingConfig } from './types'
|
||||
|
||||
// Context value interface
|
||||
interface CardSortingContextValue {
|
||||
state: CardSortingState
|
||||
// Actions
|
||||
startGame: () => void
|
||||
placeCard: (cardId: string, position: number) => void
|
||||
insertCard: (cardId: string, insertPosition: number) => void
|
||||
removeCard: (position: number) => void
|
||||
checkSolution: () => void
|
||||
revealNumbers: () => void
|
||||
goToSetup: () => void
|
||||
resumeGame: () => void
|
||||
setConfig: (field: 'cardCount' | 'showNumbers' | 'timeLimit', value: unknown) => void
|
||||
exitSession: () => void
|
||||
// Computed
|
||||
canCheckSolution: boolean
|
||||
placedCount: number
|
||||
elapsedTime: number
|
||||
hasConfigChanged: boolean
|
||||
canResumeGame: boolean
|
||||
// UI state
|
||||
selectedCardId: string | null
|
||||
selectCard: (cardId: string | null) => void
|
||||
// Spectator mode
|
||||
localPlayerId: string | undefined
|
||||
isSpectating: boolean
|
||||
}
|
||||
|
||||
// Create context
|
||||
const CardSortingContext = createContext<CardSortingContextValue | null>(null)
|
||||
|
||||
// Initial state matching validator's getInitialState
|
||||
const createInitialState = (config: Partial<CardSortingConfig>): CardSortingState => ({
|
||||
cardCount: config.cardCount ?? 8,
|
||||
showNumbers: config.showNumbers ?? true,
|
||||
timeLimit: config.timeLimit ?? null,
|
||||
gamePhase: 'setup',
|
||||
playerId: '',
|
||||
playerMetadata: {
|
||||
id: '',
|
||||
name: '',
|
||||
emoji: '',
|
||||
userId: '',
|
||||
},
|
||||
gameStartTime: null,
|
||||
gameEndTime: null,
|
||||
selectedCards: [],
|
||||
correctOrder: [],
|
||||
availableCards: [],
|
||||
placedCards: new Array(config.cardCount ?? 8).fill(null),
|
||||
selectedCardId: null,
|
||||
numbersRevealed: false,
|
||||
scoreBreakdown: null,
|
||||
})
|
||||
|
||||
/**
|
||||
* Optimistic move application (client-side prediction)
|
||||
*/
|
||||
function applyMoveOptimistically(state: CardSortingState, move: GameMove): CardSortingState {
|
||||
const typedMove = move as CardSortingMove
|
||||
|
||||
switch (typedMove.type) {
|
||||
case 'START_GAME': {
|
||||
const selectedCards = typedMove.data.selectedCards as SortingCard[]
|
||||
const correctOrder = [...selectedCards].sort((a, b) => a.number - b.number)
|
||||
|
||||
return {
|
||||
...state,
|
||||
gamePhase: 'playing',
|
||||
playerId: typedMove.playerId,
|
||||
playerMetadata: typedMove.data.playerMetadata,
|
||||
gameStartTime: Date.now(),
|
||||
selectedCards,
|
||||
correctOrder,
|
||||
availableCards: shuffleCards(selectedCards),
|
||||
placedCards: new Array(state.cardCount).fill(null),
|
||||
numbersRevealed: false,
|
||||
// Save original config for pause/resume
|
||||
originalConfig: {
|
||||
cardCount: state.cardCount,
|
||||
showNumbers: state.showNumbers,
|
||||
timeLimit: state.timeLimit,
|
||||
},
|
||||
pausedGamePhase: undefined,
|
||||
pausedGameState: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
case 'PLACE_CARD': {
|
||||
const { cardId, position } = typedMove.data
|
||||
const card = state.availableCards.find((c) => c.id === cardId)
|
||||
if (!card) return state
|
||||
|
||||
// Simple replacement (can leave gaps)
|
||||
const newPlaced = [...state.placedCards]
|
||||
const replacedCard = newPlaced[position]
|
||||
newPlaced[position] = card
|
||||
|
||||
// Remove card from available
|
||||
let newAvailable = state.availableCards.filter((c) => c.id !== cardId)
|
||||
|
||||
// If slot was occupied, add replaced card back to available
|
||||
if (replacedCard) {
|
||||
newAvailable = [...newAvailable, replacedCard]
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
availableCards: newAvailable,
|
||||
placedCards: newPlaced,
|
||||
}
|
||||
}
|
||||
|
||||
case 'INSERT_CARD': {
|
||||
const { cardId, insertPosition } = typedMove.data
|
||||
const card = state.availableCards.find((c) => c.id === cardId)
|
||||
if (!card) return state
|
||||
|
||||
// Insert with shift and compact (no gaps)
|
||||
const newPlaced = new Array(state.cardCount).fill(null)
|
||||
|
||||
// Copy existing cards, shifting those at/after insert position
|
||||
for (let i = 0; i < state.placedCards.length; i++) {
|
||||
if (state.placedCards[i] !== null) {
|
||||
if (i < insertPosition) {
|
||||
newPlaced[i] = state.placedCards[i]
|
||||
} else {
|
||||
// Cards at or after insert position shift right by 1
|
||||
// Card will be collected during compaction if it falls off the end
|
||||
newPlaced[i + 1] = state.placedCards[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Place new card at insert position
|
||||
newPlaced[insertPosition] = card
|
||||
|
||||
// Compact to remove gaps
|
||||
const compacted: SortingCard[] = []
|
||||
for (const c of newPlaced) {
|
||||
if (c !== null) {
|
||||
compacted.push(c)
|
||||
}
|
||||
}
|
||||
|
||||
// Fill final array (no gaps)
|
||||
const finalPlaced = new Array(state.cardCount).fill(null)
|
||||
for (let i = 0; i < Math.min(compacted.length, state.cardCount); i++) {
|
||||
finalPlaced[i] = compacted[i]
|
||||
}
|
||||
|
||||
// Remove from available
|
||||
let newAvailable = state.availableCards.filter((c) => c.id !== cardId)
|
||||
|
||||
// Any excess cards go back to available
|
||||
if (compacted.length > state.cardCount) {
|
||||
const excess = compacted.slice(state.cardCount)
|
||||
newAvailable = [...newAvailable, ...excess]
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
availableCards: newAvailable,
|
||||
placedCards: finalPlaced,
|
||||
}
|
||||
}
|
||||
|
||||
case 'REMOVE_CARD': {
|
||||
const { position } = typedMove.data
|
||||
const card = state.placedCards[position]
|
||||
if (!card) return state
|
||||
|
||||
const newPlaced = [...state.placedCards]
|
||||
newPlaced[position] = null
|
||||
const newAvailable = [...state.availableCards, card]
|
||||
|
||||
return {
|
||||
...state,
|
||||
availableCards: newAvailable,
|
||||
placedCards: newPlaced,
|
||||
}
|
||||
}
|
||||
|
||||
case 'REVEAL_NUMBERS': {
|
||||
return {
|
||||
...state,
|
||||
numbersRevealed: true,
|
||||
}
|
||||
}
|
||||
|
||||
case 'CHECK_SOLUTION': {
|
||||
// Server will calculate score - just transition to results optimistically
|
||||
return {
|
||||
...state,
|
||||
gamePhase: 'results',
|
||||
gameEndTime: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
case 'GO_TO_SETUP': {
|
||||
const isPausingGame = state.gamePhase === 'playing'
|
||||
|
||||
return {
|
||||
...createInitialState({
|
||||
cardCount: state.cardCount,
|
||||
showNumbers: state.showNumbers,
|
||||
timeLimit: state.timeLimit,
|
||||
}),
|
||||
// Save paused state if coming from active game
|
||||
originalConfig: state.originalConfig,
|
||||
pausedGamePhase: isPausingGame ? 'playing' : undefined,
|
||||
pausedGameState: isPausingGame
|
||||
? {
|
||||
selectedCards: state.selectedCards,
|
||||
availableCards: state.availableCards,
|
||||
placedCards: state.placedCards,
|
||||
gameStartTime: state.gameStartTime || Date.now(),
|
||||
numbersRevealed: state.numbersRevealed,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
case 'SET_CONFIG': {
|
||||
const { field, value } = typedMove.data
|
||||
const clearPausedGame = !!state.pausedGamePhase
|
||||
|
||||
return {
|
||||
...state,
|
||||
[field]: value,
|
||||
// Update placedCards array size if cardCount changes
|
||||
...(field === 'cardCount' ? { placedCards: new Array(value as number).fill(null) } : {}),
|
||||
// Clear paused game if config changed
|
||||
...(clearPausedGame
|
||||
? {
|
||||
pausedGamePhase: undefined,
|
||||
pausedGameState: undefined,
|
||||
originalConfig: undefined,
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
case 'RESUME_GAME': {
|
||||
if (!state.pausedGamePhase || !state.pausedGameState) {
|
||||
return state
|
||||
}
|
||||
|
||||
const correctOrder = [...state.pausedGameState.selectedCards].sort(
|
||||
(a, b) => a.number - b.number
|
||||
)
|
||||
|
||||
return {
|
||||
...state,
|
||||
gamePhase: state.pausedGamePhase,
|
||||
selectedCards: state.pausedGameState.selectedCards,
|
||||
correctOrder,
|
||||
availableCards: state.pausedGameState.availableCards,
|
||||
placedCards: state.pausedGameState.placedCards,
|
||||
gameStartTime: state.pausedGameState.gameStartTime,
|
||||
numbersRevealed: state.pausedGameState.numbersRevealed,
|
||||
pausedGamePhase: undefined,
|
||||
pausedGameState: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Card Sorting Provider - Single Player Pattern Recognition Game
|
||||
*/
|
||||
export function CardSortingProvider({ children }: { children: ReactNode }) {
|
||||
const { data: viewerId } = useViewerId()
|
||||
const { roomData } = useRoomData()
|
||||
const { activePlayers, players } = useGameMode()
|
||||
const { mutate: updateGameConfig } = useUpdateGameConfig()
|
||||
|
||||
// Local UI state (not synced to server)
|
||||
const [selectedCardId, setSelectedCardId] = useState<string | null>(null)
|
||||
|
||||
// Get local player (single player game)
|
||||
const localPlayerId = useMemo(() => {
|
||||
return Array.from(activePlayers).find((id) => {
|
||||
const player = players.get(id)
|
||||
return player?.isLocal !== false
|
||||
})
|
||||
}, [activePlayers, players])
|
||||
|
||||
// Merge saved config from room data
|
||||
const mergedInitialState = useMemo(() => {
|
||||
const gameConfig = roomData?.gameConfig as Record<string, unknown> | null
|
||||
const savedConfig = gameConfig?.['card-sorting'] as Partial<CardSortingConfig> | undefined
|
||||
|
||||
return createInitialState(savedConfig || {})
|
||||
}, [roomData?.gameConfig])
|
||||
|
||||
// Arcade session integration
|
||||
const { state, sendMove, exitSession } = useArcadeSession<CardSortingState>({
|
||||
userId: viewerId || '',
|
||||
roomId: roomData?.id,
|
||||
initialState: mergedInitialState,
|
||||
applyMove: applyMoveOptimistically,
|
||||
})
|
||||
|
||||
// Build player metadata for the single local player
|
||||
const buildPlayerMetadata = useCallback(() => {
|
||||
if (!localPlayerId) {
|
||||
return {
|
||||
id: '',
|
||||
name: '',
|
||||
emoji: '',
|
||||
userId: '',
|
||||
}
|
||||
}
|
||||
|
||||
const playerOwnership: Record<string, string> = {}
|
||||
if (viewerId) {
|
||||
playerOwnership[localPlayerId] = viewerId
|
||||
}
|
||||
|
||||
const metadata = buildPlayerMetadataUtil(
|
||||
[localPlayerId],
|
||||
playerOwnership,
|
||||
players,
|
||||
viewerId ?? undefined
|
||||
)
|
||||
|
||||
return metadata[localPlayerId] || { id: '', name: '', emoji: '', userId: '' }
|
||||
}, [localPlayerId, players, viewerId])
|
||||
|
||||
// Computed values
|
||||
const canCheckSolution = useMemo(
|
||||
() => state.placedCards.every((c) => c !== null),
|
||||
[state.placedCards]
|
||||
)
|
||||
|
||||
const placedCount = useMemo(
|
||||
() => state.placedCards.filter((c) => c !== null).length,
|
||||
[state.placedCards]
|
||||
)
|
||||
|
||||
const elapsedTime = useMemo(() => {
|
||||
if (!state.gameStartTime) return 0
|
||||
const now = state.gameEndTime || Date.now()
|
||||
return Math.floor((now - state.gameStartTime) / 1000)
|
||||
}, [state.gameStartTime, state.gameEndTime])
|
||||
|
||||
const hasConfigChanged = useMemo(() => {
|
||||
if (!state.originalConfig) return false
|
||||
return (
|
||||
state.cardCount !== state.originalConfig.cardCount ||
|
||||
state.showNumbers !== state.originalConfig.showNumbers ||
|
||||
state.timeLimit !== state.originalConfig.timeLimit
|
||||
)
|
||||
}, [state.cardCount, state.showNumbers, state.timeLimit, state.originalConfig])
|
||||
|
||||
const canResumeGame = useMemo(() => {
|
||||
return !!state.pausedGamePhase && !!state.pausedGameState && !hasConfigChanged
|
||||
}, [state.pausedGamePhase, state.pausedGameState, hasConfigChanged])
|
||||
|
||||
// Action creators
|
||||
const startGame = useCallback(() => {
|
||||
if (!localPlayerId) {
|
||||
console.error('[CardSortingProvider] No local player available')
|
||||
return
|
||||
}
|
||||
|
||||
const playerMetadata = buildPlayerMetadata()
|
||||
const selectedCards = generateRandomCards(state.cardCount)
|
||||
|
||||
sendMove({
|
||||
type: 'START_GAME',
|
||||
playerId: localPlayerId,
|
||||
userId: viewerId || '',
|
||||
data: {
|
||||
playerMetadata,
|
||||
selectedCards,
|
||||
},
|
||||
})
|
||||
}, [localPlayerId, state.cardCount, buildPlayerMetadata, sendMove, viewerId])
|
||||
|
||||
const placeCard = useCallback(
|
||||
(cardId: string, position: number) => {
|
||||
if (!localPlayerId) return
|
||||
|
||||
sendMove({
|
||||
type: 'PLACE_CARD',
|
||||
playerId: localPlayerId,
|
||||
userId: viewerId || '',
|
||||
data: { cardId, position },
|
||||
})
|
||||
|
||||
// Clear selection
|
||||
setSelectedCardId(null)
|
||||
},
|
||||
[localPlayerId, sendMove, viewerId]
|
||||
)
|
||||
|
||||
const insertCard = useCallback(
|
||||
(cardId: string, insertPosition: number) => {
|
||||
if (!localPlayerId) return
|
||||
|
||||
sendMove({
|
||||
type: 'INSERT_CARD',
|
||||
playerId: localPlayerId,
|
||||
userId: viewerId || '',
|
||||
data: { cardId, insertPosition },
|
||||
})
|
||||
|
||||
// Clear selection
|
||||
setSelectedCardId(null)
|
||||
},
|
||||
[localPlayerId, sendMove, viewerId]
|
||||
)
|
||||
|
||||
const removeCard = useCallback(
|
||||
(position: number) => {
|
||||
if (!localPlayerId) return
|
||||
|
||||
sendMove({
|
||||
type: 'REMOVE_CARD',
|
||||
playerId: localPlayerId,
|
||||
userId: viewerId || '',
|
||||
data: { position },
|
||||
})
|
||||
},
|
||||
[localPlayerId, sendMove, viewerId]
|
||||
)
|
||||
|
||||
const checkSolution = useCallback(() => {
|
||||
if (!localPlayerId) return
|
||||
if (!canCheckSolution) {
|
||||
console.warn('[CardSortingProvider] Cannot check - not all cards placed')
|
||||
return
|
||||
}
|
||||
|
||||
sendMove({
|
||||
type: 'CHECK_SOLUTION',
|
||||
playerId: localPlayerId,
|
||||
userId: viewerId || '',
|
||||
data: {},
|
||||
})
|
||||
}, [localPlayerId, canCheckSolution, sendMove, viewerId])
|
||||
|
||||
const revealNumbers = useCallback(() => {
|
||||
if (!localPlayerId) return
|
||||
|
||||
sendMove({
|
||||
type: 'REVEAL_NUMBERS',
|
||||
playerId: localPlayerId,
|
||||
userId: viewerId || '',
|
||||
data: {},
|
||||
})
|
||||
}, [localPlayerId, sendMove, viewerId])
|
||||
|
||||
const goToSetup = useCallback(() => {
|
||||
if (!localPlayerId) return
|
||||
|
||||
sendMove({
|
||||
type: 'GO_TO_SETUP',
|
||||
playerId: localPlayerId,
|
||||
userId: viewerId || '',
|
||||
data: {},
|
||||
})
|
||||
}, [localPlayerId, sendMove, viewerId])
|
||||
|
||||
const resumeGame = useCallback(() => {
|
||||
if (!localPlayerId || !canResumeGame) {
|
||||
console.warn('[CardSortingProvider] Cannot resume - no paused game or config changed')
|
||||
return
|
||||
}
|
||||
|
||||
sendMove({
|
||||
type: 'RESUME_GAME',
|
||||
playerId: localPlayerId,
|
||||
userId: viewerId || '',
|
||||
data: {},
|
||||
})
|
||||
}, [localPlayerId, canResumeGame, sendMove, viewerId])
|
||||
|
||||
const setConfig = useCallback(
|
||||
(field: 'cardCount' | 'showNumbers' | 'timeLimit', value: unknown) => {
|
||||
if (!localPlayerId) return
|
||||
|
||||
sendMove({
|
||||
type: 'SET_CONFIG',
|
||||
playerId: localPlayerId,
|
||||
userId: viewerId || '',
|
||||
data: { field, value },
|
||||
})
|
||||
|
||||
// Persist to database
|
||||
if (roomData?.id) {
|
||||
const currentGameConfig = (roomData.gameConfig as Record<string, unknown>) || {}
|
||||
const currentCardSortingConfig =
|
||||
(currentGameConfig['card-sorting'] as Record<string, unknown>) || {}
|
||||
|
||||
const updatedConfig = {
|
||||
...currentGameConfig,
|
||||
'card-sorting': {
|
||||
...currentCardSortingConfig,
|
||||
[field]: value,
|
||||
},
|
||||
}
|
||||
|
||||
updateGameConfig({
|
||||
roomId: roomData.id,
|
||||
gameConfig: updatedConfig,
|
||||
})
|
||||
}
|
||||
},
|
||||
[localPlayerId, sendMove, viewerId, roomData, updateGameConfig]
|
||||
)
|
||||
|
||||
const contextValue: CardSortingContextValue = {
|
||||
state,
|
||||
// Actions
|
||||
startGame,
|
||||
placeCard,
|
||||
insertCard,
|
||||
removeCard,
|
||||
checkSolution,
|
||||
revealNumbers,
|
||||
goToSetup,
|
||||
resumeGame,
|
||||
setConfig,
|
||||
exitSession,
|
||||
// Computed
|
||||
canCheckSolution,
|
||||
placedCount,
|
||||
elapsedTime,
|
||||
hasConfigChanged,
|
||||
canResumeGame,
|
||||
// UI state
|
||||
selectedCardId,
|
||||
selectCard: setSelectedCardId,
|
||||
// Spectator mode
|
||||
localPlayerId,
|
||||
isSpectating: !localPlayerId,
|
||||
}
|
||||
|
||||
return <CardSortingContext.Provider value={contextValue}>{children}</CardSortingContext.Provider>
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access Card Sorting context
|
||||
*/
|
||||
export function useCardSorting() {
|
||||
const context = useContext(CardSortingContext)
|
||||
if (!context) {
|
||||
throw new Error('useCardSorting must be used within CardSortingProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
466
apps/web/src/arcade-games/card-sorting/Validator.ts
Normal file
466
apps/web/src/arcade-games/card-sorting/Validator.ts
Normal file
@@ -0,0 +1,466 @@
|
||||
import type {
|
||||
GameValidator,
|
||||
ValidationContext,
|
||||
ValidationResult,
|
||||
} from '@/lib/arcade/validation/types'
|
||||
import type { CardSortingConfig, CardSortingMove, CardSortingState } from './types'
|
||||
import { calculateScore } from './utils/scoringAlgorithm'
|
||||
import { placeCardAtPosition, insertCardAtPosition, removeCardAtPosition } from './utils/validation'
|
||||
|
||||
export class CardSortingValidator implements GameValidator<CardSortingState, CardSortingMove> {
|
||||
validateMove(
|
||||
state: CardSortingState,
|
||||
move: CardSortingMove,
|
||||
context: ValidationContext
|
||||
): ValidationResult {
|
||||
switch (move.type) {
|
||||
case 'START_GAME':
|
||||
return this.validateStartGame(state, move.data, move.playerId)
|
||||
case 'PLACE_CARD':
|
||||
return this.validatePlaceCard(state, move.data.cardId, move.data.position)
|
||||
case 'INSERT_CARD':
|
||||
return this.validateInsertCard(state, move.data.cardId, move.data.insertPosition)
|
||||
case 'REMOVE_CARD':
|
||||
return this.validateRemoveCard(state, move.data.position)
|
||||
case 'REVEAL_NUMBERS':
|
||||
return this.validateRevealNumbers(state)
|
||||
case 'CHECK_SOLUTION':
|
||||
return this.validateCheckSolution(state)
|
||||
case 'GO_TO_SETUP':
|
||||
return this.validateGoToSetup(state)
|
||||
case 'SET_CONFIG':
|
||||
return this.validateSetConfig(state, move.data.field, move.data.value)
|
||||
case 'RESUME_GAME':
|
||||
return this.validateResumeGame(state)
|
||||
default:
|
||||
return {
|
||||
valid: false,
|
||||
error: `Unknown move type: ${(move as CardSortingMove).type}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private validateStartGame(
|
||||
state: CardSortingState,
|
||||
data: { playerMetadata: unknown; selectedCards: unknown },
|
||||
playerId: string
|
||||
): ValidationResult {
|
||||
// Must be in setup phase
|
||||
if (state.gamePhase !== 'setup') {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Can only start game from setup phase',
|
||||
}
|
||||
}
|
||||
|
||||
// Validate selectedCards
|
||||
if (!Array.isArray(data.selectedCards)) {
|
||||
return { valid: false, error: 'selectedCards must be an array' }
|
||||
}
|
||||
|
||||
if (data.selectedCards.length !== state.cardCount) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Must provide exactly ${state.cardCount} cards`,
|
||||
}
|
||||
}
|
||||
|
||||
const selectedCards = data.selectedCards as unknown[]
|
||||
|
||||
// Create correct order (sorted)
|
||||
const correctOrder = [...selectedCards].sort((a: unknown, b: unknown) => {
|
||||
const cardA = a as { number: number }
|
||||
const cardB = b as { number: number }
|
||||
return cardA.number - cardB.number
|
||||
})
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
newState: {
|
||||
...state,
|
||||
gamePhase: 'playing',
|
||||
playerId,
|
||||
playerMetadata: data.playerMetadata,
|
||||
gameStartTime: Date.now(),
|
||||
selectedCards: selectedCards as typeof state.selectedCards,
|
||||
correctOrder: correctOrder as typeof state.correctOrder,
|
||||
availableCards: selectedCards as typeof state.availableCards,
|
||||
placedCards: new Array(state.cardCount).fill(null),
|
||||
numbersRevealed: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
private validatePlaceCard(
|
||||
state: CardSortingState,
|
||||
cardId: string,
|
||||
position: number
|
||||
): ValidationResult {
|
||||
// Must be in playing phase
|
||||
if (state.gamePhase !== 'playing') {
|
||||
return { valid: false, error: 'Can only place cards during playing phase' }
|
||||
}
|
||||
|
||||
// Card must exist in availableCards
|
||||
const card = state.availableCards.find((c) => c.id === cardId)
|
||||
if (!card) {
|
||||
return { valid: false, error: 'Card not found in available cards' }
|
||||
}
|
||||
|
||||
// Position must be valid (0 to cardCount-1)
|
||||
if (position < 0 || position >= state.cardCount) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Invalid position: must be between 0 and ${state.cardCount - 1}`,
|
||||
}
|
||||
}
|
||||
|
||||
// Place the card using utility function (simple replacement)
|
||||
const { placedCards: newPlaced, replacedCard } = placeCardAtPosition(
|
||||
state.placedCards,
|
||||
card,
|
||||
position
|
||||
)
|
||||
|
||||
// Remove card from available
|
||||
let newAvailable = state.availableCards.filter((c) => c.id !== cardId)
|
||||
|
||||
// If slot was occupied, add replaced card back to available
|
||||
if (replacedCard) {
|
||||
newAvailable = [...newAvailable, replacedCard]
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
newState: {
|
||||
...state,
|
||||
availableCards: newAvailable,
|
||||
placedCards: newPlaced,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
private validateInsertCard(
|
||||
state: CardSortingState,
|
||||
cardId: string,
|
||||
insertPosition: number
|
||||
): ValidationResult {
|
||||
// Must be in playing phase
|
||||
if (state.gamePhase !== 'playing') {
|
||||
return { valid: false, error: 'Can only insert cards during playing phase' }
|
||||
}
|
||||
|
||||
// Card must exist in availableCards
|
||||
const card = state.availableCards.find((c) => c.id === cardId)
|
||||
if (!card) {
|
||||
return { valid: false, error: 'Card not found in available cards' }
|
||||
}
|
||||
|
||||
// Position must be valid (0 to cardCount, inclusive - can insert after last position)
|
||||
if (insertPosition < 0 || insertPosition > state.cardCount) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Invalid insert position: must be between 0 and ${state.cardCount}`,
|
||||
}
|
||||
}
|
||||
|
||||
// Insert the card using utility function (with shift and compact)
|
||||
const { placedCards: newPlaced, excessCards } = insertCardAtPosition(
|
||||
state.placedCards,
|
||||
card,
|
||||
insertPosition,
|
||||
state.cardCount
|
||||
)
|
||||
|
||||
// Remove card from available
|
||||
let newAvailable = state.availableCards.filter((c) => c.id !== cardId)
|
||||
|
||||
// Add any excess cards back to available (shouldn't normally happen)
|
||||
if (excessCards.length > 0) {
|
||||
newAvailable = [...newAvailable, ...excessCards]
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
newState: {
|
||||
...state,
|
||||
availableCards: newAvailable,
|
||||
placedCards: newPlaced,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
private validateRemoveCard(state: CardSortingState, position: number): ValidationResult {
|
||||
// Must be in playing phase
|
||||
if (state.gamePhase !== 'playing') {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Can only remove cards during playing phase',
|
||||
}
|
||||
}
|
||||
|
||||
// Position must be valid
|
||||
if (position < 0 || position >= state.cardCount) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Invalid position: must be between 0 and ${state.cardCount - 1}`,
|
||||
}
|
||||
}
|
||||
|
||||
// Card must exist at position
|
||||
if (state.placedCards[position] === null) {
|
||||
return { valid: false, error: 'No card at this position' }
|
||||
}
|
||||
|
||||
// Remove the card using utility function
|
||||
const { placedCards: newPlaced, removedCard } = removeCardAtPosition(
|
||||
state.placedCards,
|
||||
position
|
||||
)
|
||||
|
||||
if (!removedCard) {
|
||||
return { valid: false, error: 'Failed to remove card' }
|
||||
}
|
||||
|
||||
// Add back to available
|
||||
const newAvailable = [...state.availableCards, removedCard]
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
newState: {
|
||||
...state,
|
||||
availableCards: newAvailable,
|
||||
placedCards: newPlaced,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
private validateRevealNumbers(state: CardSortingState): ValidationResult {
|
||||
// Must be in playing phase
|
||||
if (state.gamePhase !== 'playing') {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Can only reveal numbers during playing phase',
|
||||
}
|
||||
}
|
||||
|
||||
// Must be enabled in config
|
||||
if (!state.showNumbers) {
|
||||
return { valid: false, error: 'Reveal numbers is not enabled' }
|
||||
}
|
||||
|
||||
// Already revealed
|
||||
if (state.numbersRevealed) {
|
||||
return { valid: false, error: 'Numbers already revealed' }
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
newState: {
|
||||
...state,
|
||||
numbersRevealed: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
private validateCheckSolution(state: CardSortingState): ValidationResult {
|
||||
// Must be in playing phase
|
||||
if (state.gamePhase !== 'playing') {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Can only check solution during playing phase',
|
||||
}
|
||||
}
|
||||
|
||||
// All slots must be filled
|
||||
if (state.placedCards.some((c) => c === null)) {
|
||||
return { valid: false, error: 'Must place all cards before checking' }
|
||||
}
|
||||
|
||||
// Calculate score using scoring algorithms
|
||||
const userSequence = state.placedCards.map((c) => c!.number)
|
||||
const correctSequence = state.correctOrder.map((c) => c.number)
|
||||
|
||||
const scoreBreakdown = calculateScore(
|
||||
userSequence,
|
||||
correctSequence,
|
||||
state.gameStartTime || Date.now(),
|
||||
state.numbersRevealed
|
||||
)
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
newState: {
|
||||
...state,
|
||||
gamePhase: 'results',
|
||||
gameEndTime: Date.now(),
|
||||
scoreBreakdown,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
private validateGoToSetup(state: CardSortingState): ValidationResult {
|
||||
// Save current game state for resume (if in playing phase)
|
||||
if (state.gamePhase === 'playing') {
|
||||
return {
|
||||
valid: true,
|
||||
newState: {
|
||||
...this.getInitialState({
|
||||
cardCount: state.cardCount,
|
||||
showNumbers: state.showNumbers,
|
||||
timeLimit: state.timeLimit,
|
||||
}),
|
||||
originalConfig: {
|
||||
cardCount: state.cardCount,
|
||||
showNumbers: state.showNumbers,
|
||||
timeLimit: state.timeLimit,
|
||||
},
|
||||
pausedGamePhase: 'playing',
|
||||
pausedGameState: {
|
||||
selectedCards: state.selectedCards,
|
||||
availableCards: state.availableCards,
|
||||
placedCards: state.placedCards,
|
||||
gameStartTime: state.gameStartTime || Date.now(),
|
||||
numbersRevealed: state.numbersRevealed,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Just go to setup
|
||||
return {
|
||||
valid: true,
|
||||
newState: this.getInitialState({
|
||||
cardCount: state.cardCount,
|
||||
showNumbers: state.showNumbers,
|
||||
timeLimit: state.timeLimit,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
private validateSetConfig(
|
||||
state: CardSortingState,
|
||||
field: string,
|
||||
value: unknown
|
||||
): ValidationResult {
|
||||
// Must be in setup phase
|
||||
if (state.gamePhase !== 'setup') {
|
||||
return { valid: false, error: 'Can only change config in setup phase' }
|
||||
}
|
||||
|
||||
// Validate field and value
|
||||
switch (field) {
|
||||
case 'cardCount':
|
||||
if (![5, 8, 12, 15].includes(value as number)) {
|
||||
return { valid: false, error: 'cardCount must be 5, 8, 12, or 15' }
|
||||
}
|
||||
return {
|
||||
valid: true,
|
||||
newState: {
|
||||
...state,
|
||||
cardCount: value as 5 | 8 | 12 | 15,
|
||||
placedCards: new Array(value as number).fill(null),
|
||||
// Clear pause state if config changed
|
||||
pausedGamePhase: undefined,
|
||||
pausedGameState: undefined,
|
||||
},
|
||||
}
|
||||
|
||||
case 'showNumbers':
|
||||
if (typeof value !== 'boolean') {
|
||||
return { valid: false, error: 'showNumbers must be a boolean' }
|
||||
}
|
||||
return {
|
||||
valid: true,
|
||||
newState: {
|
||||
...state,
|
||||
showNumbers: value,
|
||||
// Clear pause state if config changed
|
||||
pausedGamePhase: undefined,
|
||||
pausedGameState: undefined,
|
||||
},
|
||||
}
|
||||
|
||||
case 'timeLimit':
|
||||
if (value !== null && (typeof value !== 'number' || value < 30)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'timeLimit must be null or a number >= 30',
|
||||
}
|
||||
}
|
||||
return {
|
||||
valid: true,
|
||||
newState: {
|
||||
...state,
|
||||
timeLimit: value as number | null,
|
||||
// Clear pause state if config changed
|
||||
pausedGamePhase: undefined,
|
||||
pausedGameState: undefined,
|
||||
},
|
||||
}
|
||||
|
||||
default:
|
||||
return { valid: false, error: `Unknown config field: ${field}` }
|
||||
}
|
||||
}
|
||||
|
||||
private validateResumeGame(state: CardSortingState): ValidationResult {
|
||||
// Must be in setup phase
|
||||
if (state.gamePhase !== 'setup') {
|
||||
return { valid: false, error: 'Can only resume from setup phase' }
|
||||
}
|
||||
|
||||
// Must have paused game state
|
||||
if (!state.pausedGamePhase || !state.pausedGameState) {
|
||||
return { valid: false, error: 'No paused game to resume' }
|
||||
}
|
||||
|
||||
// Restore paused state
|
||||
return {
|
||||
valid: true,
|
||||
newState: {
|
||||
...state,
|
||||
gamePhase: state.pausedGamePhase,
|
||||
selectedCards: state.pausedGameState.selectedCards,
|
||||
correctOrder: [...state.pausedGameState.selectedCards].sort((a, b) => a.number - b.number),
|
||||
availableCards: state.pausedGameState.availableCards,
|
||||
placedCards: state.pausedGameState.placedCards,
|
||||
gameStartTime: state.pausedGameState.gameStartTime,
|
||||
numbersRevealed: state.pausedGameState.numbersRevealed,
|
||||
pausedGamePhase: undefined,
|
||||
pausedGameState: undefined,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
isGameComplete(state: CardSortingState): boolean {
|
||||
return state.gamePhase === 'results'
|
||||
}
|
||||
|
||||
getInitialState(config: CardSortingConfig): CardSortingState {
|
||||
return {
|
||||
cardCount: config.cardCount,
|
||||
showNumbers: config.showNumbers,
|
||||
timeLimit: config.timeLimit,
|
||||
gamePhase: 'setup',
|
||||
playerId: '',
|
||||
playerMetadata: {
|
||||
id: '',
|
||||
name: '',
|
||||
emoji: '',
|
||||
userId: '',
|
||||
},
|
||||
gameStartTime: null,
|
||||
gameEndTime: null,
|
||||
selectedCards: [],
|
||||
correctOrder: [],
|
||||
availableCards: [],
|
||||
placedCards: new Array(config.cardCount).fill(null),
|
||||
selectedCardId: null,
|
||||
numbersRevealed: false,
|
||||
scoreBreakdown: null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const cardSortingValidator = new CardSortingValidator()
|
||||
@@ -0,0 +1,110 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { StandardGameLayout } from '@/components/StandardGameLayout'
|
||||
import { useFullscreen } from '@/contexts/FullscreenContext'
|
||||
import { useCardSorting } from '../Provider'
|
||||
import { SetupPhase } from './SetupPhase'
|
||||
import { PlayingPhase } from './PlayingPhase'
|
||||
import { ResultsPhase } from './ResultsPhase'
|
||||
|
||||
export function GameComponent() {
|
||||
const router = useRouter()
|
||||
const { state, exitSession, startGame, goToSetup, isSpectating } = useCardSorting()
|
||||
const { setFullscreenElement } = useFullscreen()
|
||||
const gameRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Register fullscreen element
|
||||
if (gameRef.current) {
|
||||
setFullscreenElement(gameRef.current)
|
||||
}
|
||||
}, [setFullscreenElement])
|
||||
|
||||
return (
|
||||
<PageWithNav
|
||||
navTitle="Card Sorting"
|
||||
navEmoji="🔢"
|
||||
emphasizePlayerSelection={state.gamePhase === 'setup'}
|
||||
onExitSession={() => {
|
||||
exitSession()
|
||||
router.push('/arcade')
|
||||
}}
|
||||
onSetup={
|
||||
goToSetup
|
||||
? () => {
|
||||
goToSetup()
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onNewGame={() => {
|
||||
startGame()
|
||||
}}
|
||||
>
|
||||
<StandardGameLayout>
|
||||
<div
|
||||
ref={gameRef}
|
||||
className={css({
|
||||
flex: 1,
|
||||
padding: { base: '12px', sm: '16px', md: '20px' },
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
position: 'relative',
|
||||
overflow: 'auto',
|
||||
})}
|
||||
>
|
||||
{/* Spectator Mode Banner */}
|
||||
{isSpectating && state.gamePhase !== 'setup' && (
|
||||
<div
|
||||
className={css({
|
||||
width: '100%',
|
||||
maxWidth: '1200px',
|
||||
background: 'linear-gradient(135deg, #fef3c7, #fde68a)',
|
||||
borderRadius: { base: '8px', md: '12px' },
|
||||
padding: { base: '12px', md: '16px' },
|
||||
marginBottom: { base: '12px', md: '16px' },
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '8px',
|
||||
fontSize: { base: '14px', sm: '16px', md: '18px' },
|
||||
fontWeight: '600',
|
||||
color: '#92400e',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
<span role="img" aria-label="watching">
|
||||
👀
|
||||
</span>
|
||||
<span>Spectating {state.playerMetadata?.name || 'player'}'s game</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<main
|
||||
className={css({
|
||||
width: '100%',
|
||||
maxWidth: '1200px',
|
||||
background: 'rgba(255,255,255,0.95)',
|
||||
borderRadius: { base: '12px', md: '20px' },
|
||||
padding: { base: '12px', sm: '16px', md: '24px', lg: '32px' },
|
||||
boxShadow: '0 10px 30px rgba(0,0,0,0.2)',
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
})}
|
||||
>
|
||||
{state.gamePhase === 'setup' && <SetupPhase />}
|
||||
{state.gamePhase === 'playing' && <PlayingPhase />}
|
||||
{state.gamePhase === 'results' && <ResultsPhase />}
|
||||
</main>
|
||||
</div>
|
||||
</StandardGameLayout>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
'use client'
|
||||
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { useCardSorting } from '../Provider'
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export function PlayingPhase() {
|
||||
const {
|
||||
state,
|
||||
selectedCardId,
|
||||
selectCard,
|
||||
placeCard,
|
||||
insertCard,
|
||||
removeCard,
|
||||
checkSolution,
|
||||
revealNumbers,
|
||||
goToSetup,
|
||||
canCheckSolution,
|
||||
placedCount,
|
||||
elapsedTime,
|
||||
isSpectating,
|
||||
} = useCardSorting()
|
||||
|
||||
// Status message (mimics Python updateSortingStatus)
|
||||
const [statusMessage, setStatusMessage] = useState(
|
||||
`Arrange the ${state.cardCount} cards in ascending order (smallest to largest)`
|
||||
)
|
||||
|
||||
// Update status message based on state
|
||||
useEffect(() => {
|
||||
if (state.gamePhase !== 'playing') return
|
||||
|
||||
if (selectedCardId) {
|
||||
const card = state.availableCards.find((c) => c.id === selectedCardId)
|
||||
if (card) {
|
||||
setStatusMessage(
|
||||
`Selected card with value ${card.number}. Click a position or + button to place it.`
|
||||
)
|
||||
}
|
||||
} else if (placedCount === state.cardCount) {
|
||||
setStatusMessage('All cards placed! Click "Check My Solution" to see how you did.')
|
||||
} else {
|
||||
setStatusMessage(
|
||||
`${placedCount}/${state.cardCount} cards placed. Select ${placedCount === 0 ? 'a' : 'another'} card to continue.`
|
||||
)
|
||||
}
|
||||
}, [selectedCardId, placedCount, state.cardCount, state.gamePhase, state.availableCards])
|
||||
|
||||
// Format time display
|
||||
const formatTime = (seconds: number) => {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = seconds % 60
|
||||
return `${m}:${s.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// Calculate gradient for position slots (darker = smaller, lighter = larger)
|
||||
const getSlotGradient = (position: number, total: number) => {
|
||||
const intensity = position / (total - 1 || 1)
|
||||
const lightness = 30 + intensity * 45 // 30% to 75%
|
||||
return {
|
||||
background: `hsl(220, 8%, ${lightness}%)`,
|
||||
color: lightness > 60 ? '#2c3e50' : '#ffffff',
|
||||
borderColor: lightness > 60 ? '#2c5f76' : 'rgba(255,255,255,0.4)',
|
||||
}
|
||||
}
|
||||
|
||||
const handleCardClick = (cardId: string) => {
|
||||
if (isSpectating) return // Spectators cannot interact
|
||||
if (selectedCardId === cardId) {
|
||||
selectCard(null) // Deselect
|
||||
} else {
|
||||
selectCard(cardId)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSlotClick = (position: number) => {
|
||||
if (isSpectating) return // Spectators cannot interact
|
||||
if (!selectedCardId) {
|
||||
// No card selected - if slot has a card, move it back and auto-select
|
||||
if (state.placedCards[position]) {
|
||||
const cardToMove = state.placedCards[position]!
|
||||
removeCard(position)
|
||||
// Auto-select the card that was moved back
|
||||
selectCard(cardToMove.id)
|
||||
} else {
|
||||
setStatusMessage('Select a card first, or click a placed card to move it back.')
|
||||
}
|
||||
} else {
|
||||
// Card is selected - place it (replaces existing card if any)
|
||||
placeCard(selectedCardId, position)
|
||||
}
|
||||
}
|
||||
|
||||
const handleInsertClick = (insertPosition: number) => {
|
||||
if (isSpectating) return // Spectators cannot interact
|
||||
if (!selectedCardId) {
|
||||
setStatusMessage('Please select a card first, then click where to insert it.')
|
||||
return
|
||||
}
|
||||
insertCard(selectedCardId, insertPosition)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1rem',
|
||||
height: '100%',
|
||||
})}
|
||||
>
|
||||
{/* Status message */}
|
||||
<div
|
||||
className={css({
|
||||
padding: '0.75rem 1rem',
|
||||
background: '#e3f2fd',
|
||||
borderLeft: '4px solid #2c5f76',
|
||||
borderRadius: '0.25rem',
|
||||
fontSize: 'sm',
|
||||
fontWeight: '500',
|
||||
color: '#2c3e50',
|
||||
flexShrink: 0,
|
||||
})}
|
||||
>
|
||||
{statusMessage}
|
||||
</div>
|
||||
|
||||
{/* Header with timer and actions */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '1rem',
|
||||
background: 'teal.50',
|
||||
borderRadius: '0.5rem',
|
||||
flexShrink: 0,
|
||||
})}
|
||||
>
|
||||
<div className={css({ display: 'flex', gap: '2rem' })}>
|
||||
<div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'gray.600',
|
||||
fontWeight: '600',
|
||||
})}
|
||||
>
|
||||
Time
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'teal.700',
|
||||
})}
|
||||
>
|
||||
{formatTime(elapsedTime)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'gray.600',
|
||||
fontWeight: '600',
|
||||
})}
|
||||
>
|
||||
Progress
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'teal.700',
|
||||
})}
|
||||
>
|
||||
{placedCount}/{state.cardCount}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={css({ display: 'flex', gap: '0.5rem' })}>
|
||||
{state.showNumbers && !state.numbersRevealed && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={revealNumbers}
|
||||
disabled={isSpectating}
|
||||
className={css({
|
||||
padding: '0.5rem 1rem',
|
||||
borderRadius: '0.375rem',
|
||||
background: isSpectating ? 'gray.300' : 'orange.500',
|
||||
color: 'white',
|
||||
fontSize: 'sm',
|
||||
fontWeight: '600',
|
||||
border: 'none',
|
||||
cursor: isSpectating ? 'not-allowed' : 'pointer',
|
||||
opacity: isSpectating ? 0.5 : 1,
|
||||
_hover: {
|
||||
background: isSpectating ? 'gray.300' : 'orange.600',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Reveal Numbers
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={checkSolution}
|
||||
disabled={!canCheckSolution || isSpectating}
|
||||
className={css({
|
||||
padding: '0.5rem 1rem',
|
||||
borderRadius: '0.375rem',
|
||||
background: canCheckSolution && !isSpectating ? 'teal.600' : 'gray.300',
|
||||
color: 'white',
|
||||
fontSize: 'sm',
|
||||
fontWeight: '600',
|
||||
border: 'none',
|
||||
cursor: canCheckSolution && !isSpectating ? 'pointer' : 'not-allowed',
|
||||
opacity: canCheckSolution && !isSpectating ? 1 : 0.5,
|
||||
_hover: {
|
||||
background: canCheckSolution && !isSpectating ? 'teal.700' : 'gray.300',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Check Solution
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goToSetup}
|
||||
disabled={isSpectating}
|
||||
className={css({
|
||||
padding: '0.5rem 1rem',
|
||||
borderRadius: '0.375rem',
|
||||
background: isSpectating ? 'gray.400' : 'gray.600',
|
||||
color: 'white',
|
||||
fontSize: 'sm',
|
||||
fontWeight: '600',
|
||||
border: 'none',
|
||||
cursor: isSpectating ? 'not-allowed' : 'pointer',
|
||||
opacity: isSpectating ? 0.5 : 1,
|
||||
_hover: {
|
||||
background: isSpectating ? 'gray.400' : 'gray.700',
|
||||
},
|
||||
})}
|
||||
>
|
||||
End Game
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main game area */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '2rem',
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
})}
|
||||
>
|
||||
{/* Available cards */}
|
||||
<div className={css({ flex: 1, minWidth: '200px' })}>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '1rem',
|
||||
color: 'gray.700',
|
||||
})}
|
||||
>
|
||||
Available Cards
|
||||
</h3>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '10px',
|
||||
justifyContent: 'center',
|
||||
padding: '15px',
|
||||
background: 'rgba(255,255,255,0.5)',
|
||||
borderRadius: '8px',
|
||||
minHeight: '120px',
|
||||
border: '2px dashed #2c5f76',
|
||||
})}
|
||||
>
|
||||
{state.availableCards.map((card) => (
|
||||
<div
|
||||
key={card.id}
|
||||
onClick={() => handleCardClick(card.id)}
|
||||
className={css({
|
||||
width: '140px',
|
||||
height: '140px',
|
||||
padding: '8px',
|
||||
border: '2px solid',
|
||||
borderColor: selectedCardId === card.id ? '#1976d2' : 'transparent',
|
||||
borderRadius: '8px',
|
||||
background: selectedCardId === card.id ? '#e3f2fd' : 'white',
|
||||
cursor: isSpectating ? 'not-allowed' : 'pointer',
|
||||
opacity: isSpectating ? 0.5 : 1,
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
position: 'relative',
|
||||
userSelect: 'none',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 2px 6px rgba(0,0,0,0.1)',
|
||||
_hover: isSpectating
|
||||
? {}
|
||||
: {
|
||||
transform: 'translateY(-5px)',
|
||||
boxShadow: '0 8px 16px rgba(0,0,0,0.2)',
|
||||
borderColor: '#2c5f76',
|
||||
},
|
||||
})}
|
||||
style={
|
||||
selectedCardId === card.id
|
||||
? {
|
||||
transform: 'scale(1.1)',
|
||||
boxShadow: '0 6px 20px rgba(25, 118, 210, 0.3)',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: card.svgContent }}
|
||||
className={css({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
'& svg': {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'block',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
{state.numbersRevealed && (
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: '5px',
|
||||
right: '5px',
|
||||
background: '#ffc107',
|
||||
color: '#333',
|
||||
borderRadius: '4px',
|
||||
padding: '2px 8px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
})}
|
||||
>
|
||||
{card.number}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Position slots with insert buttons */}
|
||||
<div className={css({ flex: 2, minWidth: '300px' })}>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '1rem',
|
||||
color: 'gray.700',
|
||||
})}
|
||||
>
|
||||
Sort Positions (Smallest → Largest)
|
||||
</h3>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '8px',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: '15px',
|
||||
background: 'rgba(255,255,255,0.7)',
|
||||
borderRadius: '8px',
|
||||
border: '2px dashed #2c5f76',
|
||||
})}
|
||||
>
|
||||
{/* Insert button before first position */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleInsertClick(0)}
|
||||
disabled={!selectedCardId || isSpectating}
|
||||
className={css({
|
||||
width: '32px',
|
||||
height: '50px',
|
||||
background: selectedCardId && !isSpectating ? '#1976d2' : '#2c5f76',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '20px',
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
cursor: selectedCardId && !isSpectating ? 'pointer' : 'not-allowed',
|
||||
opacity: selectedCardId && !isSpectating ? 1 : 0.3,
|
||||
transition: 'all 0.2s',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
_hover: isSpectating
|
||||
? {}
|
||||
: {
|
||||
opacity: 1,
|
||||
background: '#1976d2',
|
||||
transform: selectedCardId ? 'scale(1.1)' : 'none',
|
||||
},
|
||||
})}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
|
||||
{/* Render each position slot followed by an insert button */}
|
||||
{state.placedCards.map((card, index) => {
|
||||
const gradientStyle = getSlotGradient(index, state.cardCount)
|
||||
const isEmpty = card === null
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Position slot */}
|
||||
<div
|
||||
key={`slot-${index}`}
|
||||
onClick={() => handleSlotClick(index)}
|
||||
className={css({
|
||||
width: '140px',
|
||||
height: '160px',
|
||||
padding: '0.5rem',
|
||||
borderRadius: '8px',
|
||||
border: '2px solid',
|
||||
cursor: isSpectating ? 'not-allowed' : 'pointer',
|
||||
opacity: isSpectating ? 0.5 : 1,
|
||||
transition: 'all 0.2s',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '0.25rem',
|
||||
position: 'relative',
|
||||
_hover: isSpectating
|
||||
? {}
|
||||
: {
|
||||
transform: selectedCardId && isEmpty ? 'scale(1.05)' : 'none',
|
||||
boxShadow:
|
||||
selectedCardId && isEmpty ? '0 4px 12px rgba(0,0,0,0.15)' : 'none',
|
||||
},
|
||||
})}
|
||||
style={
|
||||
isEmpty
|
||||
? {
|
||||
...gradientStyle,
|
||||
// Active state: add slight glow when card is selected
|
||||
boxShadow:
|
||||
selectedCardId && !isSpectating
|
||||
? '0 0 0 2px #1976d2, 0 2px 8px rgba(25, 118, 210, 0.3)'
|
||||
: 'none',
|
||||
}
|
||||
: {
|
||||
background: '#fff',
|
||||
color: '#333',
|
||||
borderColor: '#2c5f76',
|
||||
}
|
||||
}
|
||||
>
|
||||
{card ? (
|
||||
<>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: card.svgContent,
|
||||
}}
|
||||
className={css({
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
'& svg': {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'block',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'xs',
|
||||
opacity: 0.7,
|
||||
fontStyle: 'italic',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
← Click to move back
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
fontStyle: 'italic',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
style={{ color: gradientStyle.color }}
|
||||
>
|
||||
{index === 0 ? 'Smallest' : index === state.cardCount - 1 ? 'Largest' : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Insert button after this position */}
|
||||
<button
|
||||
key={`insert-${index + 1}`}
|
||||
type="button"
|
||||
onClick={() => handleInsertClick(index + 1)}
|
||||
disabled={!selectedCardId || isSpectating}
|
||||
className={css({
|
||||
width: '32px',
|
||||
height: '50px',
|
||||
background: selectedCardId && !isSpectating ? '#1976d2' : '#2c5f76',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '20px',
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
cursor: selectedCardId && !isSpectating ? 'pointer' : 'not-allowed',
|
||||
opacity: selectedCardId && !isSpectating ? 1 : 0.3,
|
||||
transition: 'all 0.2s',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
_hover: isSpectating
|
||||
? {}
|
||||
: {
|
||||
opacity: 1,
|
||||
background: '#1976d2',
|
||||
transform: selectedCardId ? 'scale(1.1)' : 'none',
|
||||
},
|
||||
})}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
'use client'
|
||||
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { useCardSorting } from '../Provider'
|
||||
|
||||
export function ResultsPhase() {
|
||||
const { state, startGame, goToSetup, exitSession } = useCardSorting()
|
||||
const { scoreBreakdown } = state
|
||||
|
||||
if (!scoreBreakdown) {
|
||||
return (
|
||||
<div className={css({ textAlign: 'center', padding: '2rem' })}>
|
||||
<p>No score data available</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const getMessage = (score: number) => {
|
||||
if (score === 100) return '🎉 Perfect! All cards in correct order!'
|
||||
if (score >= 80) return '👍 Excellent! Very close to perfect!'
|
||||
if (score >= 60) return '👍 Good job! You understand the pattern!'
|
||||
return '💪 Keep practicing! Focus on reading each abacus carefully.'
|
||||
}
|
||||
|
||||
const getEmoji = (score: number) => {
|
||||
if (score === 100) return '🏆'
|
||||
if (score >= 80) return '⭐'
|
||||
if (score >= 60) return '👍'
|
||||
return '📈'
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '2rem',
|
||||
padding: '1rem',
|
||||
overflow: 'auto',
|
||||
})}
|
||||
>
|
||||
{/* Score Display */}
|
||||
<div className={css({ textAlign: 'center' })}>
|
||||
<div className={css({ fontSize: '4rem', marginBottom: '0.5rem' })}>
|
||||
{getEmoji(scoreBreakdown.finalScore)}
|
||||
</div>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: { base: '2xl', md: '3xl' },
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '0.5rem',
|
||||
color: 'gray.800',
|
||||
})}
|
||||
>
|
||||
Your Score: {scoreBreakdown.finalScore}%
|
||||
</h2>
|
||||
<p className={css({ fontSize: 'lg', color: 'gray.600' })}>
|
||||
{getMessage(scoreBreakdown.finalScore)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Score Breakdown */}
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
borderRadius: '0.75rem',
|
||||
padding: '1.5rem',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '1rem',
|
||||
color: 'gray.800',
|
||||
})}
|
||||
>
|
||||
Score Breakdown
|
||||
</h3>
|
||||
|
||||
<div className={css({ display: 'flex', flexDirection: 'column', gap: '1rem' })}>
|
||||
{/* Exact Position Matches */}
|
||||
<div>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '0.25rem',
|
||||
})}
|
||||
>
|
||||
<span className={css({ fontSize: 'sm', fontWeight: '600' })}>
|
||||
Exact Position Matches (30%)
|
||||
</span>
|
||||
<span className={css({ fontSize: 'sm', color: 'gray.600' })}>
|
||||
{scoreBreakdown.exactMatches}/{state.cardCount} cards
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
width: '100%',
|
||||
height: '1.5rem',
|
||||
background: 'gray.200',
|
||||
borderRadius: '9999px',
|
||||
overflow: 'hidden',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
height: '100%',
|
||||
background: 'teal.500',
|
||||
transition: 'width 0.5s ease',
|
||||
})}
|
||||
style={{ width: `${scoreBreakdown.exactPositionScore}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Relative Order */}
|
||||
<div>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '0.25rem',
|
||||
})}
|
||||
>
|
||||
<span className={css({ fontSize: 'sm', fontWeight: '600' })}>
|
||||
Relative Order (50%)
|
||||
</span>
|
||||
<span className={css({ fontSize: 'sm', color: 'gray.600' })}>
|
||||
{scoreBreakdown.lcsLength}/{state.cardCount} in sequence
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
width: '100%',
|
||||
height: '1.5rem',
|
||||
background: 'gray.200',
|
||||
borderRadius: '9999px',
|
||||
overflow: 'hidden',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
height: '100%',
|
||||
background: 'teal.500',
|
||||
transition: 'width 0.5s ease',
|
||||
})}
|
||||
style={{ width: `${scoreBreakdown.relativeOrderScore}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Organization */}
|
||||
<div>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '0.25rem',
|
||||
})}
|
||||
>
|
||||
<span className={css({ fontSize: 'sm', fontWeight: '600' })}>Organization (20%)</span>
|
||||
<span className={css({ fontSize: 'sm', color: 'gray.600' })}>
|
||||
{scoreBreakdown.inversions} out-of-order pairs
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
width: '100%',
|
||||
height: '1.5rem',
|
||||
background: 'gray.200',
|
||||
borderRadius: '9999px',
|
||||
overflow: 'hidden',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
height: '100%',
|
||||
background: 'teal.500',
|
||||
transition: 'width 0.5s ease',
|
||||
})}
|
||||
style={{ width: `${scoreBreakdown.inversionScore}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time Taken */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
paddingTop: '0.5rem',
|
||||
borderTop: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
})}
|
||||
>
|
||||
<span className={css({ fontSize: 'sm', fontWeight: '600' })}>Time Taken</span>
|
||||
<span className={css({ fontSize: 'sm', color: 'gray.600' })}>
|
||||
{Math.floor(scoreBreakdown.elapsedTime / 60)}:
|
||||
{(scoreBreakdown.elapsedTime % 60).toString().padStart(2, '0')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{scoreBreakdown.numbersRevealed && (
|
||||
<div
|
||||
className={css({
|
||||
padding: '0.75rem',
|
||||
background: 'orange.50',
|
||||
borderRadius: '0.5rem',
|
||||
border: '1px solid',
|
||||
borderColor: 'orange.200',
|
||||
fontSize: 'sm',
|
||||
color: 'orange.700',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
⚠️ Numbers were revealed during play
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Comparison */}
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
borderRadius: '0.75rem',
|
||||
padding: '1.5rem',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '1rem',
|
||||
color: 'gray.800',
|
||||
})}
|
||||
>
|
||||
Comparison
|
||||
</h3>
|
||||
|
||||
<div className={css({ display: 'flex', flexDirection: 'column', gap: '1.5rem' })}>
|
||||
{/* User's Answer */}
|
||||
<div>
|
||||
<h4
|
||||
className={css({
|
||||
fontSize: 'md',
|
||||
fontWeight: '600',
|
||||
marginBottom: '0.5rem',
|
||||
color: 'gray.700',
|
||||
})}
|
||||
>
|
||||
Your Answer:
|
||||
</h4>
|
||||
<div className={css({ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' })}>
|
||||
{state.placedCards.map((card, i) => {
|
||||
if (!card) return null
|
||||
const isCorrect = card.number === state.correctOrder[i]?.number
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={css({
|
||||
padding: '0.5rem',
|
||||
border: '2px solid',
|
||||
borderColor: isCorrect ? 'green.500' : 'red.500',
|
||||
borderRadius: '0.375rem',
|
||||
background: isCorrect ? 'green.50' : 'red.50',
|
||||
textAlign: 'center',
|
||||
minWidth: '60px',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'xs',
|
||||
color: 'gray.600',
|
||||
marginBottom: '0.25rem',
|
||||
})}
|
||||
>
|
||||
#{i + 1}
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'bold',
|
||||
color: isCorrect ? 'green.700' : 'red.700',
|
||||
})}
|
||||
>
|
||||
{card.number}
|
||||
</div>
|
||||
{isCorrect ? (
|
||||
<div className={css({ fontSize: 'xs' })}>✓</div>
|
||||
) : (
|
||||
<div className={css({ fontSize: 'xs' })}>✗</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Correct Order */}
|
||||
<div>
|
||||
<h4
|
||||
className={css({
|
||||
fontSize: 'md',
|
||||
fontWeight: '600',
|
||||
marginBottom: '0.5rem',
|
||||
color: 'gray.700',
|
||||
})}
|
||||
>
|
||||
Correct Order:
|
||||
</h4>
|
||||
<div className={css({ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' })}>
|
||||
{state.correctOrder.map((card, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={css({
|
||||
padding: '0.5rem',
|
||||
border: '2px solid',
|
||||
borderColor: 'gray.300',
|
||||
borderRadius: '0.375rem',
|
||||
background: 'gray.50',
|
||||
textAlign: 'center',
|
||||
minWidth: '60px',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'xs',
|
||||
color: 'gray.600',
|
||||
marginBottom: '0.25rem',
|
||||
})}
|
||||
>
|
||||
#{i + 1}
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.700',
|
||||
})}
|
||||
>
|
||||
{card.number}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.75rem',
|
||||
maxWidth: '400px',
|
||||
margin: '0 auto',
|
||||
width: '100%',
|
||||
})}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={startGame}
|
||||
className={css({
|
||||
padding: '1rem',
|
||||
borderRadius: '0.5rem',
|
||||
background: 'teal.600',
|
||||
color: 'white',
|
||||
fontWeight: '600',
|
||||
fontSize: 'lg',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
background: 'teal.700',
|
||||
transform: 'translateY(-1px)',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
New Game (Same Settings)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goToSetup}
|
||||
className={css({
|
||||
padding: '1rem',
|
||||
borderRadius: '0.5rem',
|
||||
background: 'gray.600',
|
||||
color: 'white',
|
||||
fontWeight: '600',
|
||||
fontSize: 'lg',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
background: 'gray.700',
|
||||
transform: 'translateY(-1px)',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Change Settings
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={exitSession}
|
||||
className={css({
|
||||
padding: '1rem',
|
||||
borderRadius: '0.5rem',
|
||||
background: 'white',
|
||||
color: 'gray.700',
|
||||
fontWeight: '600',
|
||||
fontSize: 'lg',
|
||||
border: '2px solid',
|
||||
borderColor: 'gray.300',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
borderColor: 'gray.400',
|
||||
background: 'gray.50',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Exit to Room
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
194
apps/web/src/arcade-games/card-sorting/components/SetupPhase.tsx
Normal file
194
apps/web/src/arcade-games/card-sorting/components/SetupPhase.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
'use client'
|
||||
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { useCardSorting } from '../Provider'
|
||||
|
||||
export function SetupPhase() {
|
||||
const { state, setConfig, startGame, resumeGame, canResumeGame } = useCardSorting()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '2rem',
|
||||
padding: '2rem',
|
||||
})}
|
||||
>
|
||||
<div className={css({ textAlign: 'center' })}>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: { base: '2xl', md: '3xl' },
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '0.5rem',
|
||||
})}
|
||||
>
|
||||
Card Sorting Challenge
|
||||
</h2>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: { base: 'md', md: 'lg' },
|
||||
color: 'gray.600',
|
||||
})}
|
||||
>
|
||||
Arrange abacus cards in order using only visual patterns
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Card Count Selection */}
|
||||
<div className={css({ width: '100%', maxWidth: '400px' })}>
|
||||
<label
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontSize: 'sm',
|
||||
fontWeight: '600',
|
||||
marginBottom: '0.5rem',
|
||||
color: 'gray.700',
|
||||
})}
|
||||
>
|
||||
Number of Cards
|
||||
</label>
|
||||
<div
|
||||
className={css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '4',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
{([5, 8, 12, 15] as const).map((count) => (
|
||||
<button
|
||||
key={count}
|
||||
type="button"
|
||||
onClick={() => setConfig('cardCount', count)}
|
||||
className={css({
|
||||
padding: '0.75rem',
|
||||
borderRadius: '0.5rem',
|
||||
border: '2px solid',
|
||||
borderColor: state.cardCount === count ? 'teal.500' : 'gray.300',
|
||||
background: state.cardCount === count ? 'teal.50' : 'white',
|
||||
color: state.cardCount === count ? 'teal.700' : 'gray.700',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
borderColor: 'teal.400',
|
||||
background: 'teal.50',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{count}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Show Numbers Toggle */}
|
||||
<div className={css({ width: '100%', maxWidth: '400px' })}>
|
||||
<label
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.75rem',
|
||||
padding: '1rem',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
borderRadius: '0.5rem',
|
||||
cursor: 'pointer',
|
||||
_hover: {
|
||||
background: 'gray.50',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={state.showNumbers}
|
||||
onChange={(e) => setConfig('showNumbers', e.target.checked)}
|
||||
className={css({
|
||||
width: '1.25rem',
|
||||
height: '1.25rem',
|
||||
cursor: 'pointer',
|
||||
})}
|
||||
/>
|
||||
<div>
|
||||
<div
|
||||
className={css({
|
||||
fontWeight: '600',
|
||||
color: 'gray.700',
|
||||
})}
|
||||
>
|
||||
Allow "Reveal Numbers" button
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'gray.500',
|
||||
})}
|
||||
>
|
||||
Show numeric values during gameplay
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.75rem',
|
||||
width: '100%',
|
||||
maxWidth: '400px',
|
||||
marginTop: '1rem',
|
||||
})}
|
||||
>
|
||||
{canResumeGame && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={resumeGame}
|
||||
className={css({
|
||||
padding: '1rem',
|
||||
borderRadius: '0.5rem',
|
||||
background: 'teal.600',
|
||||
color: 'white',
|
||||
fontWeight: '600',
|
||||
fontSize: 'lg',
|
||||
cursor: 'pointer',
|
||||
border: 'none',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
background: 'teal.700',
|
||||
transform: 'translateY(-1px)',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Resume Game
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={startGame}
|
||||
className={css({
|
||||
padding: '1rem',
|
||||
borderRadius: '0.5rem',
|
||||
background: canResumeGame ? 'gray.600' : 'teal.600',
|
||||
color: 'white',
|
||||
fontWeight: '600',
|
||||
fontSize: 'lg',
|
||||
cursor: 'pointer',
|
||||
border: 'none',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
background: canResumeGame ? 'gray.700' : 'teal.700',
|
||||
transform: 'translateY(-1px)',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{canResumeGame ? 'Start New Game' : 'Start Game'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
72
apps/web/src/arcade-games/card-sorting/index.ts
Normal file
72
apps/web/src/arcade-games/card-sorting/index.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Card Sorting Challenge Game Definition
|
||||
*
|
||||
* A single-player pattern recognition game where players arrange abacus cards
|
||||
* in ascending order using only visual patterns (no numbers shown).
|
||||
*/
|
||||
|
||||
import { defineGame, getGameTheme } from '@/lib/arcade/game-sdk'
|
||||
import type { GameManifest } from '@/lib/arcade/game-sdk'
|
||||
import { GameComponent } from './components/GameComponent'
|
||||
import { CardSortingProvider } from './Provider'
|
||||
import type { CardSortingConfig, CardSortingMove, CardSortingState } from './types'
|
||||
import { cardSortingValidator } from './Validator'
|
||||
|
||||
const manifest: GameManifest = {
|
||||
name: 'card-sorting',
|
||||
displayName: 'Card Sorting Challenge',
|
||||
icon: '🔢',
|
||||
description: 'Sort abacus cards using pattern recognition',
|
||||
longDescription:
|
||||
'Challenge your abacus reading skills! Arrange cards in ascending order using only ' +
|
||||
'the visual patterns - no numbers shown. Perfect for practicing number recognition and ' +
|
||||
'developing mental math intuition.',
|
||||
maxPlayers: 1, // Single player only
|
||||
difficulty: 'Intermediate',
|
||||
chips: ['🧠 Pattern Recognition', '🎯 Solo Challenge', '📊 Smart Scoring'],
|
||||
...getGameTheme('teal'),
|
||||
available: true,
|
||||
}
|
||||
|
||||
const defaultConfig: CardSortingConfig = {
|
||||
cardCount: 8,
|
||||
showNumbers: true,
|
||||
timeLimit: null,
|
||||
}
|
||||
|
||||
// Config validation function
|
||||
function validateCardSortingConfig(config: unknown): config is CardSortingConfig {
|
||||
if (typeof config !== 'object' || config === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
const c = config as Record<string, unknown>
|
||||
|
||||
// Validate cardCount
|
||||
if (!('cardCount' in c) || ![5, 8, 12, 15].includes(c.cardCount as number)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Validate showNumbers
|
||||
if (!('showNumbers' in c) || typeof c.showNumbers !== 'boolean') {
|
||||
return false
|
||||
}
|
||||
|
||||
// Validate timeLimit
|
||||
if ('timeLimit' in c) {
|
||||
if (c.timeLimit !== null && (typeof c.timeLimit !== 'number' || c.timeLimit < 30)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export const cardSortingGame = defineGame<CardSortingConfig, CardSortingState, CardSortingMove>({
|
||||
manifest,
|
||||
Provider: CardSortingProvider,
|
||||
GameComponent,
|
||||
validator: cardSortingValidator,
|
||||
defaultConfig,
|
||||
validateConfig: validateCardSortingConfig,
|
||||
})
|
||||
208
apps/web/src/arcade-games/card-sorting/types.ts
Normal file
208
apps/web/src/arcade-games/card-sorting/types.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import type { GameConfig, GameState } from '@/lib/arcade/game-sdk/types'
|
||||
|
||||
// ============================================================================
|
||||
// Player Metadata
|
||||
// ============================================================================
|
||||
|
||||
export interface PlayerMetadata {
|
||||
id: string // Player ID (UUID)
|
||||
name: string
|
||||
emoji: string
|
||||
userId: string
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Configuration
|
||||
// ============================================================================
|
||||
|
||||
export interface CardSortingConfig extends GameConfig {
|
||||
cardCount: 5 | 8 | 12 | 15 // Difficulty (number of cards)
|
||||
showNumbers: boolean // Allow reveal numbers button
|
||||
timeLimit: number | null // Optional time limit (seconds), null = unlimited
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Core Data Types
|
||||
// ============================================================================
|
||||
|
||||
export type GamePhase = 'setup' | 'playing' | 'results'
|
||||
|
||||
export interface SortingCard {
|
||||
id: string // Unique ID for this card instance
|
||||
number: number // The abacus value (0-99+)
|
||||
svgContent: string // Serialized AbacusReact SVG
|
||||
}
|
||||
|
||||
export interface PlacedCard {
|
||||
card: SortingCard // The card data
|
||||
position: number // Which slot it's in (0-indexed)
|
||||
}
|
||||
|
||||
export interface ScoreBreakdown {
|
||||
finalScore: number // 0-100 weighted average
|
||||
exactMatches: number // Cards in exactly correct position
|
||||
lcsLength: number // Longest common subsequence length
|
||||
inversions: number // Number of out-of-order pairs
|
||||
relativeOrderScore: number // 0-100 based on LCS
|
||||
exactPositionScore: number // 0-100 based on exact matches
|
||||
inversionScore: number // 0-100 based on inversions
|
||||
elapsedTime: number // Seconds taken
|
||||
numbersRevealed: boolean // Whether player used reveal
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Game State
|
||||
// ============================================================================
|
||||
|
||||
export interface CardSortingState extends GameState {
|
||||
// Configuration
|
||||
cardCount: 5 | 8 | 12 | 15
|
||||
showNumbers: boolean
|
||||
timeLimit: number | null
|
||||
|
||||
// Game phase
|
||||
gamePhase: GamePhase
|
||||
|
||||
// Player & timing
|
||||
playerId: string // Single player ID
|
||||
playerMetadata: PlayerMetadata // Player display info
|
||||
gameStartTime: number | null
|
||||
gameEndTime: number | null
|
||||
|
||||
// Cards
|
||||
selectedCards: SortingCard[] // The N cards for this game
|
||||
correctOrder: SortingCard[] // Sorted by number (answer key)
|
||||
availableCards: SortingCard[] // Cards not yet placed
|
||||
placedCards: (SortingCard | null)[] // Array of N slots (null = empty)
|
||||
|
||||
// UI state (client-only, not in server state)
|
||||
selectedCardId: string | null // Currently selected card
|
||||
numbersRevealed: boolean // If player revealed numbers
|
||||
|
||||
// Results
|
||||
scoreBreakdown: ScoreBreakdown | null // Final score details
|
||||
|
||||
// Pause/Resume (standard pattern)
|
||||
originalConfig?: CardSortingConfig
|
||||
pausedGamePhase?: GamePhase
|
||||
pausedGameState?: {
|
||||
selectedCards: SortingCard[]
|
||||
availableCards: SortingCard[]
|
||||
placedCards: (SortingCard | null)[]
|
||||
gameStartTime: number
|
||||
numbersRevealed: boolean
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Game Moves
|
||||
// ============================================================================
|
||||
|
||||
export type CardSortingMove =
|
||||
| {
|
||||
type: 'START_GAME'
|
||||
playerId: string
|
||||
userId: string
|
||||
timestamp: number
|
||||
data: {
|
||||
playerMetadata: PlayerMetadata
|
||||
selectedCards: SortingCard[] // Pre-selected random cards
|
||||
}
|
||||
}
|
||||
| {
|
||||
type: 'PLACE_CARD'
|
||||
playerId: string
|
||||
userId: string
|
||||
timestamp: number
|
||||
data: {
|
||||
cardId: string // Which card to place
|
||||
position: number // Which slot (0-indexed)
|
||||
}
|
||||
}
|
||||
| {
|
||||
type: 'INSERT_CARD'
|
||||
playerId: string
|
||||
userId: string
|
||||
timestamp: number
|
||||
data: {
|
||||
cardId: string // Which card to insert
|
||||
insertPosition: number // Where to insert (0-indexed, can be 0 to cardCount)
|
||||
}
|
||||
}
|
||||
| {
|
||||
type: 'REMOVE_CARD'
|
||||
playerId: string
|
||||
userId: string
|
||||
timestamp: number
|
||||
data: {
|
||||
position: number // Which slot to remove from
|
||||
}
|
||||
}
|
||||
| {
|
||||
type: 'REVEAL_NUMBERS'
|
||||
playerId: string
|
||||
userId: string
|
||||
timestamp: number
|
||||
data: Record<string, never>
|
||||
}
|
||||
| {
|
||||
type: 'CHECK_SOLUTION'
|
||||
playerId: string
|
||||
userId: string
|
||||
timestamp: number
|
||||
data: Record<string, never>
|
||||
}
|
||||
| {
|
||||
type: 'GO_TO_SETUP'
|
||||
playerId: string
|
||||
userId: string
|
||||
timestamp: number
|
||||
data: Record<string, never>
|
||||
}
|
||||
| {
|
||||
type: 'SET_CONFIG'
|
||||
playerId: string
|
||||
userId: string
|
||||
timestamp: number
|
||||
data: {
|
||||
field: 'cardCount' | 'showNumbers' | 'timeLimit'
|
||||
value: unknown
|
||||
}
|
||||
}
|
||||
| {
|
||||
type: 'RESUME_GAME'
|
||||
playerId: string
|
||||
userId: string
|
||||
timestamp: number
|
||||
data: Record<string, never>
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Component Props
|
||||
// ============================================================================
|
||||
|
||||
export interface SortingCardProps {
|
||||
card: SortingCard
|
||||
isSelected: boolean
|
||||
isPlaced: boolean
|
||||
isCorrect?: boolean // After checking solution
|
||||
onClick: () => void
|
||||
showNumber: boolean // If revealed
|
||||
}
|
||||
|
||||
export interface PositionSlotProps {
|
||||
position: number
|
||||
card: SortingCard | null
|
||||
isActive: boolean // If slot is clickable
|
||||
isCorrect?: boolean // After checking solution
|
||||
gradientStyle: React.CSSProperties
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
export interface ScoreDisplayProps {
|
||||
breakdown: ScoreBreakdown
|
||||
correctOrder: SortingCard[]
|
||||
userOrder: SortingCard[]
|
||||
onNewGame: () => void
|
||||
onExit: () => void
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { AbacusReact } from '@soroban/abacus-react'
|
||||
import { renderToString } from 'react-dom/server'
|
||||
import type { SortingCard } from '../types'
|
||||
|
||||
/**
|
||||
* Generate random cards for sorting game
|
||||
* @param count Number of cards to generate
|
||||
* @param minValue Minimum abacus value (default 0)
|
||||
* @param maxValue Maximum abacus value (default 99)
|
||||
*/
|
||||
export function generateRandomCards(count: number, minValue = 0, maxValue = 99): SortingCard[] {
|
||||
// Generate pool of unique random numbers
|
||||
const numbers = new Set<number>()
|
||||
while (numbers.size < count) {
|
||||
const num = Math.floor(Math.random() * (maxValue - minValue + 1)) + minValue
|
||||
numbers.add(num)
|
||||
}
|
||||
|
||||
// Convert to sorted array (for answer key)
|
||||
const sortedNumbers = Array.from(numbers).sort((a, b) => a - b)
|
||||
|
||||
// Create card objects with SVG content
|
||||
return sortedNumbers.map((number, index) => {
|
||||
// Render AbacusReact to SVG string
|
||||
const svgContent = renderToString(
|
||||
<AbacusReact
|
||||
value={number}
|
||||
columns="auto"
|
||||
scaleFactor={1.0}
|
||||
interactive={false}
|
||||
showNumbers={false}
|
||||
animated={false}
|
||||
/>
|
||||
)
|
||||
|
||||
return {
|
||||
id: `card-${index}-${number}`,
|
||||
number,
|
||||
svgContent,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Shuffle array for random order
|
||||
*/
|
||||
export function shuffleCards(cards: SortingCard[]): SortingCard[] {
|
||||
const shuffled = [...cards]
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1))
|
||||
;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]
|
||||
}
|
||||
return shuffled
|
||||
}
|
||||
100
apps/web/src/arcade-games/card-sorting/utils/scoringAlgorithm.ts
Normal file
100
apps/web/src/arcade-games/card-sorting/utils/scoringAlgorithm.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import type { ScoreBreakdown } from '../types'
|
||||
|
||||
/**
|
||||
* Calculate Longest Common Subsequence length
|
||||
* Measures how many cards are in correct relative order
|
||||
*/
|
||||
export function longestCommonSubsequence(seq1: number[], seq2: number[]): number {
|
||||
const m = seq1.length
|
||||
const n = seq2.length
|
||||
const dp: number[][] = Array(m + 1)
|
||||
.fill(0)
|
||||
.map(() => Array(n + 1).fill(0))
|
||||
|
||||
for (let i = 1; i <= m; i++) {
|
||||
for (let j = 1; j <= n; j++) {
|
||||
if (seq1[i - 1] === seq2[j - 1]) {
|
||||
dp[i][j] = dp[i - 1][j - 1] + 1
|
||||
} else {
|
||||
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dp[m][n]
|
||||
}
|
||||
|
||||
/**
|
||||
* Count inversions (out-of-order pairs)
|
||||
* Measures how scrambled the sequence is
|
||||
*/
|
||||
export function countInversions(userSeq: number[], correctSeq: number[]): number {
|
||||
// Create mapping from value to correct position
|
||||
const correctPositions: Record<number, number> = {}
|
||||
for (let idx = 0; idx < correctSeq.length; idx++) {
|
||||
correctPositions[correctSeq[idx]] = idx
|
||||
}
|
||||
|
||||
// Convert user sequence to correct-position sequence
|
||||
const userCorrectPositions = userSeq.map((val) => correctPositions[val])
|
||||
|
||||
// Count inversions
|
||||
let inversions = 0
|
||||
for (let i = 0; i < userCorrectPositions.length; i++) {
|
||||
for (let j = i + 1; j < userCorrectPositions.length; j++) {
|
||||
if (userCorrectPositions[i] > userCorrectPositions[j]) {
|
||||
inversions++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return inversions
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate comprehensive score breakdown
|
||||
*/
|
||||
export function calculateScore(
|
||||
userSequence: number[],
|
||||
correctSequence: number[],
|
||||
startTime: number,
|
||||
numbersRevealed: boolean
|
||||
): ScoreBreakdown {
|
||||
// LCS-based score (relative order)
|
||||
const lcsLength = longestCommonSubsequence(userSequence, correctSequence)
|
||||
const relativeOrderScore = (lcsLength / correctSequence.length) * 100
|
||||
|
||||
// Exact position matches
|
||||
let exactMatches = 0
|
||||
for (let i = 0; i < userSequence.length; i++) {
|
||||
if (userSequence[i] === correctSequence[i]) {
|
||||
exactMatches++
|
||||
}
|
||||
}
|
||||
const exactPositionScore = (exactMatches / correctSequence.length) * 100
|
||||
|
||||
// Inversion-based score (organization)
|
||||
const inversions = countInversions(userSequence, correctSequence)
|
||||
const maxInversions = (correctSequence.length * (correctSequence.length - 1)) / 2
|
||||
const inversionScore = Math.max(0, ((maxInversions - inversions) / maxInversions) * 100)
|
||||
|
||||
// Weighted final score
|
||||
// - 50% for relative order (LCS)
|
||||
// - 30% for exact positions
|
||||
// - 20% for organization (inversions)
|
||||
const finalScore = Math.round(
|
||||
relativeOrderScore * 0.5 + exactPositionScore * 0.3 + inversionScore * 0.2
|
||||
)
|
||||
|
||||
return {
|
||||
finalScore,
|
||||
exactMatches,
|
||||
lcsLength,
|
||||
inversions,
|
||||
relativeOrderScore: Math.round(relativeOrderScore),
|
||||
exactPositionScore: Math.round(exactPositionScore),
|
||||
inversionScore: Math.round(inversionScore),
|
||||
elapsedTime: Math.floor((Date.now() - startTime) / 1000),
|
||||
numbersRevealed,
|
||||
}
|
||||
}
|
||||
102
apps/web/src/arcade-games/card-sorting/utils/validation.ts
Normal file
102
apps/web/src/arcade-games/card-sorting/utils/validation.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { SortingCard } from '../types'
|
||||
|
||||
/**
|
||||
* Place a card at a specific position (simple replacement, can leave gaps)
|
||||
* This is used when clicking directly on a slot
|
||||
* Returns old card if slot was occupied
|
||||
*/
|
||||
export function placeCardAtPosition(
|
||||
placedCards: (SortingCard | null)[],
|
||||
cardToPlace: SortingCard,
|
||||
position: number
|
||||
): { placedCards: (SortingCard | null)[]; replacedCard: SortingCard | null } {
|
||||
const newPlaced = [...placedCards]
|
||||
const replacedCard = newPlaced[position]
|
||||
newPlaced[position] = cardToPlace
|
||||
return { placedCards: newPlaced, replacedCard }
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a card at a specific position, shifting existing cards and compacting
|
||||
* This is used when clicking a + (insert) button
|
||||
* Returns new placedCards array with no gaps
|
||||
*/
|
||||
export function insertCardAtPosition(
|
||||
placedCards: (SortingCard | null)[],
|
||||
cardToPlace: SortingCard,
|
||||
insertPosition: number,
|
||||
totalSlots: number
|
||||
): { placedCards: (SortingCard | null)[]; excessCards: SortingCard[] } {
|
||||
// Create working array
|
||||
const newPlaced = new Array(totalSlots).fill(null)
|
||||
|
||||
// Copy existing cards, shifting those at/after position
|
||||
for (let i = 0; i < placedCards.length; i++) {
|
||||
if (placedCards[i] !== null) {
|
||||
if (i < insertPosition) {
|
||||
// Before insert position - stays same
|
||||
newPlaced[i] = placedCards[i]
|
||||
} else {
|
||||
// At or after position - shift right
|
||||
if (i + 1 < totalSlots) {
|
||||
newPlaced[i + 1] = placedCards[i]
|
||||
} else {
|
||||
// Card would fall off, will be handled by compaction
|
||||
newPlaced[i + 1] = placedCards[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Place new card at insert position
|
||||
newPlaced[insertPosition] = cardToPlace
|
||||
|
||||
// Compact to remove gaps (shift all cards left)
|
||||
const compacted: SortingCard[] = []
|
||||
for (const card of newPlaced) {
|
||||
if (card !== null) {
|
||||
compacted.push(card)
|
||||
}
|
||||
}
|
||||
|
||||
// Fill final array with compacted cards (no gaps)
|
||||
const result = new Array(totalSlots).fill(null)
|
||||
for (let i = 0; i < Math.min(compacted.length, totalSlots); i++) {
|
||||
result[i] = compacted[i]
|
||||
}
|
||||
|
||||
// Any excess cards are returned
|
||||
const excess = compacted.slice(totalSlots)
|
||||
|
||||
return { placedCards: result, excessCards: excess }
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove card at position
|
||||
*/
|
||||
export function removeCardAtPosition(
|
||||
placedCards: (SortingCard | null)[],
|
||||
position: number
|
||||
): { placedCards: (SortingCard | null)[]; removedCard: SortingCard | null } {
|
||||
const removedCard = placedCards[position]
|
||||
|
||||
if (!removedCard) {
|
||||
return { placedCards, removedCard: null }
|
||||
}
|
||||
|
||||
// Remove card and compact
|
||||
const compacted: SortingCard[] = []
|
||||
for (let i = 0; i < placedCards.length; i++) {
|
||||
if (i !== position && placedCards[i] !== null) {
|
||||
compacted.push(placedCards[i] as SortingCard)
|
||||
}
|
||||
}
|
||||
|
||||
// Fill new array
|
||||
const newPlaced = new Array(placedCards.length).fill(null)
|
||||
for (let i = 0; i < compacted.length; i++) {
|
||||
newPlaced[i] = compacted[i]
|
||||
}
|
||||
|
||||
return { placedCards: newPlaced, removedCard }
|
||||
}
|
||||
@@ -5,7 +5,16 @@
|
||||
|
||||
'use client'
|
||||
|
||||
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import {
|
||||
type GameMove,
|
||||
buildPlayerMetadata,
|
||||
@@ -29,6 +38,7 @@ interface CompatibleGameState {
|
||||
style: string
|
||||
timeoutSetting: string
|
||||
complementDisplay: string
|
||||
maxConcurrentPassengers: number
|
||||
|
||||
// Current question (extracted from currentQuestions[localPlayerId])
|
||||
currentQuestion: any | null
|
||||
@@ -93,7 +103,7 @@ interface ComplementRaceContextValue {
|
||||
lastError: string | null
|
||||
startGame: () => void
|
||||
submitAnswer: (answer: number, responseTime: number) => void
|
||||
claimPassenger: (passengerId: string) => void
|
||||
claimPassenger: (passengerId: string, carIndex: number) => void
|
||||
deliverPassenger: (passengerId: string) => void
|
||||
nextQuestion: () => void
|
||||
endGame: () => void
|
||||
@@ -102,6 +112,7 @@ interface ComplementRaceContextValue {
|
||||
setConfig: (field: keyof ComplementRaceConfig, value: unknown) => void
|
||||
clearError: () => void
|
||||
exitSession: () => void
|
||||
boostMomentum: (correct: boolean) => void // Client-side momentum boost/reduce
|
||||
}
|
||||
|
||||
const ComplementRaceContext = createContext<ComplementRaceContextValue | null>(null)
|
||||
@@ -119,12 +130,71 @@ export function useComplementRace() {
|
||||
|
||||
/**
|
||||
* Optimistic move application (client-side prediction)
|
||||
* For now, just return current state - server will validate and send back authoritative state
|
||||
* Apply moves immediately on client for responsive UI, server will confirm or reject
|
||||
*/
|
||||
function applyMoveOptimistically(state: ComplementRaceState, move: GameMove): ComplementRaceState {
|
||||
// Simple optimistic updates can be added here later
|
||||
// For now, rely on server validation
|
||||
return state
|
||||
const typedMove = move as ComplementRaceMove
|
||||
|
||||
switch (typedMove.type) {
|
||||
case 'CLAIM_PASSENGER': {
|
||||
// Optimistically mark passenger as claimed and assign to car
|
||||
const passengerId = typedMove.data.passengerId
|
||||
const carIndex = typedMove.data.carIndex
|
||||
const updatedPassengers = state.passengers.map((p) =>
|
||||
p.id === passengerId ? { ...p, claimedBy: typedMove.playerId, carIndex } : p
|
||||
)
|
||||
|
||||
// Optimistically add to player's passenger list
|
||||
const updatedPlayers = { ...state.players }
|
||||
const player = updatedPlayers[typedMove.playerId]
|
||||
if (player) {
|
||||
updatedPlayers[typedMove.playerId] = {
|
||||
...player,
|
||||
passengers: [...player.passengers, passengerId],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
passengers: updatedPassengers,
|
||||
players: updatedPlayers,
|
||||
}
|
||||
}
|
||||
|
||||
case 'DELIVER_PASSENGER': {
|
||||
// Optimistically mark passenger as delivered and award points
|
||||
const passengerId = typedMove.data.passengerId
|
||||
const passenger = state.passengers.find((p) => p.id === passengerId)
|
||||
if (!passenger) return state
|
||||
|
||||
const points = passenger.isUrgent ? 20 : 10
|
||||
const updatedPassengers = state.passengers.map((p) =>
|
||||
p.id === passengerId ? { ...p, deliveredBy: typedMove.playerId } : p
|
||||
)
|
||||
|
||||
// Optimistically remove from player's passenger list and update score
|
||||
const updatedPlayers = { ...state.players }
|
||||
const player = updatedPlayers[typedMove.playerId]
|
||||
if (player) {
|
||||
updatedPlayers[typedMove.playerId] = {
|
||||
...player,
|
||||
passengers: player.passengers.filter((id) => id !== passengerId),
|
||||
deliveredPassengers: player.deliveredPassengers + 1,
|
||||
score: player.score + points,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
passengers: updatedPassengers,
|
||||
players: updatedPlayers,
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
// For other moves, rely on server validation
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -240,6 +310,45 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
|
||||
})
|
||||
}, [activePlayers, players])
|
||||
|
||||
// Debug logging ref (track last logged values)
|
||||
const lastLogRef = useState({ key: '', count: 0 })[0]
|
||||
|
||||
// Client-side game state (NOT synced to server - purely visual/gameplay)
|
||||
const [clientMomentum, setClientMomentum] = useState(10) // Start at 10 for gentle push
|
||||
const [clientPosition, setClientPosition] = useState(0)
|
||||
const [clientPressure, setClientPressure] = useState(0)
|
||||
const [clientAIRacers, setClientAIRacers] = useState<
|
||||
Array<{
|
||||
id: string
|
||||
name: string
|
||||
position: number
|
||||
speed: number
|
||||
personality: 'competitive' | 'analytical'
|
||||
icon: string
|
||||
lastComment: number
|
||||
commentCooldown: number
|
||||
previousPosition: number
|
||||
}>
|
||||
>([])
|
||||
const lastUpdateRef = useRef(Date.now())
|
||||
const gameStartTimeRef = useRef(0)
|
||||
|
||||
// Decay rates based on skill level (momentum lost per second)
|
||||
const MOMENTUM_DECAY_RATES = {
|
||||
preschool: 2.0,
|
||||
kindergarten: 3.5,
|
||||
relaxed: 5.0,
|
||||
slow: 7.0,
|
||||
normal: 9.0,
|
||||
fast: 11.0,
|
||||
expert: 13.0,
|
||||
}
|
||||
|
||||
const MOMENTUM_GAIN_PER_CORRECT = 15
|
||||
const MOMENTUM_LOSS_PER_WRONG = 10
|
||||
const SPEED_MULTIPLIER = 0.15 // momentum * 0.15 = % per second
|
||||
const UPDATE_INTERVAL = 50 // 50ms = ~20fps
|
||||
|
||||
// Transform multiplayer state to look like single-player state
|
||||
const compatibleState = useMemo((): CompatibleGameState => {
|
||||
const localPlayer = localPlayerId ? multiplayerState.players[localPlayerId] : null
|
||||
@@ -264,6 +373,7 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
|
||||
style: multiplayerState.config.style,
|
||||
timeoutSetting: multiplayerState.config.timeoutSetting,
|
||||
complementDisplay: multiplayerState.config.complementDisplay,
|
||||
maxConcurrentPassengers: multiplayerState.config.maxConcurrentPassengers,
|
||||
|
||||
// Current question
|
||||
currentQuestion: localPlayerId
|
||||
@@ -290,23 +400,18 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
|
||||
// Race mechanics
|
||||
raceGoal: multiplayerState.config.raceGoal,
|
||||
timeLimit: multiplayerState.config.timeLimit ?? null,
|
||||
speedMultiplier: 1.0,
|
||||
aiRacers: multiplayerState.aiOpponents.map((ai) => ({
|
||||
id: ai.id,
|
||||
name: ai.name,
|
||||
position: ai.position,
|
||||
speed: ai.speed,
|
||||
personality: ai.personality,
|
||||
icon: ai.personality === 'competitive' ? '🏃♂️' : '🏃',
|
||||
lastComment: ai.lastCommentTime,
|
||||
commentCooldown: 0,
|
||||
previousPosition: ai.position,
|
||||
})),
|
||||
speedMultiplier:
|
||||
multiplayerState.config.style === 'practice'
|
||||
? 0.7
|
||||
: multiplayerState.config.style === 'sprint'
|
||||
? 0.9
|
||||
: 1.0, // Base speed multipliers by mode
|
||||
aiRacers: clientAIRacers, // Use client-side AI state
|
||||
|
||||
// Sprint mode specific
|
||||
momentum: localPlayer?.momentum || 0,
|
||||
trainPosition: localPlayer?.position || 0,
|
||||
pressure: localPlayer?.momentum ? Math.min(100, localPlayer.momentum + 10) : 0,
|
||||
// Sprint mode specific (all client-side for smooth movement)
|
||||
momentum: clientMomentum, // Client-only state with continuous decay
|
||||
trainPosition: clientPosition, // Client-calculated from momentum
|
||||
pressure: clientPressure, // Client-calculated from momentum (0-150 PSI)
|
||||
elapsedTime: multiplayerState.gameStartTime ? Date.now() - multiplayerState.gameStartTime : 0,
|
||||
lastCorrectAnswerTime: localPlayer?.lastAnswerTime || Date.now(),
|
||||
currentRoute: multiplayerState.currentRoute,
|
||||
@@ -328,11 +433,131 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
|
||||
adaptiveFeedback: localUIState.adaptiveFeedback,
|
||||
difficultyTracker: localUIState.difficultyTracker,
|
||||
}
|
||||
}, [multiplayerState, localPlayerId, localUIState])
|
||||
}, [
|
||||
multiplayerState,
|
||||
localPlayerId,
|
||||
localUIState,
|
||||
clientPosition,
|
||||
clientPressure,
|
||||
clientMomentum,
|
||||
clientAIRacers,
|
||||
])
|
||||
|
||||
console.log(
|
||||
`🚂 Sprint: momentum=${compatibleState.momentum} pos=${compatibleState.trainPosition} pressure=${compatibleState.pressure}`
|
||||
)
|
||||
// Initialize game start time when game becomes active
|
||||
useEffect(() => {
|
||||
if (compatibleState.isGameActive && compatibleState.style === 'sprint') {
|
||||
if (gameStartTimeRef.current === 0) {
|
||||
gameStartTimeRef.current = Date.now()
|
||||
lastUpdateRef.current = Date.now()
|
||||
// Reset client state for new game
|
||||
setClientMomentum(10) // Start with gentle push
|
||||
setClientPosition(0)
|
||||
setClientPressure((10 / 100) * 150) // Initial pressure from starting momentum
|
||||
}
|
||||
} else {
|
||||
// Reset when game ends
|
||||
gameStartTimeRef.current = 0
|
||||
}
|
||||
}, [compatibleState.isGameActive, compatibleState.style])
|
||||
|
||||
// Initialize AI racers when game starts
|
||||
useEffect(() => {
|
||||
if (compatibleState.isGameActive && multiplayerState.config.enableAI) {
|
||||
const count = multiplayerState.config.aiOpponentCount
|
||||
if (count > 0 && clientAIRacers.length === 0) {
|
||||
const aiNames = ['Swift AI', 'Math Bot', 'Speed Demon', 'Brain Bot']
|
||||
const personalities: Array<'competitive' | 'analytical'> = ['competitive', 'analytical']
|
||||
|
||||
const newAI = []
|
||||
for (let i = 0; i < Math.min(count, aiNames.length); i++) {
|
||||
// Use original balanced speeds: 0.32 for Swift AI, 0.2 for Math Bot
|
||||
const baseSpeed = i === 0 ? 0.32 : 0.2
|
||||
newAI.push({
|
||||
id: `ai-${i}`,
|
||||
name: aiNames[i],
|
||||
personality: personalities[i % personalities.length] as 'competitive' | 'analytical',
|
||||
position: 0,
|
||||
speed: baseSpeed, // Balanced speed from original single-player version
|
||||
icon: personalities[i % personalities.length] === 'competitive' ? '🏃♂️' : '🏃',
|
||||
lastComment: 0,
|
||||
commentCooldown: 0,
|
||||
previousPosition: 0,
|
||||
})
|
||||
}
|
||||
setClientAIRacers(newAI)
|
||||
}
|
||||
} else if (!compatibleState.isGameActive) {
|
||||
// Clear AI when game ends
|
||||
setClientAIRacers([])
|
||||
}
|
||||
}, [
|
||||
compatibleState.isGameActive,
|
||||
multiplayerState.config.enableAI,
|
||||
multiplayerState.config.aiOpponentCount,
|
||||
clientAIRacers.length,
|
||||
])
|
||||
|
||||
// Main client-side game loop: momentum decay and position calculation
|
||||
useEffect(() => {
|
||||
if (!compatibleState.isGameActive || compatibleState.style !== 'sprint') return
|
||||
|
||||
const interval = setInterval(() => {
|
||||
const now = Date.now()
|
||||
const deltaTime = now - lastUpdateRef.current
|
||||
lastUpdateRef.current = now
|
||||
|
||||
// Get decay rate based on skill level
|
||||
const decayRate =
|
||||
MOMENTUM_DECAY_RATES[compatibleState.timeoutSetting as keyof typeof MOMENTUM_DECAY_RATES] ||
|
||||
MOMENTUM_DECAY_RATES.normal
|
||||
|
||||
setClientMomentum((prevMomentum) => {
|
||||
// Calculate momentum decay for this frame
|
||||
const momentumLoss = (decayRate * deltaTime) / 1000
|
||||
|
||||
// Update momentum (don't go below 0)
|
||||
const newMomentum = Math.max(0, prevMomentum - momentumLoss)
|
||||
|
||||
// Calculate speed from momentum (% per second)
|
||||
const speed = newMomentum * SPEED_MULTIPLIER
|
||||
|
||||
// Update position (accumulate, never go backward)
|
||||
const positionDelta = (speed * deltaTime) / 1000
|
||||
setClientPosition((prev) => prev + positionDelta)
|
||||
|
||||
// Calculate pressure (0-150 PSI)
|
||||
const pressure = Math.min(150, (newMomentum / 100) * 150)
|
||||
setClientPressure(pressure)
|
||||
|
||||
return newMomentum
|
||||
})
|
||||
}, UPDATE_INTERVAL)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [
|
||||
compatibleState.isGameActive,
|
||||
compatibleState.style,
|
||||
compatibleState.timeoutSetting,
|
||||
MOMENTUM_DECAY_RATES,
|
||||
SPEED_MULTIPLIER,
|
||||
UPDATE_INTERVAL,
|
||||
])
|
||||
|
||||
// Reset client position when route changes
|
||||
useEffect(() => {
|
||||
const currentRoute = multiplayerState.currentRoute
|
||||
// When route changes, reset position and give starting momentum
|
||||
if (currentRoute > 1 && compatibleState.style === 'sprint') {
|
||||
console.log(
|
||||
`[Provider] Route changed to ${currentRoute}, resetting position. Passengers: ${multiplayerState.passengers.length}`
|
||||
)
|
||||
setClientPosition(0)
|
||||
setClientMomentum(10) // Reset to starting momentum (gentle push)
|
||||
}
|
||||
}, [multiplayerState.currentRoute, compatibleState.style, multiplayerState.passengers.length])
|
||||
|
||||
// Keep lastLogRef for future debugging needs
|
||||
// (removed debug logging)
|
||||
|
||||
// Action creators
|
||||
const startGame = useCallback(() => {
|
||||
@@ -378,7 +603,7 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
|
||||
)
|
||||
|
||||
const claimPassenger = useCallback(
|
||||
(passengerId: string) => {
|
||||
(passengerId: string, carIndex: number) => {
|
||||
const currentPlayerId = activePlayers.find((id) => {
|
||||
const player = players.get(id)
|
||||
return player?.isLocal
|
||||
@@ -390,7 +615,7 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
|
||||
type: 'CLAIM_PASSENGER',
|
||||
playerId: currentPlayerId,
|
||||
userId: viewerId || '',
|
||||
data: { passengerId },
|
||||
data: { passengerId, carIndex },
|
||||
} as ComplementRaceMove)
|
||||
},
|
||||
[activePlayers, players, viewerId, sendMove]
|
||||
@@ -532,8 +757,8 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
|
||||
break
|
||||
case 'BOARD_PASSENGER':
|
||||
case 'CLAIM_PASSENGER':
|
||||
if (action.passengerId !== undefined) {
|
||||
claimPassenger(action.passengerId)
|
||||
if (action.passengerId !== undefined && action.carIndex !== undefined) {
|
||||
claimPassenger(action.passengerId, action.carIndex)
|
||||
}
|
||||
break
|
||||
case 'DELIVER_PASSENGER':
|
||||
@@ -541,6 +766,18 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
|
||||
deliverPassenger(action.passengerId)
|
||||
}
|
||||
break
|
||||
case 'START_NEW_ROUTE':
|
||||
// Send route progression to server
|
||||
if (action.routeNumber !== undefined) {
|
||||
console.log(`[Provider] Dispatching START_NEW_ROUTE for route ${action.routeNumber}`)
|
||||
sendMove({
|
||||
type: 'START_NEW_ROUTE',
|
||||
playerId: activePlayers[0] || '',
|
||||
userId: viewerId || '',
|
||||
data: { routeNumber: action.routeNumber },
|
||||
} as ComplementRaceMove)
|
||||
}
|
||||
break
|
||||
// Local UI state actions
|
||||
case 'UPDATE_INPUT':
|
||||
setLocalUIState((prev) => ({ ...prev, currentInput: action.input || '' }))
|
||||
@@ -563,6 +800,18 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
|
||||
newBubbles.set(action.racerId, action.message)
|
||||
return { ...prev, activeSpeechBubbles: newBubbles }
|
||||
})
|
||||
// Update racer's lastComment time and cooldown to prevent spam
|
||||
setClientAIRacers((prevRacers) =>
|
||||
prevRacers.map((racer) =>
|
||||
racer.id === action.racerId
|
||||
? {
|
||||
...racer,
|
||||
lastComment: Date.now(),
|
||||
commentCooldown: Math.random() * 4000 + 2000, // 2-6 seconds
|
||||
}
|
||||
: racer
|
||||
)
|
||||
)
|
||||
break
|
||||
}
|
||||
case 'CLEAR_AI_COMMENT': {
|
||||
@@ -573,15 +822,55 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'UPDATE_AI_POSITIONS': {
|
||||
// Update client-side AI positions
|
||||
if (action.positions && Array.isArray(action.positions)) {
|
||||
setClientAIRacers((prevRacers) =>
|
||||
prevRacers.map((racer) => {
|
||||
const update = action.positions.find(
|
||||
(p: { id: string; position: number }) => p.id === racer.id
|
||||
)
|
||||
return update
|
||||
? {
|
||||
...racer,
|
||||
previousPosition: racer.position,
|
||||
position: update.position,
|
||||
}
|
||||
: racer
|
||||
})
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'UPDATE_AI_SPEEDS': {
|
||||
// Update client-side AI speeds (adaptive difficulty)
|
||||
if (action.racers && Array.isArray(action.racers)) {
|
||||
setClientAIRacers((prevRacers) =>
|
||||
prevRacers.map((racer) => {
|
||||
const update = action.racers.find(
|
||||
(r: { id: string; speed: number }) => r.id === racer.id
|
||||
)
|
||||
return update
|
||||
? {
|
||||
...racer,
|
||||
speed: update.speed,
|
||||
}
|
||||
: racer
|
||||
})
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'UPDATE_DIFFICULTY_TRACKER': {
|
||||
// Update local difficulty tracker state
|
||||
setLocalUIState((prev) => ({ ...prev, difficultyTracker: action.tracker }))
|
||||
break
|
||||
}
|
||||
// Other local actions that don't affect UI (can be ignored for now)
|
||||
case 'UPDATE_AI_POSITIONS':
|
||||
case 'UPDATE_MOMENTUM':
|
||||
case 'UPDATE_TRAIN_POSITION':
|
||||
case 'UPDATE_STEAM_JOURNEY':
|
||||
case 'UPDATE_DIFFICULTY_TRACKER':
|
||||
case 'UPDATE_AI_SPEEDS':
|
||||
case 'GENERATE_PASSENGERS':
|
||||
case 'START_NEW_ROUTE':
|
||||
case 'GENERATE_PASSENGERS': // Passengers generated server-side when route starts
|
||||
case 'COMPLETE_ROUTE':
|
||||
case 'HIDE_ROUTE_CELEBRATION':
|
||||
case 'COMPLETE_LAP':
|
||||
@@ -601,9 +890,28 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
|
||||
claimPassenger,
|
||||
deliverPassenger,
|
||||
multiplayerState.questionStartTime,
|
||||
sendMove,
|
||||
activePlayers,
|
||||
viewerId,
|
||||
]
|
||||
)
|
||||
|
||||
// Client-side momentum boost/reduce (sprint mode only)
|
||||
const boostMomentum = useCallback(
|
||||
(correct: boolean) => {
|
||||
if (compatibleState.style !== 'sprint') return
|
||||
|
||||
setClientMomentum((prevMomentum) => {
|
||||
if (correct) {
|
||||
return Math.min(100, prevMomentum + MOMENTUM_GAIN_PER_CORRECT)
|
||||
} else {
|
||||
return Math.max(0, prevMomentum - MOMENTUM_LOSS_PER_WRONG)
|
||||
}
|
||||
})
|
||||
},
|
||||
[compatibleState.style, MOMENTUM_GAIN_PER_CORRECT, MOMENTUM_LOSS_PER_WRONG]
|
||||
)
|
||||
|
||||
const contextValue: ComplementRaceContextValue = {
|
||||
state: compatibleState, // Use transformed state
|
||||
dispatch,
|
||||
@@ -619,6 +927,7 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
|
||||
setConfig,
|
||||
clearError,
|
||||
exitSession,
|
||||
boostMomentum, // Client-side momentum control
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -76,12 +76,6 @@ export class ComplementRaceValidator
|
||||
implements GameValidator<ComplementRaceState, ComplementRaceMove>
|
||||
{
|
||||
validateMove(state: ComplementRaceState, move: ComplementRaceMove): ValidationResult {
|
||||
console.log('[ComplementRace] Validating move:', {
|
||||
type: move.type,
|
||||
playerId: move.playerId,
|
||||
gamePhase: state.gamePhase,
|
||||
})
|
||||
|
||||
switch (move.type) {
|
||||
case 'START_GAME':
|
||||
return this.validateStartGame(state, move.data.activePlayers, move.data.playerMetadata)
|
||||
@@ -104,7 +98,12 @@ export class ComplementRaceValidator
|
||||
return this.validateUpdateInput(state, move.playerId, move.data.input)
|
||||
|
||||
case 'CLAIM_PASSENGER':
|
||||
return this.validateClaimPassenger(state, move.playerId, move.data.passengerId)
|
||||
return this.validateClaimPassenger(
|
||||
state,
|
||||
move.playerId,
|
||||
move.data.passengerId,
|
||||
move.data.carIndex
|
||||
)
|
||||
|
||||
case 'DELIVER_PASSENGER':
|
||||
return this.validateDeliverPassenger(state, move.playerId, move.data.passengerId)
|
||||
@@ -168,8 +167,7 @@ export class ComplementRaceValidator
|
||||
bestStreak: 0,
|
||||
correctAnswers: 0,
|
||||
totalQuestions: 0,
|
||||
position: 0,
|
||||
momentum: 50, // Start with some momentum in sprint mode
|
||||
position: 0, // Only used for practice/survival; sprint mode is client-side
|
||||
isReady: false,
|
||||
isActive: true,
|
||||
currentAnswer: null,
|
||||
@@ -191,8 +189,25 @@ export class ComplementRaceValidator
|
||||
? this.generatePassengers(state.config.passengerCount, state.stations)
|
||||
: []
|
||||
|
||||
// Calculate maxConcurrentPassengers based on initial passenger layout (sprint mode only)
|
||||
let updatedConfig = state.config
|
||||
if (state.config.style === 'sprint' && passengers.length > 0) {
|
||||
const maxConcurrentPassengers = Math.max(
|
||||
1,
|
||||
this.calculateMaxConcurrentPassengers(passengers, state.stations)
|
||||
)
|
||||
console.log(
|
||||
`[Game Start] Calculated maxConcurrentPassengers: ${maxConcurrentPassengers} for ${passengers.length} passengers`
|
||||
)
|
||||
updatedConfig = {
|
||||
...state.config,
|
||||
maxConcurrentPassengers,
|
||||
}
|
||||
}
|
||||
|
||||
const newState: ComplementRaceState = {
|
||||
...state,
|
||||
config: updatedConfig,
|
||||
gamePhase: 'playing', // Go directly to playing (countdown can be added later)
|
||||
activePlayers,
|
||||
playerMetadata: playerMetadata as typeof state.playerMetadata,
|
||||
@@ -203,6 +218,7 @@ export class ComplementRaceValidator
|
||||
routeStartTime: state.config.style === 'sprint' ? Date.now() : null,
|
||||
raceStartTime: Date.now(), // Race starts immediately
|
||||
gameStartTime: Date.now(),
|
||||
aiOpponents: [], // AI handled client-side
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
@@ -317,17 +333,9 @@ export class ComplementRaceValidator
|
||||
updatedPlayer.position = Math.min(100, player.position + 100 / state.config.raceGoal)
|
||||
}
|
||||
} else if (state.config.style === 'sprint') {
|
||||
// Sprint: Update momentum AND position
|
||||
if (correct) {
|
||||
updatedPlayer.momentum = Math.min(100, player.momentum + 15)
|
||||
} else {
|
||||
updatedPlayer.momentum = Math.max(0, player.momentum - 10)
|
||||
}
|
||||
|
||||
// Move train based on momentum (momentum/20 = position change per answer)
|
||||
// Higher momentum = faster movement
|
||||
const moveDistance = updatedPlayer.momentum / 20
|
||||
updatedPlayer.position = Math.min(100, player.position + moveDistance)
|
||||
// Sprint: All momentum/position handled client-side for smooth 20fps movement
|
||||
// Server only tracks scoring, passengers, and game progression
|
||||
// No server-side position updates needed
|
||||
} else if (state.config.style === 'survival') {
|
||||
// Survival: Always move forward, speed based on accuracy
|
||||
const moveDistance = correct ? 5 : 2
|
||||
@@ -396,7 +404,8 @@ export class ComplementRaceValidator
|
||||
private validateClaimPassenger(
|
||||
state: ComplementRaceState,
|
||||
playerId: string,
|
||||
passengerId: string
|
||||
passengerId: string,
|
||||
carIndex: number
|
||||
): ValidationResult {
|
||||
if (state.config.style !== 'sprint') {
|
||||
return { valid: false, error: 'Passengers only available in sprint mode' }
|
||||
@@ -423,22 +432,27 @@ export class ComplementRaceValidator
|
||||
return { valid: false, error: 'Passenger already claimed' }
|
||||
}
|
||||
|
||||
// Check if player is at the origin station (within 5% tolerance)
|
||||
const originStation = state.stations.find((s) => s.id === passenger.originStationId)
|
||||
if (!originStation) {
|
||||
return { valid: false, error: 'Origin station not found' }
|
||||
// Sprint mode: Position is client-side, trust client's spatial checking
|
||||
// (Client checks position in useSteamJourney before sending CLAIM move)
|
||||
// Other modes: Validate position server-side
|
||||
if (state.config.style !== 'sprint') {
|
||||
const originStation = state.stations.find((s) => s.id === passenger.originStationId)
|
||||
if (!originStation) {
|
||||
return { valid: false, error: 'Origin station not found' }
|
||||
}
|
||||
|
||||
const distance = Math.abs(player.position - originStation.position)
|
||||
if (distance > 5) {
|
||||
return { valid: false, error: 'Not at origin station' }
|
||||
}
|
||||
}
|
||||
|
||||
const distance = Math.abs(player.position - originStation.position)
|
||||
if (distance > 5) {
|
||||
return { valid: false, error: 'Not at origin station' }
|
||||
}
|
||||
|
||||
// Claim passenger
|
||||
// Claim passenger and assign to physical car
|
||||
const updatedPassengers = [...state.passengers]
|
||||
updatedPassengers[passengerIndex] = {
|
||||
...passenger,
|
||||
claimedBy: playerId,
|
||||
carIndex, // Store which physical car (0-N) the passenger is seated in
|
||||
}
|
||||
|
||||
const newState: ComplementRaceState = {
|
||||
@@ -486,15 +500,19 @@ export class ComplementRaceValidator
|
||||
return { valid: false, error: 'Passenger already delivered' }
|
||||
}
|
||||
|
||||
// Check if player is at destination station (within 5% tolerance)
|
||||
const destStation = state.stations.find((s) => s.id === passenger.destinationStationId)
|
||||
if (!destStation) {
|
||||
return { valid: false, error: 'Destination station not found' }
|
||||
}
|
||||
// Sprint mode: Position is client-side, trust client's spatial checking
|
||||
// (Client checks position in useSteamJourney before sending DELIVER move)
|
||||
// Other modes: Validate position server-side
|
||||
if (state.config.style !== 'sprint') {
|
||||
const destStation = state.stations.find((s) => s.id === passenger.destinationStationId)
|
||||
if (!destStation) {
|
||||
return { valid: false, error: 'Destination station not found' }
|
||||
}
|
||||
|
||||
const distance = Math.abs(player.position - destStation.position)
|
||||
if (distance > 5) {
|
||||
return { valid: false, error: 'Not at destination station' }
|
||||
const distance = Math.abs(player.position - destStation.position)
|
||||
if (distance > 5) {
|
||||
return { valid: false, error: 'Not at destination station' }
|
||||
}
|
||||
}
|
||||
|
||||
// Deliver passenger and award points
|
||||
@@ -527,12 +545,12 @@ export class ComplementRaceValidator
|
||||
return { valid: false, error: 'Routes only available in sprint mode' }
|
||||
}
|
||||
|
||||
// Reset all player positions to 0
|
||||
// Reset all player positions to 0 for new route (client handles momentum reset)
|
||||
const resetPlayers: Record<string, PlayerState> = {}
|
||||
for (const [playerId, player] of Object.entries(state.players)) {
|
||||
resetPlayers[playerId] = {
|
||||
...player,
|
||||
position: 0,
|
||||
position: 0, // Server position not used in sprint; client will reset
|
||||
passengers: [], // Clear any remaining passengers
|
||||
}
|
||||
}
|
||||
@@ -540,12 +558,26 @@ export class ComplementRaceValidator
|
||||
// Generate new passengers
|
||||
const newPassengers = this.generatePassengers(state.config.passengerCount, state.stations)
|
||||
|
||||
// Calculate maxConcurrentPassengers based on the new route's passenger layout
|
||||
const maxConcurrentPassengers = Math.max(
|
||||
1,
|
||||
this.calculateMaxConcurrentPassengers(newPassengers, state.stations)
|
||||
)
|
||||
|
||||
console.log(
|
||||
`[Route ${routeNumber}] Calculated maxConcurrentPassengers: ${maxConcurrentPassengers} for ${newPassengers.length} passengers`
|
||||
)
|
||||
|
||||
const newState: ComplementRaceState = {
|
||||
...state,
|
||||
currentRoute: routeNumber,
|
||||
routeStartTime: Date.now(),
|
||||
players: resetPlayers,
|
||||
passengers: newPassengers,
|
||||
config: {
|
||||
...state.config,
|
||||
maxConcurrentPassengers, // Update config with calculated value
|
||||
},
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
@@ -666,37 +698,126 @@ export class ComplementRaceValidator
|
||||
|
||||
private generatePassengers(count: number, stations: Station[]): Passenger[] {
|
||||
const passengers: Passenger[] = []
|
||||
const usedCombos = new Set<string>()
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
// Pick random origin and destination (must be different)
|
||||
const originIndex = Math.floor(Math.random() * stations.length)
|
||||
let destIndex = Math.floor(Math.random() * stations.length)
|
||||
while (destIndex === originIndex) {
|
||||
destIndex = Math.floor(Math.random() * stations.length)
|
||||
let name: string
|
||||
let avatar: string
|
||||
let comboKey: string
|
||||
|
||||
// Keep trying until we get a unique name/avatar combo
|
||||
do {
|
||||
const nameIndex = Math.floor(Math.random() * PASSENGER_NAMES.length)
|
||||
const avatarIndex = Math.floor(Math.random() * PASSENGER_AVATARS.length)
|
||||
name = PASSENGER_NAMES[nameIndex]
|
||||
avatar = PASSENGER_AVATARS[avatarIndex]
|
||||
comboKey = `${name}-${avatar}`
|
||||
} while (usedCombos.has(comboKey) && usedCombos.size < 100) // Prevent infinite loop
|
||||
|
||||
usedCombos.add(comboKey)
|
||||
|
||||
// Pick origin and destination stations
|
||||
// KEY: Destination must be AHEAD of origin (higher position on track)
|
||||
// This ensures passengers travel forward, creating better overlap
|
||||
let originStation: Station
|
||||
let destinationStation: Station
|
||||
|
||||
if (Math.random() < 0.4 || stations.length < 3) {
|
||||
// 40% chance to start at depot (first station)
|
||||
originStation = stations[0]
|
||||
// Pick any station ahead as destination
|
||||
const stationsAhead = stations.slice(1)
|
||||
destinationStation = stationsAhead[Math.floor(Math.random() * stationsAhead.length)]
|
||||
} else {
|
||||
// Start at a random non-depot, non-final station
|
||||
const nonDepotStations = stations.slice(1, -1) // Exclude depot and final station
|
||||
originStation = nonDepotStations[Math.floor(Math.random() * nonDepotStations.length)]
|
||||
// Pick a station ahead of origin (higher position)
|
||||
const stationsAhead = stations.filter((s) => s.position > originStation.position)
|
||||
destinationStation = stationsAhead[Math.floor(Math.random() * stationsAhead.length)]
|
||||
}
|
||||
|
||||
const nameIndex = Math.floor(Math.random() * PASSENGER_NAMES.length)
|
||||
const avatarIndex = Math.floor(Math.random() * PASSENGER_AVATARS.length)
|
||||
// 30% chance of urgent
|
||||
const isUrgent = Math.random() < 0.3
|
||||
|
||||
passengers.push({
|
||||
const passenger = {
|
||||
id: `p-${Date.now()}-${i}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
name: PASSENGER_NAMES[nameIndex],
|
||||
avatar: PASSENGER_AVATARS[avatarIndex],
|
||||
originStationId: stations[originIndex].id,
|
||||
destinationStationId: stations[destIndex].id,
|
||||
isUrgent: Math.random() < 0.3, // 30% chance of urgent
|
||||
name,
|
||||
avatar,
|
||||
originStationId: originStation.id,
|
||||
destinationStationId: destinationStation.id,
|
||||
isUrgent,
|
||||
claimedBy: null,
|
||||
deliveredBy: null,
|
||||
carIndex: null, // Not boarded yet
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
passengers.push(passenger)
|
||||
|
||||
console.log(
|
||||
`[Passenger ${i + 1}/${count}] ${name} waiting at ${originStation.emoji} ${originStation.name} (pos ${originStation.position}) → ${destinationStation.emoji} ${destinationStation.name} (pos ${destinationStation.position}) ${isUrgent ? '⚡ URGENT' : ''}`
|
||||
)
|
||||
}
|
||||
|
||||
console.log(`[Generated ${passengers.length} passengers total]`)
|
||||
return passengers
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the maximum number of passengers that will be on the train
|
||||
* concurrently at any given moment during the route
|
||||
*/
|
||||
private calculateMaxConcurrentPassengers(passengers: Passenger[], stations: Station[]): number {
|
||||
// Create events for boarding and delivery
|
||||
interface StationEvent {
|
||||
position: number
|
||||
isBoarding: boolean // true = board, false = delivery
|
||||
}
|
||||
|
||||
const events: StationEvent[] = []
|
||||
|
||||
for (const passenger of passengers) {
|
||||
const originStation = stations.find((s) => s.id === passenger.originStationId)
|
||||
const destStation = stations.find((s) => s.id === passenger.destinationStationId)
|
||||
|
||||
if (originStation && destStation) {
|
||||
events.push({ position: originStation.position, isBoarding: true })
|
||||
events.push({ position: destStation.position, isBoarding: false })
|
||||
}
|
||||
}
|
||||
|
||||
// Sort events by position, with deliveries before boardings at the same position
|
||||
events.sort((a, b) => {
|
||||
if (a.position !== b.position) return a.position - b.position
|
||||
// At same position, deliveries happen before boarding
|
||||
return a.isBoarding ? 1 : -1
|
||||
})
|
||||
|
||||
// Track current passenger count and maximum
|
||||
let currentCount = 0
|
||||
let maxCount = 0
|
||||
|
||||
for (const event of events) {
|
||||
if (event.isBoarding) {
|
||||
currentCount++
|
||||
maxCount = Math.max(maxCount, currentCount)
|
||||
} else {
|
||||
currentCount--
|
||||
}
|
||||
}
|
||||
|
||||
return maxCount
|
||||
}
|
||||
|
||||
private checkWinCondition(state: ComplementRaceState): string | null {
|
||||
const { config, players } = state
|
||||
|
||||
// Infinite mode: Never end the game
|
||||
if (config.winCondition === 'infinite') {
|
||||
return null
|
||||
}
|
||||
|
||||
// Practice mode: First to reach goal
|
||||
if (config.style === 'practice') {
|
||||
for (const [playerId, player] of Object.entries(players)) {
|
||||
@@ -704,6 +825,7 @@ export class ComplementRaceValidator
|
||||
return playerId
|
||||
}
|
||||
}
|
||||
// AI wins handled client-side via useAIRacers hook
|
||||
}
|
||||
|
||||
// Sprint mode: Check route-based, score-based, or time-based win conditions
|
||||
@@ -755,12 +877,15 @@ export class ComplementRaceValidator
|
||||
// Find player with highest position (most laps)
|
||||
let maxPosition = 0
|
||||
let winner: string | null = null
|
||||
|
||||
for (const [playerId, player] of Object.entries(players)) {
|
||||
if (player.position > maxPosition) {
|
||||
maxPosition = player.position
|
||||
winner = playerId
|
||||
}
|
||||
}
|
||||
// AI wins handled client-side via useAIRacers hook
|
||||
|
||||
return winner
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user