Compare commits
1,768 commits
portallist
...
nigig-make
| Author | SHA1 | Date | |
|---|---|---|---|
| 5efe6e24c9 | |||
| 28044a7367 | |||
| d82756a364 | |||
|
|
a79f0dce4d | ||
| 11375214b3 | |||
|
|
5eda8056f4 | ||
| d6d1f99ca9 | |||
| 817d881052 | |||
| 16a4d04cbb | |||
|
|
273ccc452c | ||
|
|
b41e7404b6 |
||
|
|
434583def4 |
||
|
|
3af2e93f0d |
||
|
|
b01e35faca |
||
|
|
a4fea8e259 |
||
|
|
4f9ce7a8bb | ||
|
|
b08a098cc3 | ||
|
|
221b6a4a72 | ||
|
|
426501ff72 | ||
|
|
eb4165d5a0 | ||
|
|
e7a7cad092 | ||
|
|
77e7d65fb7 | ||
|
|
4cfb51877c | ||
|
|
fbd4972450 | ||
|
|
449f51be7a | ||
|
|
2a680887d5 | ||
|
|
176725877d | ||
|
|
7354855ef2 | ||
|
|
eb0b7de06e | ||
|
|
550d89b446 | ||
|
|
be21d627a0 | ||
|
|
c2f54e7095 | ||
|
|
bb39a57e37 | ||
|
|
0bba179884 | ||
|
|
4e498ca934 | ||
|
|
95eb8086fc | ||
|
|
901eafb07b | ||
|
|
d95c096bcd | ||
|
|
e990959b55 | ||
|
|
b976c2e40c | ||
|
|
49e3bfc5ad | ||
|
|
c7daefe6a3 | ||
|
|
d8e1bade98 | ||
|
|
12cd7865e2 | ||
|
|
f75dd2bba6 | ||
|
|
11b955694d | ||
|
|
fed43d7ec3 | ||
|
|
02d72d4e10 | ||
|
|
c5b03d3f10 | ||
|
|
c9cbb4334b | ||
|
|
604a0b60d7 | ||
|
|
5b88fcf311 | ||
|
|
6187f463b2 | ||
|
|
3370d15264 | ||
|
|
630577cb98 | ||
|
|
470cffcdac | ||
|
|
35c256a76d | ||
|
|
364d65680a | ||
|
|
995aa23bc1 | ||
|
|
dfa0b07753 | ||
|
|
ec3378b060 | ||
|
|
66b42fc9dd | ||
|
|
88f7d7a2c6 | ||
|
|
60ec705ab2 | ||
|
|
848e715c6c | ||
|
|
30eeae75ca |
||
|
|
c65b72efad |
||
|
|
3a82d26045 | ||
|
|
cb93fa9822 |
||
|
|
82abd655b9 | ||
|
|
a4c87a64dd | ||
|
|
e7939c8f14 | ||
|
|
6c3c3252bf | ||
|
|
c50a74b3b7 | ||
|
|
5cb5219d23 | ||
|
|
a6c7a22ba8 | ||
|
|
283ef5553a | ||
|
|
02592599ec | ||
|
|
837da0d7bd | ||
|
|
7861ecb10e | ||
|
|
cba6d6ff7e | ||
|
|
ac10a82c34 | ||
|
|
3ad65e8bd3 | ||
|
|
725a726e3d | ||
|
|
374fa0c285 | ||
|
|
f91b0fa0bd | ||
|
|
70062ed86b | ||
|
|
ba0306d4ee | ||
|
|
82db45a00c |
||
|
|
a13b8876a5 |
||
|
|
d37a34f24e | ||
|
|
7aae8d6ffe |
||
|
|
e4e1585b05 |
||
|
|
af130e98fd |
||
|
|
529f7d7720 |
||
|
|
bc13b891e5 |
||
|
|
4f1f545ef3 |
||
|
|
585475d02a |
||
|
|
bb1474003c |
||
|
|
8b2e7e3eb0 |
||
|
|
8b03b0b2ad |
||
|
|
f5df1eb4d1 |
||
|
|
850edc8ca6 |
||
|
|
221cd7a1d5 |
||
|
|
393206a356 |
||
|
|
013e355e23 |
||
|
|
ad73981737 |
||
|
|
0bc3c7798b |
||
|
|
efeb6d6bbf |
||
|
|
7fff61c7a5 |
||
|
|
f497a9fc14 |
||
|
|
cb8ed4c119 | ||
|
|
e3ac9f2035 |
||
|
|
52d290581f | ||
|
|
a86f6e3bda | ||
|
|
4912f2690d | ||
|
|
e697eb81ae |
||
|
|
eba743032d |
||
|
|
3d18a137ca | ||
|
|
12dcfc1e77 |
||
|
|
46ee22634c |
||
|
|
d0bc4c8a69 |
||
|
|
a391f5e347 |
||
|
|
877234c6cf |
||
|
|
8447f5666a |
||
|
|
6d1e9d324c |
||
|
|
a982f741d7 |
||
|
|
d5d85d7501 |
||
|
|
c96b28628c |
||
|
|
5416cdd25c |
||
|
|
3eae9b762a |
||
|
|
8c276fefbc |
||
|
|
e76019a9f4 | ||
|
|
621c7dfee4 | ||
|
|
5ebbe29850 | ||
|
|
245d47ef5a | ||
|
|
9df7c778f3 | ||
|
|
4eef5a1cb0 | ||
|
|
84c634ac32 | ||
|
|
4c3093a39b |
||
|
|
fdcf33e167 | ||
|
|
ee39df2089 |
||
|
|
b377e6b9e4 | ||
|
|
2a7e59ac84 | ||
|
|
17ca8cf9ce | ||
|
|
ba1ba116c3 | ||
|
|
48612ea771 | ||
|
|
30dc2e621e | ||
|
|
1dfae4654b | ||
|
|
77208b237e | ||
|
|
c2fb2c4b06 | ||
|
|
a5992d3fb2 | ||
|
|
fe7cc8babc | ||
|
|
55db1be071 | ||
|
|
20b6c53b6f |
||
|
|
e0549dc502 |
||
|
|
5ec03ff0ad |
||
|
|
ecff7b816b |
||
|
|
3fe61b3ed0 |
||
|
|
1a2453e7e4 |
||
|
|
9d3d0e4450 | ||
|
|
a3294c3c99 | ||
|
|
d8d6a7d971 | ||
|
|
61a3f53c5c | ||
|
|
30385ae3c6 | ||
|
|
614e203f17 | ||
|
|
b32022a7d5 |
||
|
|
cd6d2e78cd |
||
|
|
07354753b1 |
||
|
|
93aaca16fe |
||
|
|
5d214947d7 |
||
|
|
18669f5a58 |
||
|
|
ea993f30c4 | ||
|
|
92eee25a4c | ||
|
|
2325f23dbe | ||
|
|
8049779c0b | ||
|
|
2faaf7b16b |
||
|
|
047e9cba21 |
||
|
|
c7d7202551 |
||
|
|
87863fe42f | ||
|
|
4a576a7add |
||
|
|
85fbea9b0f |
||
|
|
03a64a9406 |
||
|
|
293804f044 |
||
|
|
93a5a875f3 |
||
|
|
6c8d210d9d | ||
|
|
12a0e1b135 | ||
|
|
4a562a2b51 | ||
|
|
eba806b819 | ||
|
|
51056831c5 |
||
|
|
6102d4329b |
||
|
|
e66d1041cd |
||
|
|
007bf000a1 |
||
|
|
0136260f6a |
||
|
|
3cf602a432 |
||
|
|
113ab5c6ad |
||
|
|
ccecc172e9 |
||
|
|
c9ada286a3 | ||
|
|
56d948d62f | ||
|
|
7b81d7ef6b | ||
|
|
ceb1e384e8 | ||
|
|
1ab6e6ed27 | ||
|
|
4b801ae74e | ||
|
|
dc3baaf69d | ||
|
|
3335c26f27 | ||
|
|
93c013707a | ||
|
|
cc480e274e | ||
|
|
985cb4adb6 |
||
|
|
b262f7bac3 | ||
|
|
d7520e837f | ||
|
|
f1a2bec527 | ||
|
|
3a90346eaa | ||
|
|
5e7955770e | ||
|
|
4bb2190ea8 | ||
|
|
b12af23a7a | ||
|
|
ffc578c243 | ||
|
|
32612cfe88 | ||
|
|
c925d2a56b | ||
|
|
747ef9b2e8 | ||
|
|
db323ee42e | ||
|
|
fc5d960062 | ||
|
|
087952a638 | ||
|
|
126e848063 |
||
|
|
92006e12e3 |
||
|
|
ebe1cab1b7 |
||
|
|
3d81877bf4 |
||
|
|
d12fa28a8e |
||
|
|
7c25ded861 |
||
|
|
7b5b05fb29 |
||
|
|
2374359993 |
||
|
|
5e7d2a45a2 |
||
|
|
cc60726b35 |
||
|
|
3e0330b239 |
||
|
|
fa68f930fa |
||
|
|
f2c02e878e |
||
|
|
ab006d0e25 |
||
|
|
e2cf5592c9 |
||
|
|
c9cabd2554 | ||
|
|
3968a4aaba | ||
|
|
0f0ef763d6 | ||
|
|
d8883d1783 | ||
|
|
73544cbf60 | ||
|
|
641a158ec5 | ||
|
|
25df7febe0 | ||
|
|
c6906d2674 | ||
|
|
a2f032ecc7 | ||
|
|
a21f8f480c | ||
|
|
26cd960c48 | ||
|
|
a9d6172f8c | ||
|
|
17eb90842d |
||
|
|
b869208cea |
||
|
|
a00ae30dd0 |
||
|
|
ce7da33f1d |
||
|
|
1cda404284 |
||
|
|
10c33aca95 |
||
|
|
7bcacc33f8 |
||
|
|
0f2939945c |
||
|
|
4fc9a06b06 | ||
|
|
3f2baab26d | ||
|
|
cb3b36d241 | ||
|
|
95c822450b | ||
|
|
f6cf4e5c13 | ||
|
|
5f20c2b6da | ||
|
|
c8280ee338 | ||
|
|
1c7835fca9 | ||
|
|
96bbe681d8 | ||
|
|
e343aa00d4 | ||
|
|
fcc003814e | ||
|
|
02704b27a9 |
||
|
|
507bcf35d1 |
||
|
|
3a6499b70d |
||
|
|
f99dee329c |
||
|
|
191ac72bf9 |
||
|
|
b5f2562768 | ||
|
|
966c8fda96 | ||
|
|
2bb0fe88cc | ||
|
|
27220ca062 | ||
|
|
b99ce4fe48 | ||
|
|
7fb8f420a4 | ||
|
|
cfd39ecc72 | ||
|
|
a0732c853c | ||
|
|
eddabcf8ca | ||
|
|
d0785c5212 | ||
|
|
ebcf0d58ac | ||
|
|
3a29497923 | ||
|
|
bc70aa5ed7 | ||
|
|
f8ffda732a | ||
|
|
0fdc86b528 | ||
|
|
d83941cf7a | ||
|
|
4056a99c14 | ||
|
|
afeadc8650 | ||
|
|
ac3ed67fe6 | ||
|
|
fcc98d6d69 | ||
|
|
3e7fb23e95 | ||
|
|
cf1b38f9d0 | ||
|
|
d596d15d9e | ||
|
|
c352bf75e8 | ||
|
|
efb78e542f | ||
|
|
bc64ac1164 | ||
|
|
ebdd3fe530 | ||
|
|
a495095c9a | ||
|
|
0cc533660e | ||
|
|
cf0b9d0153 | ||
|
|
282a9a34e2 | ||
|
|
29ca115f14 | ||
|
|
5112d37ebd | ||
|
|
2819866999 | ||
|
|
0088feb734 | ||
|
|
83fd90015f | ||
|
|
83a9fa2017 | ||
|
|
a517b9cb42 | ||
|
|
d2853799f9 | ||
|
|
6da8ad2f23 |
||
|
|
afbca7d466 |
||
|
|
788d7a42c4 | ||
|
|
0d13952005 |
||
|
|
014732ecf7 | ||
|
|
f868b8f001 | ||
|
|
b15a346d8a | ||
|
|
78296f515b | ||
|
|
2820fa6bf5 | ||
|
|
5e04f6fb06 | ||
|
|
b6fa66ae41 | ||
|
|
66075ff67f |
||
|
|
8583c07a84 |
||
|
|
988d28505c | ||
|
|
6431464aa1 | ||
|
|
d997a1b05a | ||
|
|
7a102e5cf4 | ||
|
|
0fc2114f61 | ||
|
|
3f7b0a1bac | ||
|
|
0cff3fd7f6 |
||
|
|
e309f9c34e |
||
|
|
5fc97da197 |
||
|
|
43ba50e59a |
||
|
|
770e9cfbdd |
||
|
|
a6ea8a662b |
||
|
|
d1f3ca7f73 | ||
|
|
e0e73f9ea6 | ||
|
|
06c3e1c0d1 | ||
|
|
2b677c6da0 | ||
|
|
b9287c3888 | ||
|
|
dea06dd282 | ||
|
|
e888e946ab | ||
|
|
f9ac0d651e | ||
|
|
8ec45d41a0 | ||
|
|
84ec5394ba | ||
|
|
2f3260f214 | ||
|
|
a0351cf90e |
||
|
|
d63676590f |
||
|
|
4fa866bf4a |
||
|
|
291e9365a2 |
||
|
|
0aa7118f78 | ||
|
|
d50d15f16b | ||
|
|
2cff94d01b | ||
|
|
be1229492d |
||
|
|
7e51266f5b |
||
|
|
7134504e82 |
||
|
|
785b823751 | ||
|
|
840b8b5cb3 | ||
|
|
2914c74b79 | ||
|
|
d5e73849a5 | ||
|
|
02411768e1 | ||
|
|
23e1973ac9 | ||
|
|
bb24dbd50a | ||
|
|
5a4bef21d6 | ||
|
|
4c7b806524 | ||
|
|
6cd202a553 | ||
|
|
52afaf022b | ||
|
|
1a0f01ce89 | ||
|
|
1fe83a5c6c |
||
|
|
987f21bea4 | ||
|
|
3803e091de | ||
|
|
efb38f2f89 | ||
|
|
91f0873847 | ||
|
|
72269c0da2 | ||
|
|
543ae72c34 | ||
|
|
c3585c7b98 | ||
|
|
a7c5942177 | ||
|
|
a8f5877687 | ||
|
|
36a1a6b62a | ||
|
|
49750e5d54 | ||
|
|
c1cee9c216 | ||
|
|
debd9f33ed | ||
|
|
a2a3347144 | ||
|
|
c3ad851992 | ||
|
|
94711aa64b |
||
|
|
a9fe1a8090 |
||
|
|
1cf51b3261 | ||
|
|
2b79d27b44 |
||
|
|
53fa2aa86b | ||
|
|
6658d6515f | ||
|
|
b070eb7477 | ||
|
|
64b1a7c74b | ||
|
|
92e751ffde | ||
|
|
da93a47467 | ||
|
|
8ee34e0e84 | ||
|
|
057035243b | ||
|
|
0525556ceb | ||
|
|
3e3f4fd2e0 | ||
|
|
4db21794fe | ||
|
|
f4279de855 |
||
|
|
51f23402b5 |
||
|
|
7ab7f324b3 |
||
|
|
f3178e65a6 |
||
|
|
5a6eacd7ef |
||
|
|
fe5cd60087 |
||
|
|
8189d8eab2 |
||
|
|
1feaac5494 |
||
|
|
2ebeb20262 |
||
|
|
7dd4597e83 |
||
|
|
f337fcd9be |
||
|
|
67d6ed2906 |
||
|
|
f040a2f2a6 | ||
|
|
bca911513d | ||
|
|
2e900f48af |
||
|
|
3242b11afc |
||
|
|
61a70d0ebd | ||
|
|
0c80201e66 | ||
|
|
1bad158945 | ||
|
|
7895cd34fc | ||
|
|
cde02e2e48 | ||
|
|
bba8868caf |
||
|
|
feb1ecd55f | ||
|
|
a6eb9906a0 | ||
|
|
5170eed9af | ||
|
|
ffbeadc173 |
||
|
|
c344dcd4fb |
||
|
|
c44b456ebc |
||
|
|
5407f085e7 |
||
|
|
609655aa5c | ||
|
|
39d107a09b | ||
|
|
9cf8128f15 |
||
|
|
fbde8812a3 | ||
|
|
bdf760b58b | ||
|
|
7a9274ebf8 | ||
|
|
612f3b9cd8 | ||
|
|
e23a8b3f26 | ||
|
|
cceb499765 |
||
|
|
928521e994 | ||
|
|
def4c5f6e6 | ||
|
|
e79e374375 | ||
|
|
38d08cab0b | ||
|
|
ba51dceee4 | ||
|
|
7c7d91b8ad |
||
|
|
c22cf23cf0 |
||
|
|
814fe4d9cd |
||
|
|
cb87a70b74 | ||
|
|
3d7852d0b3 | ||
|
|
3e8e09fbe9 |
||
|
|
04dea089bd |
||
|
|
27b910ff6c |
||
|
|
ec505b27f0 | ||
|
|
a1a9c38004 | ||
|
|
24a9b0627d | ||
|
|
923a554e49 | ||
|
|
b64a83fa15 | ||
|
|
5d81bf9490 |
||
|
|
9124a025af | ||
|
|
bc6da37276 | ||
|
|
e61e3bfbce | ||
|
|
15d39cca74 |
||
|
|
df1b267569 | ||
|
|
5db0aa3686 | ||
|
|
7c5c0edeba | ||
|
|
a7d471a8ad | ||
|
|
42a45913d6 | ||
|
|
f00f4c0560 | ||
|
|
9a8020fec0 | ||
|
|
7a3cc93921 | ||
|
|
0846e575b0 | ||
|
|
06fb9ee8eb |
||
|
|
713628a89f |
||
|
|
d19f99d1ef |
||
|
|
5028a602d2 |
||
|
|
60039d4ee9 |
||
|
|
4ff3be2f20 |
||
|
|
35792db27f | ||
|
|
d5f198d126 | ||
|
|
83c628eaa8 | ||
|
|
08cc3f44af | ||
|
|
9e1d37d6ef | ||
|
|
6c465092da | ||
|
|
1924bc2f6b | ||
|
|
c9bd068a70 | ||
|
|
4207f0d4e9 | ||
|
|
3b0f5a5c40 | ||
|
|
394ee17864 | ||
|
|
8c3f839ee3 | ||
|
|
8ee09a8466 | ||
|
|
00ce91ab5e | ||
|
|
7f313a6261 | ||
|
|
8e3ae8ea1c |
||
|
|
8b515338a2 | ||
|
|
4bc5cec4cb | ||
|
|
923a9c091b | ||
|
|
dbf82942c6 | ||
|
|
5a89cc27f1 | ||
|
|
8d65fdb31f | ||
|
|
650767daa8 | ||
|
|
79d844450d | ||
|
|
4ad0be2a69 | ||
|
|
47f906c340 | ||
|
|
7adc274be7 | ||
|
|
adc773d764 | ||
|
|
4a87f4ca31 | ||
|
|
9ea5dc9471 | ||
|
|
b8d431bf8d | ||
|
|
f9e68f8650 | ||
|
|
051f48de84 | ||
|
|
b30788f8cd | ||
|
|
45ecd5f006 | ||
|
|
2d0d853ffe | ||
|
|
704d2a7a5f | ||
|
|
8d0694b78f | ||
|
|
05238ee39d | ||
|
|
96c3992c94 | ||
|
|
ecc2ad6086 | ||
|
|
d7c69e49bb | ||
|
|
c0125ba11d | ||
|
|
cb92b61b66 | ||
|
|
eeec57f459 | ||
|
|
98547946dd | ||
|
|
b8626a0b40 | ||
|
|
81483d4555 | ||
|
|
0f3864a44a | ||
|
|
17d86d145e | ||
|
|
b7d573eaf5 |
||
|
|
1b800c4c8e |
||
|
|
91c6ea5859 |
||
|
|
ee8e75b713 | ||
|
|
e163f5d11a | ||
|
|
c545c2259e | ||
|
|
4edce27d8c | ||
|
|
4f38c0c324 | ||
|
|
e64e606caf | ||
|
|
5d6472c2a0 | ||
|
|
5b27043eed | ||
|
|
b437899d82 | ||
|
|
462fd3dcec | ||
|
|
2b06c8846a | ||
|
|
0d353ba303 | ||
|
|
524ac9186d | ||
|
|
0d96511a11 | ||
|
|
6c17b5aac8 | ||
|
|
1ef52d6553 | ||
|
|
4542da2d17 | ||
|
|
53971390b2 | ||
|
|
fe028654af | ||
|
|
c47697bc2e | ||
|
|
f844171542 | ||
|
|
30115c6e4f | ||
|
|
ad546ec237 | ||
|
|
b6c45297f4 | ||
|
|
1632445402 | ||
|
|
01a4985e86 | ||
|
|
6712047ece | ||
|
|
9c4dcebdc6 | ||
|
|
79ac8d834f | ||
|
|
051557c569 | ||
|
|
084c3df5bc | ||
|
|
0bffb98f28 | ||
|
|
52ed7f698b | ||
|
|
a8599a4b22 | ||
|
|
6c8716a121 | ||
|
|
952e6134a9 | ||
|
|
0ce6ec05e8 |
||
|
|
d544b7335b |
||
|
|
3b2e5730ae |
||
|
|
445e74de24 |
||
|
|
f0907d6782 |
||
|
|
975e766c1a |
||
|
|
ecb5e7ffae |
||
|
|
8b6ccc0d85 | ||
|
|
e1d3653de2 |
||
|
|
af684756c9 |
||
|
|
855f52f5cc |
||
|
|
82fb692b05 | ||
|
|
dd4c79b1e1 | ||
|
|
fdc918a890 |
||
|
|
f71bdf48f7 |
||
|
|
4f34bce7ee |
||
|
|
04e4c51ab8 | ||
|
|
deaf6303d7 | ||
|
|
b3cec2dd2d |
||
|
|
8ac86f0666 | ||
|
|
0675c98cc8 |
||
|
|
7bee85bb3e |
||
|
|
8549e70668 |
||
|
|
31f2a41038 |
||
|
|
a00db2bf04 |
||
|
|
8b42b20198 | ||
|
|
9cf7fbb8b7 | ||
|
|
813844becc | ||
|
|
22ebf59337 | ||
|
|
690be7c85d |
||
|
|
3db5766d33 |
||
|
|
ddca609ad8 | ||
|
|
d1737883da |
||
|
|
8f3451c5cb |
||
|
|
ec983c982a |
||
|
|
33eeb0d244 | ||
|
|
8aab18f4d9 | ||
|
|
677d2ba934 | ||
|
|
d75499e215 | ||
|
|
688da80e11 | ||
|
|
c3cd1f034a |
||
|
|
c0082cc323 |
||
|
|
1519ca78cc |
||
|
|
b14e9cf45f |
||
|
|
2631876982 |
||
|
|
7813788fcd | ||
|
|
81c3dc5dd9 | ||
|
|
f37bb78aaf | ||
|
|
94c459cee9 | ||
|
|
35f01b85a0 | ||
|
|
9d18257fd1 | ||
|
|
7816d8da43 | ||
|
|
ca17ee8baa | ||
|
|
7752acdf13 | ||
|
|
5486630d21 | ||
|
|
aa056e8053 | ||
|
|
7dc1249b4f | ||
|
|
ddbf066580 |
||
|
|
d2b0513138 |
||
|
|
751e8d994d |
||
|
|
cd90738d8d |
||
|
|
c9e36d14c8 |
||
|
|
759eeec03c |
||
|
|
adfa3dbace |
||
|
|
a8135d72e2 | ||
|
|
1b38aad46f | ||
|
|
00c64eb965 | ||
|
|
887763fcf9 | ||
|
|
6fa70b19ce | ||
|
|
431aa095dc |
||
|
|
6bc8fde866 |
||
|
|
c6aa6675b1 | ||
|
|
f47a027964 | ||
|
|
68222399e3 | ||
|
|
b6dea262e2 | ||
|
|
251156b233 | ||
|
|
e3bf60cf96 | ||
|
|
617b5ba7e7 | ||
|
|
ec56475706 | ||
|
|
2d8754eea9 | ||
|
|
4ac3391dc2 | ||
|
|
882f97b5b5 | ||
|
|
e76b4a1469 | ||
|
|
5b2e29fb74 | ||
|
|
adb988a8aa | ||
|
|
2665d0023b | ||
|
|
051b17a980 | ||
|
|
209c660226 |
||
|
|
a98c49dadd | ||
|
|
e734c3f1f6 | ||
|
|
f38e030a1e | ||
|
|
f6bafb9a82 | ||
|
|
74059d928c | ||
|
|
7cc23e477e | ||
|
|
5532faabfc |
||
|
|
6fdcbc923b | ||
|
|
e423ba59e5 | ||
|
|
88abfedd24 |
||
|
|
ebd3a6dd6d | ||
|
|
0eda8b1a99 | ||
|
|
4581ed7bc3 | ||
|
|
dc157d9853 | ||
|
|
bfbf83e7b9 | ||
|
|
6c800b6319 | ||
|
|
c7e3a46a8e | ||
|
|
d599904ea4 | ||
|
|
8490ef12e3 | ||
|
|
ef7bdb09ac | ||
|
|
af9e8d8616 | ||
|
|
a18c1f15d6 | ||
|
|
32d7c14b29 | ||
|
|
e14f84ee5d | ||
|
|
6335a3e660 | ||
|
|
408b6273d8 | ||
|
|
4d475a6b88 | ||
|
|
e56080623f | ||
|
|
6a6e39f933 | ||
|
|
2b250ff622 |
||
|
|
909dafcaab | ||
|
|
bc2f3fc1db | ||
|
|
a1a38a862f | ||
|
|
0e845c991a | ||
|
|
a97de6db2e | ||
|
|
0cf4a8e3ca | ||
|
|
c058dee74b | ||
|
|
2abd0c89b2 | ||
|
|
b8f1aacfd2 | ||
|
|
894c485d50 | ||
|
|
d23b1b06a0 | ||
|
|
1581943963 | ||
|
|
0a71358169 | ||
|
|
0d682afd4a | ||
|
|
dd7e125d92 | ||
|
|
c2342aedfb | ||
|
|
a5510b3f53 | ||
|
|
20e79994d4 | ||
|
|
d32b7ca004 | ||
|
|
c142ae342d | ||
|
|
0ed063f951 | ||
|
|
68cce922c9 | ||
|
|
107b2f625f | ||
|
|
7656e2b537 | ||
|
|
ae50c3997e | ||
|
|
41a2aa4758 | ||
|
|
d8967385bc | ||
|
|
a298eb62f3 | ||
|
|
c6d8df41ec | ||
|
|
7b45e69fed | ||
|
|
8e7985507e | ||
|
|
db10fc4ceb | ||
|
|
66c6b010e4 | ||
|
|
953bb4a4e1 | ||
|
|
b7739e2d62 | ||
|
|
8f74844b98 | ||
|
|
30ea63fc30 | ||
|
|
cb55db99cb | ||
|
|
f05b9ca9ce | ||
|
|
484bbb91f1 | ||
|
|
e013be1894 | ||
|
|
f5a25d726c | ||
|
|
41746c0346 | ||
|
|
fa114f59ef | ||
|
|
cd1c1753f2 | ||
|
|
e854539b2a | ||
|
|
90dfde084c | ||
|
|
250566d044 | ||
|
|
3773d8db2d |
||
|
|
26b1d49e53 |
||
|
|
e2f30f16e9 | ||
|
|
670944980b | ||
|
|
1c8ceb4693 | ||
|
|
5ad1df51f1 | ||
|
|
acdac25dfa | ||
|
|
c7fed2da23 | ||
|
|
f430c0a0b5 | ||
|
|
969b8bf3b3 | ||
|
|
874c3c490a | ||
|
|
9ee2a0b711 | ||
|
|
8dc9efe96b | ||
|
|
c2e2af5d86 | ||
|
|
29b2778114 | ||
|
|
309626bd19 | ||
|
|
bf6ba654bd | ||
|
|
f82f0abe0b | ||
|
|
4d036ecdc4 | ||
|
|
1db3ff2d08 | ||
|
|
210606aee7 | ||
|
|
6f7f864778 | ||
|
|
976b6af59d | ||
|
|
0579dfb81c | ||
|
|
762b72c8b6 | ||
|
|
345028f2bb | ||
|
|
53551f85b7 | ||
|
|
fa98587e89 | ||
|
|
b791dcc741 | ||
|
|
53aa1ab325 | ||
|
|
8a60aee681 | ||
|
|
7969fd4ee1 | ||
|
|
d27ab1e1f6 | ||
|
|
1cdc947ecb | ||
|
|
5fb238f0b1 | ||
|
|
641c367e89 | ||
|
|
e72fce3c55 | ||
|
|
4524c8e90a | ||
|
|
3a0691697d | ||
|
|
8e84ba7f56 | ||
|
|
35e2b31dce | ||
|
|
e121a1d7b2 | ||
|
|
476c19a473 | ||
|
|
73e5a06053 | ||
|
|
2b0329a856 | ||
|
|
99e1497ae6 | ||
|
|
2d898b3e1c | ||
|
|
599599a5a6 | ||
|
|
d2393f4c44 | ||
|
|
a1717b781c | ||
|
|
cff0582831 | ||
|
|
0bb54c29cf | ||
|
|
8f46f52f28 | ||
|
|
09d8babcbb | ||
|
|
b5a213e5fb | ||
|
|
ee70de2ba2 | ||
|
|
0aefdc4ddb | ||
|
|
959b6edc0d | ||
|
|
066d020ac9 | ||
|
|
3f0830a0da | ||
|
|
b675e78934 | ||
|
|
344af4d1cb | ||
|
|
8b4f116a75 | ||
|
|
10ab1838d5 | ||
|
|
b9f957ecef | ||
|
|
9edced7a6c | ||
|
|
d1fdcbe3d6 | ||
|
|
156ce2e41b | ||
|
|
6233d5221f | ||
|
|
e27aa52b0b | ||
|
|
7915b520b2 | ||
|
|
2cb357a246 | ||
|
|
60447d41e6 | ||
|
|
1ba7b2f7fe | ||
|
|
30df081776 | ||
|
|
52cbec46b2 | ||
|
|
f09667328a | ||
|
|
bc92ceb5e2 | ||
|
|
ed18b1768d | ||
|
|
f06d6da283 | ||
|
|
c134f5c252 | ||
|
|
9488f6e6c5 | ||
|
|
96420e1651 | ||
|
|
5ee33c49c1 | ||
|
|
033ee387e3 | ||
|
|
988c2d7a77 | ||
|
|
2710bef7a1 | ||
|
|
56d47582bb | ||
|
|
8a1f8b1d02 | ||
|
|
bb420272cf | ||
|
|
fea7e5ffc9 | ||
|
|
2a303e28ff | ||
|
|
d5b88c6af0 | ||
|
|
63c6cbec53 | ||
|
|
ff0066d448 | ||
|
|
3febb0c552 | ||
|
|
af7d507488 | ||
|
|
d3d8a5784f | ||
|
|
d3119a4a80 | ||
|
|
b486149d82 | ||
|
|
4b44f9f244 | ||
|
|
33d3c5de1d | ||
|
|
31c3280717 | ||
|
|
0b63766ac3 | ||
|
|
3dca3ce432 | ||
|
|
ee1dc18164 | ||
|
|
9d84fe240b | ||
|
|
45e49cf0cb | ||
|
|
1ec5c0de84 | ||
|
|
387b57e23a | ||
|
|
0fba4ca504 | ||
|
|
b8d26ff50f | ||
|
|
7bbc6050bc | ||
|
|
a84695d95e | ||
|
|
ed28b89658 | ||
|
|
eadc371149 | ||
|
|
4a199c6227 | ||
|
|
5ac4d48d65 | ||
|
|
46b11d555e | ||
|
|
a52134bfca | ||
|
|
072f6f5690 | ||
|
|
96513d8d72 | ||
|
|
05a9e7c4f6 | ||
|
|
71434bdd72 | ||
|
|
668b82e379 | ||
|
|
ab8ed845c8 | ||
|
|
23880be6c8 | ||
|
|
c3d5d044d1 | ||
|
|
e6a0f027b7 | ||
|
|
6c720bf04b | ||
|
|
8c84bf6959 | ||
|
|
86c9130ac9 | ||
|
|
6435218097 | ||
|
|
e5977d2310 | ||
|
|
d94ec93372 | ||
|
|
edc9f81c17 | ||
|
|
9b04b5ac69 | ||
|
|
07941e1b8c | ||
|
|
3a0de192af | ||
|
|
b59d6b1002 | ||
|
|
dc953df2c4 | ||
|
|
78def5b61f | ||
|
|
8acbf54bdd | ||
|
|
15667d35b7 | ||
|
|
7f1c9713f8 | ||
|
|
99026683dd | ||
|
|
79d2ba8595 | ||
|
|
e1a64a2735 | ||
|
|
899ab3ac94 | ||
|
|
60a7ee9f82 | ||
|
|
0ba3151f84 | ||
|
|
15f1a314e6 | ||
|
|
7ae85ce36f | ||
|
|
57bf1e22ec | ||
|
|
752dc1d617 | ||
|
|
a5a30207c2 | ||
|
|
d8a91df623 | ||
|
|
a05596500b | ||
|
|
80c58eb6f4 | ||
|
|
ae97be726d | ||
|
|
c6fcc3bdbf | ||
|
|
26815fad10 | ||
|
|
8cec6cf365 | ||
|
|
0b738f92d3 | ||
|
|
85cb77ea30 | ||
|
|
a905ce327c | ||
|
|
9e820b53cb | ||
|
|
fa69ae106c | ||
|
|
93246fe7f2 | ||
|
|
8229fa4669 | ||
|
|
06d046574f | ||
|
|
110ac0e40d | ||
|
|
4654be8549 | ||
|
|
ecb31f83c3 | ||
|
|
4c831e90c8 | ||
|
|
3863209c39 | ||
|
|
6456dcd7cb | ||
|
|
f6f07cb612 | ||
|
|
986d36ad72 | ||
|
|
51f43e08f6 | ||
|
|
97ef7e4ddd | ||
|
|
863a05cef2 | ||
|
|
20aeb2f3d3 | ||
|
|
b98fb4eb40 | ||
|
|
053d6b57d3 | ||
|
|
92f1cd4ea5 | ||
|
|
28b112c016 | ||
|
|
3ba3ec9b70 | ||
|
|
e23a1aa7b5 | ||
|
|
11329e51cb | ||
|
|
939ba7f77b | ||
|
|
a7fd72dd3f | ||
|
|
7a2d7b2327 | ||
|
|
cb3a7f65f3 | ||
|
|
45073fca83 | ||
|
|
13ee17a68b | ||
|
|
cd238c9c06 | ||
|
|
b3e4bdcb9e | ||
|
|
24e61cfcf7 | ||
|
|
9c037711b3 | ||
|
|
11e2c58325 | ||
|
|
5533a98861 | ||
|
|
830003cd1a | ||
|
|
967bba81a0 | ||
|
|
e41b4d4711 | ||
|
|
ba57a32b31 |
||
|
|
aadc68376d | ||
|
|
4b55c1e3b0 | ||
|
|
0c48db0969 | ||
|
|
e31f860206 | ||
|
|
6acd73b3a3 | ||
|
|
8b9ce04f05 | ||
|
|
9e53e13d68 |
||
|
|
e082842ba8 | ||
|
|
d9d0b8ce5b | ||
|
|
af5ff2a59e | ||
|
|
5052bf40af | ||
|
|
e6e77001e2 | ||
|
|
e0aafdf8ce | ||
|
|
44928b763c | ||
|
|
2405c28b2a | ||
|
|
c710e149d6 | ||
|
|
f1a2d723c9 | ||
|
|
84c1d24254 | ||
|
|
c2b022b38a | ||
|
|
3373883bf3 | ||
|
|
1fc3682e4a | ||
|
|
d6579a5769 | ||
|
|
a0c71abc2e | ||
|
|
3a43b7824c | ||
|
|
920740767c | ||
|
|
d0d114ab0d | ||
|
|
7b3fb2e976 | ||
|
|
3bdc6bab17 | ||
|
|
e235cd7005 | ||
|
|
83ac77f8ba | ||
|
|
5a672aac7b | ||
|
|
b4c2915960 | ||
|
|
e1660e426d | ||
|
|
c128a82cde | ||
|
|
b4a8333a22 | ||
|
|
924d3801d0 | ||
|
|
b0381ccd20 | ||
|
|
571329122c | ||
|
|
0f1fff61c3 | ||
|
|
7d505faf88 | ||
|
|
6927db1bb4 | ||
|
|
4ea0333415 | ||
|
|
a36f0a102d | ||
|
|
19f0518a3e | ||
|
|
54eb8ceb3d | ||
|
|
602bf9f07f | ||
|
|
2fff9135a5 | ||
|
|
2c6ed2f734 | ||
|
|
111894787a | ||
|
|
f7111dd412 | ||
|
|
e3f6ed6bd4 | ||
|
|
c7a34d973f | ||
|
|
f53e1e9575 | ||
|
|
5a75c45e3f | ||
|
|
abbb742c36 | ||
|
|
8fd847c1ad | ||
|
|
1063c3cec3 | ||
|
|
42485455f5 | ||
|
|
c182b0a9c0 | ||
|
|
c023554c5f | ||
|
|
87dc4de4aa | ||
|
|
707ce113e6 | ||
|
|
ea55dc9ad3 | ||
|
|
80581d92d4 | ||
|
|
aca1828886 | ||
|
|
e74db7f165 | ||
|
|
868f96bee6 | ||
|
|
f33e4bdd44 | ||
|
|
f4c626a864 | ||
|
|
81daa73f17 | ||
|
|
e09c64ebd1 | ||
|
|
e248f240b2 | ||
|
|
80c656fed8 | ||
|
|
20db041c33 | ||
|
|
cd8a7bac62 | ||
|
|
48c88705ec |
||
|
|
fe4dc7ce60 | ||
|
|
eec6e89846 | ||
|
|
e35751cf3f | ||
|
|
4b5ad14ce7 | ||
|
|
5272d7c996 | ||
|
|
b37635e4f7 | ||
|
|
abb1b4ef85 | ||
|
|
d1768c677a | ||
|
|
e0e910ccc8 | ||
|
|
a5f398085f | ||
|
|
f0fac7e1e5 | ||
|
|
89592532e5 | ||
|
|
8350b6bf86 | ||
|
|
74d181563a | ||
|
|
17ab6d60f3 | ||
|
|
51e8fc9cb2 | ||
|
|
91f068106c | ||
|
|
ca08a99c5e | ||
|
|
77c33adcc3 | ||
|
|
ebcee59cf9 | ||
|
|
c7c7966e93 | ||
|
|
f1a2271ff2 | ||
|
|
d1d727f6df | ||
|
|
9d43f6ec64 | ||
|
|
71fe89d3c1 | ||
|
|
7145dc7298 | ||
|
|
9f89b86821 | ||
|
|
951467be88 | ||
|
|
2b3640715a | ||
|
|
e12b6cca4d | ||
|
|
bc34aacf6a | ||
|
|
edcd8d7854 | ||
|
|
3271655c1c | ||
|
|
1d6672056b | ||
|
|
e72677c880 | ||
|
|
066a7aeeb7 | ||
|
|
774a07ce44 | ||
|
|
ba3b02b77d |
||
|
|
2898fb1367 |
||
|
|
cddca0b266 | ||
|
|
8e4fec1ad4 | ||
|
|
618e46a35a | ||
|
|
ac629a08b1 | ||
|
|
4b1a422951 | ||
|
|
c94b9bcc54 | ||
|
|
88bd0b53d4 | ||
|
|
9968ac4a78 | ||
|
|
6194d0756c | ||
|
|
d2e5b10f79 | ||
|
|
7a5069495a | ||
|
|
20fb64d074 | ||
|
|
6c76e6a2c7 | ||
|
|
20b8635520 | ||
|
|
85bfa562bc | ||
|
|
b3d6078837 | ||
|
|
4355f29c4b |
||
|
|
e97eb8f163 | ||
|
|
41f36f3481 | ||
|
|
c68b62af96 | ||
|
|
d0c0635dc8 | ||
|
|
f2195130f9 | ||
|
|
2d98142985 | ||
|
|
63cc30997f | ||
|
|
b965734cb7 | ||
|
|
6badfb0520 | ||
|
|
3b265e41cb | ||
|
|
6fd524eadc | ||
|
|
c1f4d84845 | ||
|
|
f84d8d1c99 | ||
|
|
e1aa4bffb0 | ||
|
|
a517bb17fa | ||
|
|
d3621ff749 | ||
|
|
7c06a84052 | ||
|
|
b0ed30c556 | ||
|
|
109e70e356 | ||
|
|
e5ed812242 | ||
|
|
888a7131a2 | ||
|
|
a0719510c0 | ||
|
|
70ac15c940 | ||
|
|
c76219087d | ||
|
|
3e429c72e1 | ||
|
|
5607fe9bed | ||
|
|
df2db9eed8 | ||
|
|
e00b893343 | ||
|
|
6a7b655815 | ||
|
|
c2ab823150 | ||
|
|
a13120f400 | ||
|
|
bfcb6dc572 | ||
|
|
afac947a41 | ||
|
|
df40c25162 | ||
|
|
acdcfc7719 | ||
|
|
388b3c4e09 | ||
|
|
6b6d561584 | ||
|
|
b35474cea2 | ||
|
|
e367d2d3f3 | ||
|
|
83dba3b49e | ||
|
|
96982a5e85 | ||
|
|
c1cf374da3 | ||
|
|
6990c445aa | ||
|
|
faecc487d0 | ||
|
|
d839583777 | ||
|
|
c5ccd63a85 | ||
|
|
1bb561587e | ||
|
|
a3d56709e7 | ||
|
|
ebfdfb9d24 | ||
|
|
377bcca1d8 | ||
|
|
becf624d8a | ||
|
|
8a022e7b8c | ||
|
|
b966c2ab02 |
||
|
|
65883107fd | ||
|
|
ce22d7af3a |
||
|
|
9b08dbdfe5 | ||
|
|
6e285af7bb | ||
|
|
4c0394bbd9 | ||
|
|
5bc0b10b8a |
||
|
|
fd606cc3f9 |
||
|
|
58c1768e4c |
||
|
|
bcb3bfa1d3 |
||
|
|
054e2df626 | ||
|
|
2baab504d4 | ||
|
|
a526edcb44 | ||
|
|
b8bbc3218f | ||
|
|
597e506099 | ||
|
|
933e3aec88 | ||
|
|
0c5f4f59e4 | ||
|
|
3be9aaabeb | ||
|
|
ec21136184 | ||
|
|
ba19892a93 |
||
|
|
7e2e0eadae |
||
|
|
b40b9af499 | ||
|
|
61913a3e5f | ||
|
|
b5de1c3060 |
||
|
|
c32329fc0d | ||
|
|
7b8952e2e9 | ||
|
|
1cfba392c1 | ||
|
|
169e561e8e | ||
|
|
d3defbd31f | ||
|
|
286db39bd5 | ||
|
|
735a489219 | ||
|
|
5b42bfacb7 | ||
|
|
d50428a47b | ||
|
|
10130f066a | ||
|
|
02a1f03eb6 | ||
|
|
0d99fc0e3a |
||
|
|
fa88145586 |
||
|
|
65121c1ec3 |
||
|
|
7880546817 | ||
|
|
5f7ea80f4c | ||
|
|
dd8df0d6a4 | ||
|
|
da323b87fd | ||
|
|
6bec51d980 | ||
|
|
8a41821304 | ||
|
|
7ae84db0cf | ||
|
|
b5053163fd | ||
|
|
8850a48058 | ||
|
|
f4bbe57578 | ||
|
|
6c6185ab9c | ||
|
|
66954ef694 | ||
|
|
c22475f093 | ||
|
|
925395b79e | ||
|
|
f77cbbb7aa | ||
|
|
769c57fa36 | ||
|
|
eec748a4b4 | ||
|
|
b9394eb6b9 | ||
|
|
fb000a6331 | ||
|
|
3977cec662 | ||
|
|
066a5ffdb7 | ||
|
|
b2a2866257 | ||
|
|
5e08f8a7d6 | ||
|
|
ad4f30fc5d | ||
|
|
78c5127cc0 | ||
|
|
d33ab6a405 | ||
|
|
e759c5ba30 | ||
|
|
161a21c591 | ||
|
|
3fc63930b9 | ||
|
|
488d340fc3 | ||
|
|
5dbe2daf12 | ||
|
|
b6a49c6b05 | ||
|
|
d44f13074b | ||
|
|
79bbdbd7d6 | ||
|
|
f0497e7a83 | ||
|
|
5c1ae0de4c | ||
|
|
dda8253830 | ||
|
|
466b1474b8 | ||
|
|
0ffbe61ece | ||
|
|
c538926b8e | ||
|
|
619dec61c4 |
||
|
|
67be5bcfb3 |
||
|
|
14c0413a9b | ||
|
|
b3dc1e2c42 | ||
|
|
659e3ed110 | ||
|
|
3c7868ffb0 | ||
|
|
39cd6bb06d |
||
|
|
bacd3e8e14 | ||
|
|
cbc9861b00 | ||
|
|
f910755f8d | ||
|
|
495150f49f | ||
|
|
08eef2797b | ||
|
|
97983ad26f |
||
|
|
0fc9e07f2f | ||
|
|
f46adf30b0 | ||
|
|
6bc466e1c6 | ||
|
|
fcaadf061d | ||
|
|
eefd1057e7 | ||
|
|
c07ca804cd | ||
|
|
e761b94852 | ||
|
|
4384468057 | ||
|
|
48d80e5ea0 | ||
|
|
3e6d4c951e |
||
|
|
e68263e2a2 |
||
|
|
d04b8339f4 | ||
|
|
8ed9e1fb93 | ||
|
|
2a8d0ded8e | ||
|
|
8bd13d28b0 | ||
|
|
fe78b82ab5 | ||
|
|
25efce79ec | ||
|
|
142f76972b | ||
|
|
4e698ea384 | ||
|
|
7382169d98 | ||
|
|
acd565dce0 | ||
|
|
5edd4e9ae0 |
||
|
|
fe01aa947e |
||
|
|
8205c30521 | ||
|
|
fe1a0a4dd2 | ||
|
|
725a6343b5 | ||
|
|
c731a43fe7 | ||
|
|
b8494ae112 | ||
|
|
37cb236ac7 | ||
|
|
8509ff90aa | ||
|
|
76215572dc | ||
|
|
0f50405d49 | ||
|
|
86a13523dd | ||
|
|
938b86b11d | ||
|
|
28b7be3ff4 | ||
|
|
e7c8c3e3e3 | ||
|
|
8732511703 | ||
|
|
ceb68e3091 | ||
|
|
61075f95f2 | ||
|
|
0ba6fa7f50 | ||
|
|
74d4f0ffae | ||
|
|
856dbdc5d8 | ||
|
|
5f58721cc5 |
||
|
|
fc4b7a22b4 |
||
|
|
899128907e |
||
|
|
bc77fb44e2 | ||
|
|
d5bc7b9806 | ||
|
|
4aca6b0653 | ||
|
|
6b5773eea5 | ||
|
|
04f25515ac | ||
|
|
02c73d01fb | ||
|
|
04587b4722 | ||
|
|
60c4593593 | ||
|
|
3d6e4c4a4b | ||
|
|
f75f7adc9a | ||
|
|
c6b76a4566 | ||
|
|
fcb8fa8faf | ||
|
|
255c0b4d88 | ||
|
|
1100833c15 | ||
|
|
0aec50a276 | ||
|
|
1756d53fd4 | ||
|
|
7d861363ce | ||
|
|
0a47183528 | ||
|
|
1f909308cc | ||
|
|
52b246cde7 | ||
|
|
25e0d31aea | ||
|
|
71f5a3be7d | ||
|
|
45068fbb25 | ||
|
|
d3b6a2bdab | ||
|
|
0d4a5a1906 | ||
|
|
7881fbb17c | ||
|
|
9674bda214 | ||
|
|
7435852986 |
||
|
|
fea82ee336 | ||
|
|
e63956b616 | ||
|
|
f185ccfc36 | ||
|
|
84dc2afaf0 | ||
|
|
5da6d7397d | ||
|
|
4e969bbdd7 | ||
|
|
f2e1bbcd7b | ||
|
|
1a195ba297 | ||
|
|
c504ba62c9 | ||
|
|
b1cb63962e | ||
|
|
3c1d9c29d4 | ||
|
|
6250208d24 | ||
|
|
5c7a718924 | ||
|
|
de86ff4028 | ||
|
|
12d7b8f2ab | ||
|
|
7e9de7ce42 | ||
|
|
2176889023 | ||
|
|
ad5ac76b10 | ||
|
|
0bd7cc3d59 | ||
|
|
dbf0ac96fc |
||
|
|
f8cd9e6683 |
||
|
|
cfde454ad2 |
||
|
|
4f221544f4 |
||
|
|
8cd911a8e9 |
||
|
|
8a33f8c37e |
||
|
|
c659a7da56 |
||
|
|
ef8502d3a3 |
||
|
|
d85f0b1778 |
||
|
|
a71c71a7de |
||
|
|
ddcae99b69 | ||
|
|
8741f03d20 | ||
|
|
5cb44bd4c9 | ||
|
|
1fed558272 | ||
|
|
40ca856ee5 | ||
|
|
b86c02f299 | ||
|
|
3e9a51f90a | ||
|
|
4f5bf512a0 | ||
|
|
4a1fefc97d | ||
|
|
16a742a9ff | ||
|
|
c2830a9f51 | ||
|
|
4fc2402acf | ||
|
|
82f596df8d | ||
|
|
033e76f9b5 | ||
|
|
022ef91a60 | ||
|
|
d6ba528d41 | ||
|
|
c395817415 | ||
|
|
c36ea7100c | ||
|
|
a4ff3b8617 | ||
|
|
7fb9ec7525 | ||
|
|
afe5693e64 | ||
|
|
a08a1902f9 | ||
|
|
63ad498818 | ||
|
|
98cd17ca14 | ||
|
|
573c3aa442 | ||
|
|
cfb1cf543b | ||
|
|
21177b3c29 | ||
|
|
f7e5f1f7b2 | ||
|
|
d0fd07d5a5 | ||
|
|
2d0c71692c | ||
|
|
6ce9aca06b |
||
|
|
601ef508ef | ||
|
|
e36be09618 | ||
|
|
9daf918978 | ||
|
|
3560f1e1e2 | ||
|
|
03b288f8b3 | ||
|
|
f87d8729d0 | ||
|
|
c80ee686f8 | ||
|
|
003851e6f7 | ||
|
|
2968eb5e03 | ||
|
|
021685d530 | ||
|
|
192714f3ff | ||
|
|
b4e2dda008 | ||
|
|
36c641d76c | ||
|
|
f9030476c6 | ||
|
|
fc87b130cd | ||
|
|
289f4f087b | ||
|
|
89a8c5c03d | ||
|
|
0f4d82b26f | ||
|
|
075de99e55 | ||
|
|
e64258f058 | ||
|
|
04dd594d06 | ||
|
|
af175ed071 | ||
|
|
63d392c05c | ||
|
|
a19df0ed65 | ||
|
|
053a1c0258 | ||
|
|
2c58e512d5 | ||
|
|
f54913f7d4 | ||
|
|
999c86495d | ||
|
|
26c2871afb | ||
|
|
c7c4fc5b19 | ||
|
|
a4f932e81d | ||
|
|
175046579d | ||
|
|
1755305260 | ||
|
|
21eae8b7bb | ||
|
|
db0b68ea3e | ||
|
|
56453ee1d2 | ||
|
|
b796660128 | ||
|
|
0def9d5440 | ||
|
|
cbaf3479e6 | ||
|
|
1beb90e9a8 | ||
|
|
dd90bc1c88 | ||
|
|
2ee3aca1b7 | ||
|
|
b614b39a7e | ||
|
|
a4752502e9 | ||
|
|
9790987a0f | ||
|
|
e41d1b8cc3 | ||
|
|
eb4a6e2b45 | ||
|
|
94a57d130f | ||
|
|
be9d8ab0dd | ||
|
|
a11fc3c687 | ||
|
|
43be2d9ab7 | ||
|
|
30a30895d5 | ||
|
|
ab6dacb0fe | ||
|
|
23e5688887 | ||
|
|
5cf404df5c | ||
|
|
911a7b4431 | ||
|
|
5ac762da01 | ||
|
|
ae2ed8542c | ||
|
|
3bf1d1507e | ||
|
|
29edb5df54 | ||
|
|
54fd4d9994 | ||
|
|
57594427ac | ||
|
|
8b51a81998 | ||
|
|
55dd831792 | ||
|
|
cdf89355ee | ||
|
|
722d4fddda | ||
|
|
9c38fbc0d3 | ||
|
|
af19a04e49 | ||
|
|
86d21d4014 | ||
|
|
33bf32e282 | ||
|
|
b567063b27 | ||
|
|
c117ff382e | ||
|
|
897052b8aa | ||
|
|
8971ca1275 | ||
|
|
653d2911e4 | ||
|
|
679efc2b52 | ||
|
|
d0991d3962 | ||
|
|
99f27908fc | ||
|
|
57de5745c2 | ||
|
|
38e487bfac | ||
|
|
070027bcb5 | ||
|
|
99e651757c | ||
|
|
ecbfee01b4 | ||
|
|
ae3fb25ae2 | ||
|
|
63f4945f31 | ||
|
|
8f1ff45ae1 | ||
|
|
eb84525216 | ||
|
|
c3499d7fa0 | ||
|
|
96c351e9c0 | ||
|
|
6437849c8b | ||
|
|
efb687b6e6 | ||
|
|
ab5a010e68 | ||
|
|
fcca8c8649 | ||
|
|
f8c764c780 | ||
|
|
b55bb29ef7 | ||
|
|
66c47e4e76 | ||
|
|
ced4f72ed7 | ||
|
|
71f93d1611 | ||
|
|
d9a4f86bb5 | ||
|
|
b690c060df | ||
|
|
c6e4d1f1a1 | ||
|
|
6cabde85e6 | ||
|
|
254c0ccccd | ||
|
|
1992a092f6 | ||
|
|
9ca52276e5 | ||
|
|
984dd7e8af | ||
|
|
7b679fa4a0 | ||
|
|
1329efa45f | ||
|
|
00edc7a4df | ||
|
|
2a6c6985ea | ||
|
|
9e467e2d8e | ||
|
|
c432bbbf10 | ||
|
|
f78275d3e4 | ||
|
|
b08c115d33 | ||
|
|
0963a22286 | ||
|
|
5cb480db89 | ||
|
|
61a5c4d49c | ||
|
|
4a2a053e09 | ||
|
|
f1560978ca | ||
|
|
44cf86a897 | ||
|
|
27ca0e767c | ||
|
|
e14590b347 | ||
|
|
5c162d1bd1 | ||
|
|
02b540f667 | ||
|
|
912995bdbf | ||
|
|
1892e7456e | ||
|
|
3d7c83ed03 | ||
|
|
6102a9ee4f | ||
|
|
85df8d5c6c | ||
|
|
fdf9d66e81 | ||
|
|
cd172245dd | ||
|
|
8785cf9f63 | ||
|
|
6d79a6c7da | ||
|
|
0dbf11b821 | ||
|
|
2571e20894 | ||
|
|
67cffbe879 | ||
|
|
4f18372633 | ||
|
|
4cf8cb8e41 | ||
|
|
96fdd75707 | ||
|
|
75d1a50507 | ||
|
|
86422fb774 | ||
|
|
a08fb728de | ||
|
|
6a9b75c601 | ||
|
|
9568059be3 | ||
|
|
462ef81efe | ||
|
|
fdab48a1b7 | ||
|
|
0e77401bbc | ||
|
|
9919360ba5 | ||
|
|
f7ba57942c | ||
|
|
e576d4e0d6 | ||
|
|
252f4e328a |
||
|
|
963cad9d81 | ||
|
|
8970147b06 |
||
|
|
20f1333f55 | ||
|
|
97a224befb | ||
|
|
7fc2283327 |
||
|
|
a5b8f1bb6a |
||
|
|
13842ff545 |
||
|
|
7513acdf3f |
||
|
|
8516eea76f | ||
|
|
7920779934 | ||
|
|
d5050e15fb | ||
|
|
8d60077782 | ||
|
|
c578200a6e | ||
|
|
332e44fcd4 | ||
|
|
8c35957234 | ||
|
|
813bcd272d | ||
|
|
3981d8777c | ||
|
|
4254632fda | ||
|
|
c86b6f5f52 | ||
|
|
60ef77846b | ||
|
|
8a933e01c4 | ||
|
|
509ce1075a | ||
|
|
82ad912ce0 | ||
|
|
7547702955 | ||
|
|
85343cb5ab | ||
|
|
f568d99a91 | ||
|
|
f880ff15f9 | ||
|
|
9e2f85ace5 | ||
|
|
0c44b63541 | ||
|
|
7d3a659bb3 | ||
|
|
18805f42ae | ||
|
|
780aa4f9d8 | ||
|
|
0adbed4549 | ||
|
|
e0e921669b | ||
|
|
dcceca19af | ||
|
|
0619e8907d | ||
|
|
34f6ba8be8 | ||
|
|
0139383e82 | ||
|
|
6f7427987e | ||
|
|
99354d0975 | ||
|
|
ca37266543 | ||
|
|
70b161d158 | ||
|
|
c84d56e9b2 | ||
|
|
d69b2bda8d | ||
|
|
662c21f351 | ||
|
|
6f8d032d84 | ||
|
|
f9061931e3 | ||
|
|
fa1e96f90d | ||
|
|
deb5b5ea67 | ||
|
|
671f6af404 | ||
|
|
ac97bf978d | ||
|
|
e21b177b99 | ||
|
|
50b7cb732f | ||
|
|
a466c0b442 | ||
|
|
cd87a1076a | ||
|
|
b6e84e6863 | ||
|
|
60539ab38c | ||
|
|
4182040748 | ||
|
|
959749009e | ||
|
|
b1c444f82c | ||
|
|
f58b107664 | ||
|
|
c05f1c7453 | ||
|
|
eff462825f | ||
|
|
ea27251944 | ||
|
|
e48627c0a8 | ||
|
|
9228b22b85 | ||
|
|
3427107a4f | ||
|
|
2e08834988 | ||
|
|
7ca05c7711 | ||
|
|
54b58570f8 | ||
|
|
689b134385 | ||
|
|
edf8c69ab4 | ||
|
|
ca7de3ba50 | ||
|
|
a350aeaa91 | ||
|
|
3beaed1e5d | ||
|
|
1d6ba10df3 | ||
|
|
3520f0c38a | ||
|
|
727f6b9d14 | ||
|
|
5949090856 | ||
|
|
a9347b1695 | ||
|
|
343ad815b9 | ||
|
|
48e8a54fe7 | ||
|
|
4603f22319 | ||
|
|
86a4a50387 | ||
|
|
8d58b1d658 | ||
|
|
5091282353 | ||
|
|
8aa2f96dbc | ||
|
|
515abb0057 | ||
|
|
e07f306260 | ||
|
|
70f2fb7025 | ||
|
|
24f48c57c8 | ||
|
|
0aa2340e59 | ||
|
|
840e7669d1 | ||
|
|
4cbf821c34 | ||
|
|
42a3db6fe5 | ||
|
|
ad8891a4be | ||
|
|
ed7629b74e | ||
|
|
3ccf3628f2 | ||
|
|
791e11f036 | ||
|
|
8576b399cf | ||
|
|
97317539c4 | ||
|
|
98e30c03cf | ||
|
|
d46e92cc55 | ||
|
|
f4746924ad | ||
|
|
54953c6e7a | ||
|
|
3f31a6dcd1 | ||
|
|
f25f843c6f | ||
|
|
fea9767ec0 | ||
|
|
e7da71f266 | ||
|
|
e6bdf1c025 | ||
|
|
3b5c73b028 |
||
|
|
fe649a34d3 |
||
|
|
d6fb5b8014 |
||
|
|
0cf3638556 |
||
|
|
3885596b04 |
||
|
|
836d042c45 |
||
|
|
792011702e |
||
|
|
432dd2207a |
||
|
|
8d34458302 |
||
|
|
cb96fb5c7f | ||
|
|
40f8166e53 | ||
|
|
eee6fd8922 | ||
|
|
5d2767cc7a | ||
|
|
4fbd5363ac | ||
|
|
08f2651373 | ||
|
|
5ee3e3d978 | ||
|
|
34b364f124 | ||
|
|
fbb3f7eee0 | ||
|
|
fc85dd4b3a | ||
|
|
9fe9750984 | ||
|
|
3c649b4f54 | ||
|
|
1da98f6995 | ||
|
|
3b7b2f0dd3 | ||
|
|
8588740225 | ||
|
|
183a3e58c7 | ||
|
|
b48804cfb5 | ||
|
|
b8646879a6 | ||
|
|
9793f2366b | ||
|
|
3f924f5278 | ||
|
|
928c7cd679 | ||
|
|
a6027e094c | ||
|
|
f18c87989a | ||
|
|
a34a1194d7 | ||
|
|
01c22006ea | ||
|
|
9ac5c7962a | ||
|
|
e825ea4b6c | ||
|
|
6c1fa12bd8 | ||
|
|
a55ed669aa | ||
|
|
998f677adf | ||
|
|
2840727fa7 | ||
|
|
1ae7eef34e | ||
|
|
956e862611 | ||
|
|
460177ddc1 | ||
|
|
fe8a60bc62 | ||
|
|
2f60a93960 | ||
|
|
9f93aea7cd |
||
|
|
d48585604d | ||
|
|
b44537fdbb | ||
|
|
f5ffecdd06 | ||
|
|
397b6bffa8 | ||
|
|
553aeb21b7 | ||
|
|
251737968a | ||
|
|
ea18528d4d |
||
|
|
784ac0d2ea |
||
|
|
14e9e41ef2 |
||
|
|
27d4c1e616 | ||
|
|
7a904ea179 | ||
|
|
66671e13af | ||
|
|
5b3130e624 | ||
|
|
0abdc02f22 | ||
|
|
750221451c | ||
|
|
abbfdfd7bb | ||
|
|
1cd8861e26 | ||
|
|
a1868e19d3 | ||
|
|
227f3aa152 | ||
|
|
28a412f8d9 | ||
|
|
199d20e864 | ||
|
|
b07dd40d7c | ||
|
|
64fdec5b64 | ||
|
|
ab846774bf | ||
|
|
8ecf0d9265 | ||
|
|
8b972ab9d2 | ||
|
|
a1ad9ca706 | ||
|
|
4d45885472 | ||
|
|
b9c209246c | ||
|
|
2092a4dd42 | ||
|
|
8a71619733 | ||
|
|
b0cee19a9b | ||
|
|
77b7b6aad9 | ||
|
|
963a98782f | ||
|
|
c50eb85ce0 | ||
|
|
99e6cfb64c | ||
|
|
548abe1b02 | ||
|
|
76c878d55c | ||
|
|
f5b01c1289 | ||
|
|
33d131b32d | ||
|
|
2bb7b6822c | ||
|
|
612e0a43df | ||
|
|
ba0e5e7957 | ||
|
|
0af7541b27 | ||
|
|
b462cf3301 | ||
|
|
d6d534ca09 | ||
|
|
77cbfe7d9b | ||
|
|
ef1178f1dd | ||
|
|
fc6739237a | ||
|
|
5a687fee93 | ||
|
|
d71dc0277b | ||
|
|
6c7e83f0ac | ||
|
|
137dbee5c9 | ||
|
|
f489c6d63e | ||
|
|
b2559b0493 | ||
|
|
9ec000e38e | ||
|
|
9236ffd6c9 | ||
|
|
983ea8e5b6 | ||
|
|
66f002f167 | ||
|
|
1b4d0f71d1 | ||
|
|
5b2285c604 | ||
|
|
4e49401fdb | ||
|
|
47f6a3162a | ||
|
|
4301bd0837 | ||
|
|
2ac5eeadfb | ||
|
|
f10203851d | ||
|
|
4a6642a87c | ||
|
|
de8c4496c4 | ||
|
|
41feb04a06 | ||
|
|
7b04e4bfb6 | ||
|
|
5a0d9a3287 | ||
|
|
931da9104a | ||
|
|
e731adbc40 | ||
|
|
8b22de8e92 | ||
|
|
9b0ceb7c2d | ||
|
|
ac5e04ff2d | ||
|
|
a2d1953f08 | ||
|
|
eb4c9ea6f3 | ||
|
|
99ddb2e82f | ||
|
|
356b793c09 | ||
|
|
b1c1fd4dfa | ||
|
|
43763010ab | ||
|
|
bb0ddb0bfd | ||
|
|
2f1686c132 | ||
|
|
39f465310a | ||
|
|
4d83b8fbc5 |
||
|
|
fe33ecf888 |
||
|
|
93b8c44705 |
||
|
|
d01acd5b95 |
||
|
|
a83d91c1ac | ||
|
|
bfd402fa19 | ||
|
|
4e0e82e5bc | ||
|
|
b3bdb834d7 | ||
|
|
04415e9b3c | ||
|
|
7a7b36e05b | ||
|
|
206b53a71b | ||
|
|
42bf320045 | ||
|
|
fc2b255117 | ||
|
|
0165c5793e | ||
|
|
87e69934a5 | ||
|
|
e0fa1b6442 | ||
|
|
1dffa38ab9 | ||
|
|
6b7dab0334 | ||
|
|
0bf95e7254 | ||
|
|
8e99dda1a6 | ||
|
|
b3101b2d3e | ||
|
|
1833dd1be0 | ||
|
|
d9f2b7df28 | ||
|
|
91cdb5a33c | ||
|
|
fa80c554d3 | ||
|
|
472d5dd565 | ||
|
|
7011142c85 | ||
|
|
14bcf4383c | ||
|
|
c160835b3a | ||
|
|
a5c29db888 |
469 changed files with 15302 additions and 116204 deletions
28
.gitignore
vendored
28
.gitignore
vendored
|
|
@ -52,31 +52,3 @@ ggml-*.bin
|
|||
/kokoro_voices/
|
||||
__pycache__/
|
||||
tesla_credentials.json
|
||||
|
||||
# Studio per-checkout runtime state: terminal scrollback, AI chat logs, window
|
||||
# layout. Committed once by mistake (fab00bf27 "Studio term histories") — it is
|
||||
# local scratch, it churns every session, and it had grown to ~30 MB of chat
|
||||
# JSON and terminal buffers carrying developer email addresses and home paths.
|
||||
.makepad/
|
||||
|
||||
# Working notes, plans and handoff docs. These are scratch for whoever is
|
||||
# mid-task — they go stale the moment the work lands, and a repo root full of
|
||||
# them buries the three docs that are actually reference material. Kept on
|
||||
# disk, out of the repo. The reference docs (README, splash.md, splashgame.md)
|
||||
# and the Studio runbook (AGENTS.md) stay tracked.
|
||||
/aigame*.md
|
||||
/blur.md
|
||||
/bridge.md
|
||||
/datasources.md
|
||||
/game.md
|
||||
/glass.md
|
||||
/gps.md
|
||||
/handoff.md
|
||||
/isolate.md
|
||||
/layers.md
|
||||
/map.md
|
||||
/map2.md
|
||||
/profiler.md
|
||||
/route.md
|
||||
/shiny.md
|
||||
/status.md
|
||||
|
|
|
|||
68
Cargo.toml
68
Cargo.toml
|
|
@ -1,20 +1,7 @@
|
|||
workspace.members = [
|
||||
# === app ===
|
||||
"widgets/dll",
|
||||
# === apps ===
|
||||
"apps/route",
|
||||
# === arcade (game.md) ===
|
||||
"apps/arcade",
|
||||
"libs/game/math",
|
||||
"libs/game/render",
|
||||
"libs/game/sim",
|
||||
"libs/game/blocks",
|
||||
"libs/game/gen",
|
||||
"libs/game/audio",
|
||||
"libs/game/script",
|
||||
"libs/game/net",
|
||||
"libs/game/coedit",
|
||||
"libs/game/pkg",
|
||||
"libs/game/session",
|
||||
"libs/game/assets",
|
||||
# === examples ===
|
||||
"examples/hotload_ui",
|
||||
"examples/teamtalk",
|
||||
|
|
@ -53,19 +40,46 @@ workspace.members = [
|
|||
"examples/cad",
|
||||
"examples/box3d",
|
||||
"examples/hello_world",
|
||||
# === xr app ===
|
||||
"xr",
|
||||
# === studio ===
|
||||
"studio/hub",
|
||||
"studio/desktop",
|
||||
# === necessary tools ===
|
||||
"tools/cargo_makepad",
|
||||
"tools/map_tiles",
|
||||
"tools/map_bake",
|
||||
"tools/remote",
|
||||
"tools/arcade_eval",
|
||||
# === tests ===
|
||||
"libs/box3d",
|
||||
"libs/gif",
|
||||
"libs/gltf",
|
||||
"libs/splat",
|
||||
"libs/latex_math",
|
||||
"libs/mbtile_reader",
|
||||
"libs/map_nav",
|
||||
"libs/geodata",
|
||||
"libs/makepad_ai",
|
||||
"libs/tesla",
|
||||
"libs/converse",
|
||||
"libs/pdf_parse",
|
||||
"libs/regex",
|
||||
"libs/svg",
|
||||
"libs/apple_sys",
|
||||
"libs/mlx",
|
||||
"libs/mlx/cli",
|
||||
"libs/shared_bytes",
|
||||
"libs/tsdf",
|
||||
"platform/network",
|
||||
"libs/filesystem_watcher",
|
||||
"libs/live_reload_core",
|
||||
"platform/script/test",
|
||||
"platform/script/std",
|
||||
"platform/studio",
|
||||
"studio/hub",
|
||||
"libs/makepad_test",
|
||||
"libs/makepad_test/macros",
|
||||
"studio/desktop",
|
||||
"tools/cargo_makepad",
|
||||
"tools/tui_test",
|
||||
"tools/profiler",
|
||||
"tools/map_tiles",
|
||||
"libs/mb3d",
|
||||
"libs/openexr",
|
||||
"libs/git",
|
||||
"libs/fast_inflate",
|
||||
"libs/lz4",
|
||||
"xr",
|
||||
"libs/cef",
|
||||
]
|
||||
|
||||
workspace.exclude = [
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@
|
|||
|
||||
- Discord: https://discord.gg/adqBRq7Ece
|
||||
- Rik Arends: https://twitter.com/rikarends
|
||||
- Eddy Bruel: -
|
||||
- Sebastian Michailidis: https://bsky.app/profile/okpokpokp.bsky.social
|
||||
|
||||
Makepad is an AI-accelerated application and game development environment for Rust. It combines a high-performance UI runtime, a live-editable design language, and a fast iteration loop so you can build native and web apps with a tight feedback cycle.
|
||||
|
||||
It also has a large set of AI backends integrated for embedding llms or generative AI models inside applications or run them easily on local hardware
|
||||
Makepad is an AI-accelerated application development environment for Rust. It combines a high-performance UI runtime, a live-editable design language, and a fast iteration loop so you can build native and web apps with a tight feedback cycle.
|
||||
|
||||
This repository contains the core engine, widgets, tools, and examples.
|
||||
|
||||
|
|
@ -17,7 +17,6 @@ This repository contains the core engine, widgets, tools, and examples.
|
|||
- A Rust-first framework with a scriptable UI DSL.
|
||||
- A studio app for running, inspecting, and iterating on examples and projects.
|
||||
- An AI-accelerated workflow: structure and tooling aimed at making code generation, refactoring, and iteration faster and safer.
|
||||
- Simple forward 3D renderer for making games on Quest and all other supported platforms
|
||||
|
||||
## Features
|
||||
|
||||
|
|
@ -53,7 +52,7 @@ Linux build/runtime dependencies are listed in `./tools/linux_deps.sh`:
|
|||
Use the apt-get command below, or run the script on Ubuntu/WSL2:
|
||||
|
||||
```bash
|
||||
sudo apt-get update && sudo apt-get install -y --no-install-recommends build-essential pkg-config clang ca-certificates libssl-dev libx11-dev libxcursor-dev libxkbcommon-dev libxrandr-dev libxi-dev libxinerama-dev libasound2-dev libpulse-dev libwayland-dev wayland-protocols libegl1-mesa-dev libgl1-mesa-dev libgles2-mesa-dev libglx-dev libdrm-dev libgbm-dev libgl1-mesa-dri mesa-vulkan-drivers mesa-utils mesa-utils-extra x11-apps gstreamer1.0-tools gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly gstreamer1.0-libav gstreamer1.0-gl gstreamer1.0-alsa gstreamer1.0-pipewire libgstreamer1.0-0 libgstreamer-plugins-base1.0-0 libgstreamer-gl1.0-0
|
||||
sudo apt-get update && sudo apt-get install -y --no-install-recommends build-essential pkg-config clang ca-certificates libssl-dev libx11-dev libxcursor-dev libxkbcommon-dev libxrandr-dev libxi-dev libxinerama-dev libasound2-dev libpulse-dev libwayland-dev wayland-protocols libegl1-mesa-dev libgl1-mesa-dev libgles2-mesa-dev libglx-dev libdrm-dev libgbm-dev libgl1-mesa-dri mesa-vulkan-drivers mesa-utils mesa-utils-extra x11-apps
|
||||
```
|
||||
|
||||
## Build And Run Makepad Studio
|
||||
|
|
|
|||
308
aigame.md
Normal file
308
aigame.md
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
# aigame — moving the AI Game Maker from Godot to Makepad
|
||||
|
||||
Plan for replacing the Godot backend of the kids' Game Maker (`examples/godot`) with a
|
||||
small, AI-generatable game engine running **inside makepad itself**, built from three
|
||||
things we already have: the **splash script VM** (hot-reloadable, per-isolate), the
|
||||
**box3d** physics engine, and the **xr** 3D scene framework. Written 2026-07-09.
|
||||
|
||||
---
|
||||
|
||||
## 1. What we have today (the Godot version)
|
||||
|
||||
**Where games go:** `~/games/<name>/` (override with `GAMEMAKER_HOME`), one Godot 4.7
|
||||
project per game. The app owns `tools/` (agent harness) and `CLAUDE.md`; the kid's game
|
||||
is `project.godot` + `scenes/*.tscn` + `scripts/*.gd`. Per-game app state (chat log,
|
||||
Claude session id, model choice) lives in `<game>/.gamemaker/`.
|
||||
|
||||
**The loop:** kid holds F1 and talks (Whisper) or types → transcript goes to Claude Code
|
||||
(via `makepad_ai::ClaudeCodeAgent`, tool policy locked to Read/Glob/Grep/Edit/Write +
|
||||
`tools/gd` only) → Claude edits GDScript/tscn files → the app keeps the kid on the
|
||||
last-good running game and relaunches it only when the turn completes.
|
||||
|
||||
**What the AI actually builds** (from the real `~/games/my-game`, ~2600 lines generated):
|
||||
|
||||
- Everything is **procedural colored boxes** — no image/model/sound assets at all
|
||||
(enforced by the system prompt). 2D: `StaticBody2D`+`ColorRect` ground segments,
|
||||
platforms, moving platforms, walls, a goal flag. 3D: cube ground/stairs/towers/trees,
|
||||
a gold goal block, box-people.
|
||||
- **Character controllers**: `CharacterBody2D/3D` + gravity + jump + `move_and_slide`,
|
||||
respawn on fall, mount/dismount vehicles.
|
||||
- **Behaviors**: per-frame `_physics_process(delta)` steering (villagers chase, soldiers
|
||||
patrol), group queries (`get_nodes_in_group("vehicle")`), distance checks, spawn/despawn,
|
||||
timers (`create_timer`), win/lose conditions.
|
||||
- **HUD**: `Label`s toggled visible ("You win!", "Caught!").
|
||||
- **Input** through named actions only (`ui_left/ui_right/ui_accept`) so keyboard, input
|
||||
tapes, and gamepad (AgentEye binds the A button) all work through one vocabulary.
|
||||
|
||||
This is the complete capability envelope a kids' game engine needs. It is small.
|
||||
|
||||
## 2. How the agent remote-operates the game and looks at pictures
|
||||
|
||||
Three mechanisms, all file-based (Claude only has `tools/gd` as a shell surface):
|
||||
|
||||
| Verb | Mechanism | What the AI gets |
|
||||
|---|---|---|
|
||||
| `gd peek` | Drops `.agent/peek_request`; the **AgentEye autoload** in the *live* game polls (250ms), grabs 4 viewport screenshots over ~1.2s via `get_viewport().get_texture().get_image()`, writes player pos/vel/`is_on_floor()` | `.agent/sheet.png` + `state.txt` — sees what the kid sees, zero interruption |
|
||||
| `gd shot <scene> [frames] [tape]` | Second Godot instance boots `tools/harness.tscn`; harness loads the target scene, **replays a JSON input tape by frame number** (`{"f":30,"press":"ui_accept"}` → `Input.action_press`), prints `[probe]` pos/vel/floor lines every 15 frames; recorded with `--write-movie --fixed-fps 60` (deterministic: same tape ⇒ same frames). Since 2026-07-09 it launches via `open -g` + an unfocusable offscreen window so it never steals the kid's focus | contact sheet + numeric probe log — "the jump feels floaty" becomes a number |
|
||||
| `gd errors` | Greps the run log for `SCRIPT ERROR` / `ERROR:` | error text |
|
||||
|
||||
**The pictures:** `tools/sheet.py` tiles N evenly-spaced frames into one labelled contact
|
||||
sheet (`.agent/sheet.png`) so the agent reads *one* image showing motion over time instead
|
||||
of 120 frames.
|
||||
|
||||
**Weaknesses inherent to the Godot backend** (what the migration removes):
|
||||
|
||||
1. **Separate process.** Applying `.tscn` changes needs a full game restart (state loss,
|
||||
focus management, pid babysitting, zombie processes). We just spent a day making this
|
||||
tolerable; in-process it disappears entirely.
|
||||
2. **Capture needs a real (hidden) window** — Godot's `--headless` crashes with
|
||||
`--write-movie`. Makepad has a true headless CPU renderer.
|
||||
3. **Opaque runtime.** The only introspection is print statements the harness happened to
|
||||
include; errors come from log-grepping.
|
||||
4. **Two unfamiliar languages** (GDScript + tscn) and a giant API surface the model can
|
||||
misuse. A curated DSL of ~25 constructs is easier to prompt and to verify.
|
||||
|
||||
## 3. Building blocks in makepad (surveyed 2026-07-09)
|
||||
|
||||
### 3.1 Splash script VM — the hosting/hot-reload story is already built
|
||||
|
||||
- `widgets/src/splash.rs`: the `Splash` widget evaluates DSL **strings** in a dedicated
|
||||
isolate VM (`cx.alloc_splash_vm_with_network`), with instruction limits
|
||||
(`with_instruction_limit(200_000, …)`), and re-evaluates **incrementally** via
|
||||
`eval_with_append_source` parser checkpoints — this is how aichat streams a growing
|
||||
`runsplash` block into a live widget. Per-isolate `let`/`fn` state persists across events.
|
||||
- The DSL is a real language: `let`/`fn`, templates, `for`, `if/else`, closures,
|
||||
`on_click`-style handlers, struct arrays, `promise()/.await()`, HTTP. `splash.md` is the
|
||||
authoring manual the AI already follows; `examples/splash_preview/` is an offline
|
||||
generate-and-verify corpus harness (drives the `claude` CLI, evals every generated app,
|
||||
flags empty widget trees).
|
||||
- **Gaps for games:** no script-facing frame tick or timers, no keyboard events routed
|
||||
into isolates, and eval errors are *not* fed back (a broken block renders blank; errors
|
||||
only reach stderr via `ScriptVm::drain_errors`). All three are core workstreams below.
|
||||
|
||||
### 3.2 box3d — the physics core (`libs/box3d`, pure Rust port of Erin Catto's Box3D)
|
||||
|
||||
- Shapes: sphere, capsule, convex hull (`make_box_hull`), triangle mesh, heightfield,
|
||||
compound. Bodies: static/kinematic/dynamic. Full joint set. Sensors with begin/end
|
||||
touch **events**, contact/hit events, ray/shape casts, explosions, wind.
|
||||
- A **kinematic character controller** (`mover.rs`, `world_collide_mover`,
|
||||
`world_cast_mover`) — exactly what the player/NPC vocabulary needs.
|
||||
- **Bit-exact deterministic** across architectures and worker counts, faster than Rapier
|
||||
on 8/9 benchmark scenes, with a built-in **snapshot + record/replay** substrate
|
||||
(hash-exact). This upgrades the whole verify story: same tape ⇒ same *simulation*, not
|
||||
just same frames.
|
||||
- Consumer API is flat free functions (`create_world`, `create_body`,
|
||||
`create_hull_shape`, `world_step`, `body_get_transform`). **Rust-only today — zero
|
||||
script bindings.** That binding layer is the single biggest work item.
|
||||
- `examples/box3d/src/main.rs` already shows the renderer we need: instanced lit
|
||||
boxes/spheres (`DrawPhysMesh` script-shader, per-instance color+transform), an
|
||||
`XrCamera` orbit, `NextFrame`-driven stepping. ~600 lines, self-contained.
|
||||
|
||||
### 3.3 makepad-xr — the 3D scene layer (`xr/`, crate `makepad-xr`)
|
||||
|
||||
- `XrNode` scene graph (pos/rot/scale, physics body kind, children), object library
|
||||
(`Cube`, `IcoSphere`, `Gltf`, `FractalTree`, splats), behaviors in Rust (`Tank`, `Car`,
|
||||
`Shooter`), 2D-UI-on-a-plane (`XrView` — a free HUD system), PBR-ish shading.
|
||||
- **Worlds are authored in the script DSL and hot-reload** (`XrNode` handles
|
||||
`apply.is_reload()`; `on_render` closures rebuild geometry live) — proof that
|
||||
"AI edits script → live 3D scene updates" already works in this codebase.
|
||||
- Desktop fallback: orbit camera + **gamepad** gameplay (`cx.game_input_states()`).
|
||||
Quest: hands, passthrough, depth-scanned colliders, multiplayer (`xr/net`).
|
||||
- Caveats: physics is **Rapier3D** (not box3d), **no audio**, **no keyboard gameplay
|
||||
input**, and behaviors are compiled Rust (only the scene layer is scriptable).
|
||||
|
||||
### 3.4 Remote-operate substrate we already proved this session
|
||||
|
||||
- **Headless renderer**: `MAKEPAD=headless` builds render real frames to PNG on CPU with
|
||||
JIT-compiled shaders (`--draws=N`, `MAKEPAD_HEADLESS_OUT_DIR`). We used it today to
|
||||
pixel-verify a widget fix. This replaces the hidden-window capture instance outright.
|
||||
- **makepad_test** (`libs/makepad_test`): selector-driven UI automation (click/fill/
|
||||
wait_text) against a headless app instance, with failure screenshots — the skeleton of
|
||||
an input-tape runner.
|
||||
- **Studio hub bridge**: `WidgetTreeDump` / `Click` / `Screenshot` RPC into a running
|
||||
app — the skeleton of `peek` without file polling.
|
||||
|
||||
## 4. The aigame engine — design
|
||||
|
||||
**Principle: keep the exact product shape** (voice → Claude edits files → kid keeps
|
||||
playing until the AI is happy → instant apply), swap the engine underneath. The Game
|
||||
Maker app shell (chat, Whisper, TTS, sessions, model picker, relaunch policy) is reused
|
||||
as-is; only "relaunch Godot" becomes "hot-swap the game script".
|
||||
|
||||
### 4.1 Architecture
|
||||
|
||||
```
|
||||
~/games/<name>/game.splash ← the file(s) Claude edits (Read/Edit/Write, same as now)
|
||||
│ (file watch)
|
||||
GameMaker app ─── GameHost widget ← owns a splash isolate + a box3d World + fixed 60Hz tick
|
||||
│ │
|
||||
│ ├─ shadow-eval on change: new isolate, eval, 1 smoke tick
|
||||
│ │ ├─ clean → swap in (kid sees change in <1s, mid-play)
|
||||
│ │ └─ errors → keep last-good running, errors go to the agent
|
||||
│ ├─ renderer: instanced boxes/spheres/capsules (from examples/box3d)
|
||||
│ ├─ HUD: plain makepad widgets overlaid (or XrView in 3D)
|
||||
│ └─ ActionMap: keyboard + gamepad + tape → named actions
|
||||
└─ agent harness (tools/ag): test / peek / errors — see §5
|
||||
```
|
||||
|
||||
- **One process.** The game is a widget in the Game Maker window (optionally poppable
|
||||
into its own window later). No pids, no focus stealing, no restart.
|
||||
- **"It works" state, upgraded:** today the kid keeps the old *process*; here the
|
||||
last-good *isolate + world* keep running while the new source shadow-evals. A turn that
|
||||
ends broken never even flickers the kid's game — and the AI gets the error text
|
||||
immediately instead of a blank screen (fixes the Splash blank-on-error gap).
|
||||
- **Fixed timestep** (1/60, 4 substeps, like the box3d example) for determinism; render
|
||||
interpolation optional later.
|
||||
|
||||
### 4.2 The script surface the AI writes (curated, not raw bindings)
|
||||
|
||||
Bind a small **game vocabulary** into the isolate rather than exposing raw box3d — a
|
||||
~25-construct API is easier to prompt, to sandbox, and to keep stable. Sketch (syntax
|
||||
illustrative, follows splash rules):
|
||||
|
||||
```splash
|
||||
// game.splash — everything the AI edits lives here
|
||||
let SPEED = 240.0
|
||||
let JUMP = 520.0
|
||||
|
||||
fn build_world() {
|
||||
game.gravity(vec3(0, -30, 0))
|
||||
game.box{pos: vec3(0, -1, 0) size: vec3(120, 2, 8) color: #x3a8f4a} // ground
|
||||
for i in 0..6 {
|
||||
game.box{pos: vec3(10 + i * 8, i * 2, 0) size: vec3(4, 1, 4) color: #x8a6a3a}
|
||||
}
|
||||
game.box{pos: vec3(58, 13, 0) size: vec3(2, 2, 2) color: #xf5c13a tag: "goal" sensor: true}
|
||||
}
|
||||
|
||||
player := game.mover{pos: vec3(0, 2, 0) size: vec3(1, 2, 1) color: #x4466aa lock_z: true}
|
||||
|
||||
npc := game.mover{pos: vec3(30, 2, 0) size: vec3(1, 2, 1) color: #xaa4444 lock_z: true
|
||||
on_tick: |dt| { self.walk_towards(player.pos(), 3.0) }
|
||||
}
|
||||
|
||||
player.on_tick: |dt| {
|
||||
self.walk(input.axis("left", "right") * SPEED * dt)
|
||||
if input.pressed("jump") && self.on_floor() { self.jump(JUMP) }
|
||||
if self.pos().y < -20 { self.teleport(vec3(0, 2, 0)) }
|
||||
}
|
||||
|
||||
game.on_touch: |a, b| {
|
||||
if a.tag() == "goal" || b.tag() == "goal" { ui.hud_win.set_visible(true) }
|
||||
}
|
||||
|
||||
game.camera.follow(player, side_2d: true)
|
||||
```
|
||||
|
||||
Vocabulary checklist, derived 1:1 from what the Godot corpus actually used:
|
||||
|
||||
| Corpus need (Godot) | aigame construct | Backed by |
|
||||
|---|---|---|
|
||||
| ColorRect/box world building | `game.box/sphere/capsule{...}` (static/dynamic/kinematic, color, tag, sensor) | box3d shapes + instanced `DrawPhysMesh` |
|
||||
| CharacterBody + move_and_slide | `game.mover{...}` + `walk/jump/on_floor/teleport` | box3d `mover.rs` character controller |
|
||||
| `_physics_process(delta)` | `on_tick: \|dt\| {...}` per entity + `game.on_tick` | fixed-step pump into isolate |
|
||||
| groups / `get_nodes_in_group` | `tag:` + `game.find("tag")`, `e.distance_to(x)` | engine-side registry |
|
||||
| Area2D / goal triggers | `sensor: true` + `game.on_touch` | box3d sensor events |
|
||||
| moving platforms | kinematic body + `on_tick` setting velocity | box3d kinematic |
|
||||
| vehicles/mount | `attach/detach` (weld joint or parent) | box3d joints |
|
||||
| HUD labels | plain widgets over the viewport (`ui.hud_win`…) | makepad widgets (free) |
|
||||
| respawn / timers | `teleport`, `game.after(secs, \|\| {...})` | engine timer wheel |
|
||||
| input actions | `input.pressed("jump")`, `input.axis(..)` | ActionMap (§4.3) |
|
||||
| 2D platformer | `lock_z: true` + `side_2d` camera | box3d motion locks / parallel joint — **verify which; fallback: post-step plane clamp** |
|
||||
| win/lose sounds | `game.beep{...}` synth SFX | `cx.audio_output` (already used for TTS) |
|
||||
|
||||
### 4.3 Input: one ActionMap for keyboard, gamepad, and tapes
|
||||
|
||||
Mirror the Godot design that made tapes+controllers free: script code only ever sees
|
||||
named actions (`left/right/jump/up`). The engine maps arrow keys/WASD + gamepad
|
||||
(`cx.game_input_states()`, as xr's Tank does) + **tape events** onto the same names.
|
||||
Makepad has full keyboard events (`Event::KeyDown`/`KeyCode`) — xr just never wired them;
|
||||
we wire them in the GameHost, not in xr.
|
||||
|
||||
### 4.4 Renderer choice
|
||||
|
||||
**Phase 1: lift `examples/box3d`'s renderer** (instanced lit unit-cube/sphere +
|
||||
`XrCamera`, ~200 lines) into the GameHost. It draws exactly the corpus art style.
|
||||
**Later: converge with xr** — adopt `XrNode`/`XrView` for scene+HUD and port xr's physics
|
||||
from Rapier to box3d (justified independently: box3d is faster on 8/9 scenes and
|
||||
deterministic; one physics engine in the tree instead of two). That convergence buys
|
||||
Quest/hands/multiplayer for the *same game scripts* — a kid's game playable in VR — but
|
||||
it is explicitly not on the critical path.
|
||||
|
||||
## 5. The agent harness on makepad (remote-operate, tier by tier)
|
||||
|
||||
Same three verbs, better substrate. `tools/ag` (or a `gd`-compatible shim so the prompt
|
||||
barely changes):
|
||||
|
||||
| Verb | Godot today | aigame |
|
||||
|---|---|---|
|
||||
| `ag test [frames] [tape]` | hidden Godot instance, --write-movie, probe prints | **headless run of the same GameHost** (`MAKEPAD=headless`): eval `game.splash`, feed the tape into the ActionMap, `world_step` N frames, render PNGs on CPU, emit probe lines (engine reads pos/vel/on_floor directly — no print statements needed). box3d determinism ⇒ bit-exact repeatability, stronger than Godot's fixed-fps movie |
|
||||
| `ag peek` | file-RPC into live game (AgentEye), viewport screenshots | in-process: the app screenshots its own game pass texture + dumps entity state on request (file trigger kept for CLI compat, or a local socket). Kid keeps playing, same as now |
|
||||
| `ag errors` | grep run logs | **drain the script VM error queue** — precise parse/runtime errors with line numbers, returned as text. Also auto-attached to the turn when a shadow-eval fails, so the AI often self-corrects *without* running anything |
|
||||
| pictures | `sheet.py` contact sheet | keep `sheet.py` verbatim (it's engine-agnostic: dir of PNGs → one labelled sheet) |
|
||||
| tapes | JSON `{"f":N,"press":"ui_accept"}` | same format, actions renamed; probe list = tags |
|
||||
|
||||
Bonus unlocked by box3d: `ag test --record` / snapshot scrubbing — the AI can capture a
|
||||
deterministic recording once and re-probe it at different frames without re-running.
|
||||
|
||||
## 6. Changes to the Game Maker app (small)
|
||||
|
||||
- `play_game()`/`relaunch_if_pending()` → `GameHost::reload(path)` (shadow-eval + swap).
|
||||
The turn-completion policy from 2026-07-09 stays: kid keeps last-good until the AI is
|
||||
happy — it just gets cheaper (no process restart, sub-second apply).
|
||||
- System prompt + per-game `CLAUDE.md`: rewritten against the aigame DSL; ship a new
|
||||
**`aigame-dsl.md`** authoring guide (the `splash.md` equivalent for games: the
|
||||
vocabulary table, tick/input/tape rules, the `#x` hex rule, worked platformer example).
|
||||
- Template: `~/games/<name>/game.splash` starter + `tools/ag` + tapes. `refresh_harness`
|
||||
(added today) already re-stamps tools on project switch.
|
||||
- Permission policy shrinks: Claude gets `Edit/Write(./**)` + `Bash(./tools/ag:*)` only.
|
||||
|
||||
## 7. Phases and milestones
|
||||
|
||||
**Phase 0 — proof of loop (the risky part, do first)**
|
||||
Script-bind the minimum box3d surface (world/step, box+sphere bodies, transforms) into a
|
||||
splash isolate; GameHost widget with fixed tick, keyboard ActionMap, `on_tick` dispatch
|
||||
into script closures; lift the box3d example renderer.
|
||||
*Milestone:* a ~100-line `game.splash` platformer (locked z) runs at 60fps and hot-reloads
|
||||
on file save without dropping world state of the running instance until swap.
|
||||
*Verify here:* per-tick script-call overhead with ~20 entities × 60Hz under instruction
|
||||
limits; box3d 2D locking mechanism; headless JIT compiles `DrawPhysMesh` (we fixed the
|
||||
scalar-cast JIT bug today — same risk class).
|
||||
|
||||
**Phase 1 — the corpus vocabulary**
|
||||
Mover controller, sensors → `on_touch`, tags/queries, spawn/despawn, timers, camera
|
||||
follow (side-2D + third-person), HUD overlay widgets, gamepad, synth SFX (`game.beep`).
|
||||
*Milestone:* hand-port `~/games/my-game` (both the 2D level and the 3D chase sandbox) to
|
||||
`game.splash` — the real generated corpus is the acceptance test for vocabulary
|
||||
completeness.
|
||||
|
||||
**Phase 2 — the agent loop**
|
||||
Headless `ag test` (tape → frames → probe → sheet), `ag peek`, VM-error round-trip +
|
||||
shadow-eval, Game Maker integration, new prompt + `aigame-dsl.md`, template swap.
|
||||
*Milestone:* end-to-end kid session: "make him jump higher" → edit → self-test headless →
|
||||
turn completes → game hot-swaps mid-play; zero focus steal; broken edits never reach the
|
||||
kid and come back to the AI as line-numbered errors.
|
||||
*Regression harness:* splash_preview-style batch corpus — a set of recorded kid requests
|
||||
run through the real `claude` CLI against `aigame-dsl.md`, each result eval-checked and
|
||||
tape-smoke-tested headlessly.
|
||||
|
||||
**Phase 3 — convergence and reach**
|
||||
Port `makepad-xr` physics Rapier→box3d; host aigame scenes on `XrNode`/`XrView`; the same
|
||||
`game.splash` then runs on Quest (hands/passthrough) and inherits xr multiplayer.
|
||||
Optional: box3d record/replay scrubbing in the harness; state-preserving hot reload via
|
||||
world snapshots.
|
||||
|
||||
## 8. Open questions / decision log
|
||||
|
||||
1. **Curated `game.*` API vs raw box3d bindings** — recommended: curated (smaller prompt,
|
||||
stable across engine refactors, sandboxable). Raw bindings can come later for power use.
|
||||
2. **2D story** — one engine (3D + locked axis + orthographic-ish side camera), not a
|
||||
second 2D engine. Needs the motion-lock verification in Phase 0.
|
||||
3. **Where behaviors live** — corpus says script-side `on_tick` closures suffice (steering
|
||||
is ~10 lines); compiled-Rust behaviors (xr's Tank pattern) stay an escape hatch for
|
||||
things script is too slow for.
|
||||
4. **Splash-in-chat vs GameHost** — games do NOT run as `runsplash` chat blocks; the
|
||||
GameHost is a dedicated widget with its own isolate, tick, and input focus. Chat
|
||||
blocks stay for the AI showing UI snippets.
|
||||
5. **Error feedback for aichat generally** — the VM-error round-trip built for aigame
|
||||
(drain_errors → agent) should be upstreamed to the `Splash` widget too; blank-on-error
|
||||
hurts every runsplash use case.
|
||||
52
aigame_parity_gap.md
Normal file
52
aigame_parity_gap.md
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# Full-parity gap: current ~/games/my-game vs the aigame engine
|
||||
|
||||
Goal (rik, 2026-07-09 late): run the little Godot game — CURRENT state — fully in
|
||||
splash/gamemaker. Evidence: 21:54 `gd shot` sheet + a full close-read of all 17 actor
|
||||
scripts. The game now spawns **~44 creatures + 2 vehicles**: Giant DogDay guardian
|
||||
(23-box model, 1.9x scale, intercept-charges nightmares), 3 headcrabs (leap + latch to
|
||||
the player's head, 0.5x speed debuff, shake off by jumping), the Prototype
|
||||
(weeping-angel: only moves unwatched, LOS dot 0.55 vs camera forward), Baba Chops ram
|
||||
(27 boxes, fire eyes with proximity-ramped emission), Nightmare Huggy (arm-reach lerp),
|
||||
4 nightmare critters, 10 farm animals (per-kind synth calls w/ pitch), Huggy pack,
|
||||
Kissy-as-bodyguard, grapple hand (yank player to walls / haul creatures, stretched
|
||||
cable), 64-chunk welded smooth terrain (256x256 cells, 0.5 terracing, caves, alpha
|
||||
water at y=3.5), ProceduralSky, shadowed sun, crosshair + hint + 4 colored flash HUD.
|
||||
|
||||
**Key architectural relief:** Godot NEVER rotates the physics body — only the visual
|
||||
`Model` child yaws (atan2 facing + turn-rate clamp). Our unrotated-AABB physics is the
|
||||
same design; the gap is visual/animation, not physics.
|
||||
|
||||
Axis trap for the re-port: main3d.gd heightfield is x-major (`i*(CELLS+1)+j`), our
|
||||
game.terrain is z-major row-major — transpose or the goal lands on the wrong peak.
|
||||
(The Godot-side "_heights@62" parse error is a stale Godot cache: identifier doesn't
|
||||
exist on disk; file is internally consistent.)
|
||||
|
||||
## Engine work — Fork A "the look" (game_view render/world)
|
||||
|
||||
| # | Item | Spec from the game |
|
||||
|---|---|---|
|
||||
| A1 | Per-entity model yaw | auto-face velocity w/ turn-rate clamp (default movers), `game.face(id, yaw)` / `turn_rate` override; physics AABB unchanged |
|
||||
| A2 | Owner-local parts | part offsets rotate with owner yaw; fronts at −z convention |
|
||||
| A3 | Part animation | `game.move_part(part, {pos, rot_x, rot_z…})` engine-lerped (t≈dt*9, Godot's constant); `game.scale(id, v)` model scale (CatNap curl 1/0.6/1, DogDay 1.9x); headcrab spin |
|
||||
| A4 | Emission | glow parts (eyes 3–4, runtime ramp 1.5→5, bolt/beacon); `glow:` spawn opt + `game.glow(id, e)` |
|
||||
| A5 | Sky + ambient + fog | gradient top (0.32,0.58,0.9) → horizon (0.75,0.87,0.96); distance fade |
|
||||
| A6 | Blob shadows | dark ground quad per mover at height-lookup y (real shadow maps out of scope) |
|
||||
| A7 | Smooth terrain | triangulated heightfield mesh, flat per-tri normals, per-vertex height colors (SAND≤3.6/GRASS≤13/DIRT≤17.5/STONE≤21/SNOW), height-lookup collision; translucent water sheet (0.25,0.55,0.85,0.6) |
|
||||
|
||||
## Engine work — Fork B "systems"
|
||||
|
||||
| # | Item | Spec |
|
||||
|---|---|---|
|
||||
| B1 | Ride attach + debuff | `attach(..., {mode:"ride", spin})` per-frame head-follow; `game.speed_mult(id, f)`; shake-off = script (vel.y > 5 or timeout → detach) |
|
||||
| B2 | Grapple support | `game.beam(a, b, {size,color})` stretched transient box (cable); haul = attach + detach-with-pop (exists); yank = set_vel (exists) |
|
||||
| B3 | Camera | third-person low rig: pivot height 1.6, boom 10, pitch clamp [−1.2, 0.25], occlusion pull-in vs terrain/boxes (`max(1, hit−0.5)`), ignore tagged scenery; optional mouse-look |
|
||||
| B4 | HUD slots | crosshair toggle, hint line (top-left), 4 independent colored center banners: `game.text(slot, msg, {color, size})`; billboard labels: multiple per entity, color/size/height opts |
|
||||
| B5 | Synth voices | bark, moo, clank, whip (+ per-call pitch already works) |
|
||||
|
||||
## Script-side patterns (no engine work — fixture implements with existing APIs)
|
||||
group-AI intercept (tags + find + dual distance gates), LOS gate (cam_yaw dot), duck
|
||||
damage dispatch (tag → handler table in script), stun/stagger contract, animal AI.
|
||||
|
||||
## Sequence
|
||||
Fork A → Fork B (same files, sequential) → Fork C re-port of the CURRENT game as
|
||||
fixtures/sandbox3d.splash v2 (acceptance test) → GPU visual check by rik.
|
||||
179
aigame_port_findings.md
Normal file
179
aigame_port_findings.md
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
# aigame port findings — my-game 3D sandbox → game.splash
|
||||
|
||||
Result of test-porting the AI-generated Godot game (`~/games/my-game`, the active
|
||||
3D sandbox: ~2100 lines across main3d + 9 actor scripts) onto the gamemaker
|
||||
engine as one `game.splash`. Fixture:
|
||||
`examples/gamemaker/resources/fixtures/sandbox3d.splash` (~370 lines — 5.7×
|
||||
denser than the GDScript, mostly because the engine owns spawning/physics/SFX).
|
||||
Written 2026-07-09. Companion checklist: `aigame_port_inventory.md`.
|
||||
|
||||
## Verification
|
||||
|
||||
- Whole world evaluates clean first-load: **1036 entities** (961 terrain columns,
|
||||
water sheets, 14 trees, goal + beacon, 19-creature cast, 2 trucks), zero eval
|
||||
or tick errors at the 500k/tick instruction limit.
|
||||
- Tape run (`ag test`, 200 frames): spawn→fall→land at exactly ground+half;
|
||||
camera-relative walk at exactly SPEED 6.0; jump arc peaks +2.25 = JUMP²/2G to
|
||||
the decimal; wander AI steers the cast; respawn works. Probe numbers, not hopes.
|
||||
- Mount/drive/dismount verified end-to-end *by accident*: the first fixture spawn
|
||||
was 2.3 units from a truck and the player silently auto-mounted at tick 1 —
|
||||
seat height, top speed, and jump-to-dismount all matched the Godot behavior
|
||||
before I even tried to test them. (Fixed by matching the original's player
|
||||
transform (-8, 11, 8).)
|
||||
- NOT verified visually (headless pane compositing still pending, task #9):
|
||||
colors, camera feel, HUD text rendering. Not exercised behaviorally: nightmare
|
||||
wake/pounce and the win touch (code-verified only; both use the same verbs the
|
||||
probes exercised).
|
||||
|
||||
## Engine bugs the port flushed out (both fixed)
|
||||
|
||||
1. **Sensors were solid.** `step_world` filtered collision candidates by body
|
||||
kind only — `sensor: true` boxes (goals, my water sheets, the beacon) blocked
|
||||
movement, contradicting the documented contract. One-line filter fix. The
|
||||
starter game never noticed because its goal floats at jump apex.
|
||||
2. **(Earlier, same session) optional positional args trapped** — probing
|
||||
`args[1]` on a 1-arg call failed the whole eval. Every optional arg would
|
||||
have hit this.
|
||||
|
||||
A port whose only failures were engine bugs, not script bugs, is a good sign
|
||||
for the DSL's authorability.
|
||||
|
||||
## The six predicted cleanups — verdict from actually porting
|
||||
|
||||
| # | Prediction (aigame_port_inventory.md) | Verdict |
|
||||
|---|---|---|
|
||||
| 1 | First-class SFX bank + synth | **Confirmed hard.** The corpus had invented `squeak` and `roar` beyond the bank we shipped — the bank grew twice in one day. Named-bank + beep/jingle is the right shape; expect the bank to keep growing from corpus usage. |
|
||||
| 2 | Tags + broadcast/duck-dispatch | **Half-confirmed.** `find`/`tag`/`distance` + script-side state objects covered everything the port needed — cross-actor "messages" (enrage/zap/heal) became plain field writes on shared script objects, which is *better* than Godot's group+has_method bus. A native broadcast API is NOT needed while one script owns all actors. It becomes needed only if games ever split across isolates. **Deferred, deliberately.** |
|
||||
| 3 | Script raycast | **Dodged, honestly.** Ledge-probe AI was replaced by heightmap lookup (`h_at`) because terrain heights are script data anyway. Fine for this game; a shooter that needs line-of-sight will force `game.raycast`. Keep on the roadmap, don't build speculatively. |
|
||||
| 4 | Camera-relative helper | **Confirmed, split in two.** The missing primitive wasn't a movement helper — it was `game.cam_yaw()` (the orbit camera's yaw was engine-private). With yaw readable, two lines of cos/sin in script do the rest; documented as a pattern instead of an API. |
|
||||
| 5 | `attach/detach` (seats/carrying) | **Confirmed emphatically.** Replaced ~100 lines of per-actor teleport-following + collision toggling in GDScript with 2 calls. Vehicles, passengers, and the dismount-pop all fell out. |
|
||||
| 6 | Mover platform carry | Already in the engine (kinematic floor_id carry); the port didn't stress it (no moving platforms in the 3D sandbox — they're in the 2D game). |
|
||||
|
||||
## New findings (not predicted)
|
||||
|
||||
1. **No RNG in the script language** — the single most-used Godot facility
|
||||
(randf/randi everywhere in wander AI) simply didn't exist. Added
|
||||
`game.rand()`/`game.rand_range(a,b)`, xorshift **seeded per eval**: wander AI
|
||||
now replays identically under input tapes, which Godot's `randomize()`
|
||||
corpus could never do. Determinism became a feature of the port.
|
||||
2. **No noise either** — the terrain wants value noise. A 12-line script
|
||||
`hash/smooth/noise2` worked fine at 31×31. At the original's 256×256 it
|
||||
wouldn't (65k columns ≈ 3M+ instructions, and 65k entities would swamp both
|
||||
the O(statics×movers) physics and the instanced renderer). The Godot game
|
||||
itself had to weld chunks — scale is an *engine* concern in any engine.
|
||||
→ Roadmap: `game.terrain({cells, cell, heights})` native heightfield
|
||||
(box3d has heightfield shapes waiting) + greedy column merging.
|
||||
3. **`shoot` was a hardcoded gap** — the ActionMap knew jump but nothing else;
|
||||
the corpus registers custom actions at runtime (mouse+F+gamepad X). Added F →
|
||||
`shoot`/`shoot_pressed`. Real fix on the roadmap: `game.action("dash", "KeyQ")`
|
||||
runtime action registration, like the corpus does in Godot.
|
||||
4. **Projectiles work but are clunky**: a bolt = gravity-0 mover + set_vel +
|
||||
script-side life/hit bookkeeping in a `retain` closure. Fine at 5 bolts;
|
||||
a bullet-hell would want `game.spawn_projectile({vel, life, on_hit})` with
|
||||
engine-side lifetime. Mover-vs-mover hits also aren't reported by `on_touch`
|
||||
(sensor×mover only) — the port used distance checks; real overlap events for
|
||||
movers is a small, worthwhile addition.
|
||||
5. **One HUD line is enough** — the original's 5 labels + colors + cancel-token
|
||||
flashes collapsed into `game.text` + a 6-line script `flash()` with a
|
||||
generation counter. Colored/positioned HUD text can wait.
|
||||
6. **Multi-part models are the visible fidelity loss.** Every creature is one
|
||||
colored box; the originals have legs/shirt/head/eyes/smile built from 6–12
|
||||
cubes, billboard nametags, emissive eyes. The single biggest visual upgrade
|
||||
per line of API: `game.part(id, {offset, size, color})` decorative child
|
||||
boxes (no physics), plus `game.label(id, text)` nametags. This is also what
|
||||
made the Godot corpus *charming* — worth prioritizing over new mechanics.
|
||||
7. **Script ergonomics held up.** Struct-arrays of actor state + one shared
|
||||
steering function expressed 9 GDScript classes in ~120 lines. Field mutation
|
||||
through array elements, closures capturing top-level `let`s, and `retain`
|
||||
with side effects all just worked. The `0.0 - x` workaround for (possibly
|
||||
fine) unary minus should be tested and, if broken, fixed — it's the ugliest
|
||||
thing in the fixture.
|
||||
|
||||
## Fidelity ledger (what the port drops vs the original)
|
||||
|
||||
- **Terrain at 1/8 resolution** (31×31×4u columns vs 256×256×0.625u welded
|
||||
chunks): terraces are chunkier, no caves (cave-carving needs the fine grid to
|
||||
read as tunnels), no smooth triangulated slopes, no vertex-color blending.
|
||||
- **Retired cast stays retired** (Huggy, Robot, soldiers — the original has them
|
||||
commented out too), so Kissy's enrage/defend/flee arcs are dormant here as
|
||||
there; her follow behavior is live.
|
||||
- Single-box creatures, no nametags, no arm-reach animation, no emissive glow,
|
||||
no procedural sky (fixed background), water is an opaque thin sheet (alpha
|
||||
untested in the cube renderer), no camera-blocking ray (camera can clip
|
||||
through hills), no mouse-capture (orbit-drag instead), mini-variants are
|
||||
recolored critters.
|
||||
- Behavior approximations: injured-Kissy heals into a follower (original swaps
|
||||
to a full Kissy with her own arc); nightmare glow-eyes become a body-color
|
||||
swap; critter squeak probabilities eyeballed.
|
||||
|
||||
## Bottom line
|
||||
|
||||
The port took one authoring pass, found two engine bugs and five API gaps, and
|
||||
every gap closed with a small primitive rather than a framework. The engine's
|
||||
current vocabulary + script-side state objects genuinely cover the corpus's
|
||||
*behavioral* range; the visible gap is decoration (parts/labels), and the
|
||||
structural ceiling is terrain scale — both have clear, small next steps.
|
||||
|
||||
## v2 re-port (2026-07-09 late) — the full current game
|
||||
|
||||
`fixtures/sandbox3d.splash` v2 ports the game's CURRENT state (~3600 GDScript lines,
|
||||
44 creatures + 2 trucks) onto the upgraded engine: verified headless — eval clean at
|
||||
90 entities, tape run writes test_done/captures/probes, plaza floor exactly 7.9 and
|
||||
walk exactly 6.0 (Godot ground truth), and two identical runs produce byte-identical
|
||||
probes. The tape also exercises the full truck mount/drive/dismount chain.
|
||||
|
||||
### Fidelity ledger
|
||||
|
||||
**Exact:** every spawn-table name/position/size/color/speed; all behavior constants —
|
||||
Giant DogDay guardian (follow 34/stand 9.5, charge 8.4 to an intercept point, bonk 5 →
|
||||
stagger+knockback+bark, golden beam flash), Kissy bodyguard (gates 18/34, intercept,
|
||||
shove 2.2), headcrabs (chase 26, leap at 6 [up 8 fwd 7, cd 1.8], latch 1.5 → ride-attach
|
||||
(0,1.95,0) spin 2 + speed_mult 0.5, shake-off on vel.y>5 or 4s, self-stun 2.5),
|
||||
Prototype weeping-angel (LOS dot 0.55 in 34, creep 70, eye glow 1.5/4.5), Baba Chops
|
||||
(charge 14 @7.2, ram 2.4, fire-eye glow 2→5 ramp), Nightmare Huggy (hunt 90, arm-reach
|
||||
via move_part inside 30), nightmare critters (creep 30, nip 1.3 bounce), CatNap
|
||||
(sleep-curl scale 1/0.6/1, wake 9, catch 1.9), farm animals (shy 4.5/flee 4.4, calls
|
||||
4–14s at Godot's per-kind pitches), trucks/passengers, terraced terrain recipe
|
||||
(12.5+noise, snap 0.5, plaza 26/14/7, water 3.5, height-color bands), goal on the true
|
||||
peak + glowing beacon, third-person camera (1.6/10/−0.35) + crosshair + hint, zap
|
||||
dispatch on all threat kinds, HUD flash tokens, win latch, full synth bank.
|
||||
|
||||
**Approximated:** terrain colors are single-color auto-shade (Godot has height bands
|
||||
sand/grass/dirt/stone/snow — a 257² script colors array would blow the eval budget;
|
||||
engine follow-up: band colors as terrain options);
|
||||
models simplified but silhouette-faithful (DogDay 21 parts incl. the 8-box sunbeam
|
||||
collar vs 23; Baba 15 vs 27 — horns straight not curled); animal voices = moo/squeak
|
||||
at Godot's pitch tables (no dedicated oink/baa/cluck recipes); only Nightmare Huggy's
|
||||
arms animate (pack arms static); zap stun uniform 4.5s (Godot 4–5.5 per kind).
|
||||
|
||||
**Dropped:** cave tunnels only. (x-major→z-major heightfield transpose handled;
|
||||
goal verified on the true peak via `game.ground_peak()`.)
|
||||
|
||||
### Engine gaps: found by the port, then closed engine-side (same day)
|
||||
|
||||
1. **CLOSED — eval budget vs terrain scale.** `game.terrain` now runs its noise
|
||||
engine-side (`freq`/`offset`/`step`/`min`/`max`/`plaza`, cells up to 384), plus
|
||||
`game.ground_y(x, z)` and `game.ground_peak()`. The fixture builds the full
|
||||
Godot-scale 257×257 world (256 cells @0.625u) with zero script instruction cost,
|
||||
and all spawn/tree/goal heights come from ground queries.
|
||||
2. **CLOSED — second input action.** `grab` exists (keyboard G, gamepad B, tape
|
||||
action) with `grab`/`grab_pressed` snapshot fields. The grapple hand is ported:
|
||||
32 u/s flight to range 16 on a `game.beam` cable, terrain hit → player yank
|
||||
(22 u/s + 6.5 up), creature hit → ride-attach haul, held 1.2s, set down at the
|
||||
player's feet with a pop. Approximations (memo): solid-hit detection is
|
||||
terrain-height only (tree/box bodies don't catch the cable — needs a script
|
||||
raycast/box-probe verb); the hand vanishes at max range instead of retracting.
|
||||
3. **CLOSED — label outlines.** Engine draws a 4-copy dark outline behind every
|
||||
billboard label; nothing needed in script.
|
||||
4. **OPEN — height-carving.** Caves need holes/overhangs in the heightfield (or
|
||||
rock-slab CSG); the smooth mesh is single-valued.
|
||||
5. **OPEN (new, minor) — terrain band colors.** Height-band coloring
|
||||
(sand/grass/dirt/stone/snow) as engine-side terrain options, since script-built
|
||||
color arrays don't scale past ~100² vertices.
|
||||
6. **OPEN (new, minor) — solid-probe verb.** A `game.raycast`/box-probe would let
|
||||
the grapple (and ledge-AI patterns from the corpus) hit boxes, not just terrain.
|
||||
|
||||
Verification after the engine-noise/grapple update: eval clean at 90 entities,
|
||||
zero JIT failures, last_error empty, tape (walk/jump/shoot/grab) → test_done +
|
||||
captures + probes with plaza floor exactly 7.9 and walk exactly 6.0, two runs
|
||||
byte-identical.
|
||||
70
aigame_port_inventory.md
Normal file
70
aigame_port_inventory.md
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
# my-game → splash port inventory (generated 2026-07-09)
|
||||
|
||||
Exhaustive Godot API surface of ~/games/my-game (the AI-generated corpus), used as the
|
||||
acceptance checklist for the aigame engine port. Two parallel games: 2D side-scroller
|
||||
(main.tscn) and the ACTIVE 3D sandbox (main3d.tscn, run/main_scene). 13 actors share one
|
||||
behavior pattern set; 2D/3D scripts are near-mirrors.
|
||||
|
||||
## First-class engine APIs required (by measured usage weight)
|
||||
|
||||
- **Position as currency**: `global_position` read/write ×122 (write = teleport: respawn,
|
||||
mount-follow, carry). `distance_to` ×14, `length` ×23, `normalized` ×16.
|
||||
- **Kinematic character motion**: `move_and_slide` ×14 (ALL actors), `is_on_floor()` ×33,
|
||||
hand-rolled gravity (`velocity.y += G*dt`, engine gravity unused), `move_toward` ×9
|
||||
(friction), `is_on_wall` ×6 (hop trigger), per-actor jump/fall constants, respawn on
|
||||
fall (y beyond limit → teleport to recorded `_spawn`).
|
||||
- **World building**: StaticBody2D/3D + box colliders for ALL terrain; 3D visuals 100%
|
||||
MeshInstance3D+BoxMesh+StandardMaterial3D.albedo_color (8 sites); 2D visuals 100%
|
||||
ColorRect. Counts: 2D = 6 ground + 19 platforms + 7 movers + walls/flag/hills; 3D =
|
||||
ground 3 + stairs 7 + towers 8 + trees 12 + goal + 10 creatures + 2 vehicles.
|
||||
- **Groups as event bus** (no signal bus!): groups player/vehicle/soldier/kissy/huggy/game;
|
||||
`get_nodes_in_group` iterated per-frame for nearest-target;
|
||||
broadcast = scan group + `has_method()` duck-typing ×14 (`huggy_caught`, `enrage`,
|
||||
`capture`, `rescue`, `zap`, `send_home`, `cam_yaw`…). Engine needs: tags + find/iterate
|
||||
+ dynamic method dispatch on entities.
|
||||
- **Scene tree**: `add_child` ×53 (all procedural), `is_instance_valid` ×14 (cached-ref
|
||||
guards), `set_script` ×6 (behavior attach to bare bodies), `queue_free` ×3 (bolt).
|
||||
- **Input**: `get_axis(left,right)` ×6, `is_action_just_pressed` ×7; actions used:
|
||||
shoot×8, ui_up/right/left/accept×4 each, ui_down×2, ui_cancel×1. RUNTIME InputMap
|
||||
action creation (shoot = mouse-left + F key + gamepad X). Mouse-capture relative-motion
|
||||
camera (captured/visible modes, pitch clamp, Esc release).
|
||||
- **Camera**: 2D = child cam, position smoothing (speed 6), level-bounds limits.
|
||||
3D = CamYaw→CamPitch→Camera3D boom (z=13), mouse yaw/pitch, camera-relative locomotion
|
||||
via `Basis(UP, yaw) * wish` — shared by player AND vehicle steering (`driver.cam_yaw()`).
|
||||
- **Random/AI math**: randf_range ×16, randf ×7, randi/pick_random ×5, lerp ×8, atan2
|
||||
(model facing), sin/cos ping-pong (movers).
|
||||
- **Audio — procedural synth, zero files** (sfx.gd autoload the AI wrote itself):
|
||||
8-voice AudioStreamPlayer pool, 22050Hz s16 WAV buffers generated by `_sweep`
|
||||
(square pitch glide), `_zap` (falling tone+noise), `_notes` (arpeggio). Bank: jump,
|
||||
shoot, zap, grab, angry, calm, rescue, shove, board, win. API `Sfx.play(name, pitch)`.
|
||||
→ aigame should ship this as first-class (`game.sfx("jump")` + beep/jingle synth).
|
||||
|
||||
## Structural patterns to support
|
||||
|
||||
- Mount/dismount: nearest group("vehicle") within REACH → `mount(player)` → disable
|
||||
collider (deferred) + stop actor physics + per-frame teleport to seat; up to 3
|
||||
passenger seats; eject with pop velocity + cooldown.
|
||||
- Win conditions polled in `_process` (goal distance / x threshold) + `_won` latch + HUD.
|
||||
- HUD: Label text/visible + font/outline theme overrides; transient banners via
|
||||
`await create_timer(s)` with generation-token cancel (the ONLY async in the game — no
|
||||
tweens/AnimationPlayer anywhere).
|
||||
- Ledge AI: RayCast repositioned per frame + force_raycast_update; ±90° sidestep along
|
||||
edges (no navmesh). Needs script-facing raycast.
|
||||
- Moving platforms (2D): AnimatableBody2D sync_to_physics, cosine ping-pong, carries
|
||||
riders via floor tracking.
|
||||
- Collision layers: 1=world, 2=creatures, 4=vehicle, 8=player; creatures/player mask
|
||||
world-only (pass through each other); bolt masks world+creatures.
|
||||
- Label3D billboard nametags (no_depth_test); emission material on bolt projectile;
|
||||
ProceduralSky + one shadowed DirectionalLight3D.
|
||||
|
||||
## Confirmed absent (do NOT build): tweens, particles, AnimationPlayer, navmesh,
|
||||
custom shaders, paths, physics joints (in-game), signals (only Area3D.body_entered ×1).
|
||||
|
||||
## Porting risk list (ranked)
|
||||
1. Procedural audio synth (engine-side now, task #6)
|
||||
2. Mouse-capture camera + camera-relative movement basis
|
||||
3. Ledge raycast AI (script raycast API)
|
||||
4. Moving platforms carrying riders (mover ground-velocity inheritance)
|
||||
5. Runtime input action registration (ActionMap covers it)
|
||||
6. Deferred collision toggling during mount (engine needs disable-collision-safe-point)
|
||||
7. Jolt move_and_slide floor-snap feel parity (box3d mover tuning)
|
||||
|
|
@ -1,210 +0,0 @@
|
|||
# Arcade budgets
|
||||
|
||||
What a generated game can spend and still hold frame time. This feeds Fable's
|
||||
system prompt so generated games stay inside the envelope by construction
|
||||
rather than being profiled after the fact.
|
||||
|
||||
**Status: measured on an M-class Mac (release), extrapolated conservatively
|
||||
for Quest. The Quest column is an ESTIMATE until it is run on device.**
|
||||
|
||||
## Simulation
|
||||
|
||||
| thing | measured | notes |
|
||||
|---|---|---|
|
||||
| 100 movers + 50 rigid bodies + 65×65 terrain | **0.038 ms/tick** | M1a, release. The 60 Hz budget is 16.6 ms, so the sim is ~0.2 % of it |
|
||||
| entity lookup | O(log n) | binary search over the sorted-id Vec (M0r) |
|
||||
| script per tick | ≤ 2 ms | one cumulative 500k-instruction pool shared by on_tick + timers + touch events |
|
||||
|
||||
The sim is nowhere near the limiting factor. Draw calls and CPU skinning are.
|
||||
|
||||
## Vertex and instance bandwidth
|
||||
|
||||
Quest is bandwidth-bound before it is ALU-bound, so this is the number that
|
||||
matters most on device.
|
||||
|
||||
| stream | before | after | note |
|
||||
|---|---|---|---|
|
||||
| cube instance | 44 floats / **176 B** | 32 floats / **128 B** | **−27 %**, measured from the compiled shader (`RenderStats::instance_floats`), not counted by hand |
|
||||
| skinned character vertex | 16 floats / 64 B | 6 floats / **24 B** | **−62 %**, and this one is re-uploaded *every frame* |
|
||||
| shadow mesh vertex | 16 floats / 64 B | 6 floats / **24 B** | **−62 %** |
|
||||
| terrain vertex | 16 floats / 64 B | unchanged | uploaded once per terrain revision, so the win is small; see below |
|
||||
|
||||
The Knight is the prize: 3716 verts × 64 B = **238 KB every frame** (skinning
|
||||
is CPU-side, so the whole buffer is re-uploaded) → **89 KB**. On a
|
||||
bandwidth-bound tiler that is the single biggest saving available.
|
||||
|
||||
The instance saving came from moving `sun_color`, `sun_sky`, `sun_ground` and
|
||||
`fog_color` **off the instance stream into shader uniforms**. They are
|
||||
identical for every instance in a batch, so as instance fields they were 12
|
||||
floats of pure duplication per cube. `fog_density` stays per-instance because
|
||||
shadows switch it off individually. At the demo's instance counts that is
|
||||
48 B × every cube in the frame, every frame.
|
||||
|
||||
### How the packing works
|
||||
|
||||
**Vertex attributes in this engine are f32-only** — there is no u8/u16/i16
|
||||
attribute type. Compression therefore means bit-packing into f32 lanes and
|
||||
unpacking in the shader, which the engine already supports: `unpack2f16` and
|
||||
`unpack4u8` are builtins on every backend (Metal/GLSL/HLSL/WGSL), and
|
||||
`geom.VectorVertexPacked` is the house precedent.
|
||||
|
||||
`geom.GameMeshVertex` (`draw/geometry_gen.rs`) is the shared packed layout:
|
||||
|
||||
| field | packing | floats |
|
||||
|---|---|---|
|
||||
| position | 3 × f32, kept exact | 3 |
|
||||
| normal | octahedral, 2 × f16 in one lane | 1 |
|
||||
| uv | 2 × f16 in one lane | 1 |
|
||||
| colour | 4 × unorm8 in one lane | 1 |
|
||||
| **total** | | **6 floats / 24 B** |
|
||||
|
||||
Two gotchas worth keeping: the pod struct must use **flat `f32` fields, not
|
||||
`Vec3f`** — std140 pads a vec3 to 16 bytes and the Rust repr(C) size then
|
||||
fails the POD size assertion. And in the shader language `let` bindings are
|
||||
immutable and helper fns cannot be forward-referenced, so the octahedral
|
||||
decode uses a branchless `step(0,v)*2-1` for its sign rather than reassignment
|
||||
or a shared helper (the `sign()` builtin returns 0 at 0, which would collapse
|
||||
the fold on an axis-aligned normal).
|
||||
|
||||
Terrain still uses PbrVertex: it uploads once per terrain revision rather than
|
||||
per frame, so the saving does not justify re-verifying gamemaker's 257×257
|
||||
fixture. It is a mechanical follow-up if wanted.
|
||||
|
||||
## Rendering
|
||||
|
||||
One draw call per shape per pass, plus one per skinned character. Particles and
|
||||
shadows join the existing alpha batch, so **neither adds a draw call**.
|
||||
|
||||
| thing | desktop | Quest (est.) | why |
|
||||
|---|---|---|---|
|
||||
| entities | 2000 | 600 | instance packing is cheap; fill rate is not |
|
||||
| skinned characters | 8 | 2–3 | CPU skinning: ~3.7k verts each, re-uploaded every frame |
|
||||
| projected shadows (`shadow_budget`) | 24 | 8 | 0.6 µs for 24, ~2 µs for 64–128 — the CPU cost is trivial, the fill cost of large ground quads is not |
|
||||
| particles (`ParticleSystem::cap`) | 2000 | 500 | see below |
|
||||
|
||||
### Particle cost (measured, per frame, step + instance build)
|
||||
|
||||
| cap | live | step | instances | total |
|
||||
|---|---|---|---|---|
|
||||
| 500 | 500 | 0.8 µs | 0.7 µs | **1.5 µs** |
|
||||
| 1000 | 1000 | 3.1 µs | 2.1 µs | **5.2 µs** |
|
||||
| 2000 | 2000 | 3.1 µs | 2.8 µs | **5.9 µs** |
|
||||
| 4000 | 4000 | 11.5 µs | 14.5 µs | **26 µs** |
|
||||
|
||||
CPU cost stays negligible even at 4000. The real limit is **overdraw**: every
|
||||
particle is an alpha-blended quad, and a Quest fills pixels far more slowly
|
||||
than it runs this loop. Hence the 500 cap there — it is a fill-rate budget, not
|
||||
a CPU one.
|
||||
|
||||
### CPU light bake (bake.rs), measured release
|
||||
|
||||
| stage | cost | when it runs |
|
||||
|---|---|---|
|
||||
| AO (per static, 5 face samples × 8 rays) | **15 µs** | world edits only — sun-independent |
|
||||
| sun visibility (1 ray per static + per probe) | **34 µs** | world edits **and** whenever the sun swings past 0.03 rad |
|
||||
| probe lattice sky term | **61 µs** | world edits only |
|
||||
|
||||
Measured on the demo world (12 statics, 13 occluders, 605 probes). Debug
|
||||
builds are ~50× slower (5.7 ms for the probe pass) — measure in release.
|
||||
|
||||
The split matters: AO is the expensive half and does not depend on the sun, so
|
||||
a day/night cycle only pays the 34 µs sun pass. A ray that starts above the
|
||||
heightfield's highest point and travels upward skips the terrain march
|
||||
entirely, which is what keeps the probe pass in microseconds.
|
||||
|
||||
Bake output costs **zero** bandwidth and zero GPU: it is folded into the
|
||||
instance colours the renderer was already sending.
|
||||
|
||||
### Shadow tiers
|
||||
|
||||
Casters are ranked by camera distance. The nearest `shadow_budget` get a
|
||||
projected silhouette; everything else gets a blob. Both cost one instance, so
|
||||
the budget buys fidelity rather than draw calls. Rigid bodies and anything
|
||||
person-sized (≥ 0.5 units tall) count as heroes; smaller movers always get
|
||||
blobs.
|
||||
|
||||
## Setting the budgets
|
||||
|
||||
```rust
|
||||
renderer.set_shadow_budget(8); // standalone XR
|
||||
particles.set_cap(500); // standalone XR
|
||||
```
|
||||
|
||||
Lowering these on one device is safe: particles and shadows are tier-3 Local
|
||||
(game.md), so two devices in the same room may draw different numbers of them
|
||||
and the simulation cannot diverge — particles never touch the world RNG, which
|
||||
`particles_never_advance_the_world_rng` asserts.
|
||||
|
||||
## Rules of thumb for generated games
|
||||
|
||||
- A racing game with 4 cars, a track of ~200 static pieces and dust particles
|
||||
sits at a few percent of frame budget on desktop.
|
||||
- Prefer one emitter attached to a moving entity over per-frame bursts: an
|
||||
emitter costs one request, bursts cost one per call.
|
||||
- Characters are the expensive thing. Two or three on Quest, not eight.
|
||||
- Terrain above ~129 cells starts to matter for eval time, not draw time (the
|
||||
isolate's wall-clock budget is 64 ms and it is a hard bail, not a yield).
|
||||
|
||||
## step_world weight (measured 2026-08-03, release, aarch64)
|
||||
|
||||
Measured with `cargo run -p makepad-game-sim --release --example weigh`, which
|
||||
wraps the global allocator so the byte counts include everything the tick
|
||||
touches, not just what the harness allocates.
|
||||
|
||||
The tick used to clone two things per tick purely to dodge a borrow: the whole
|
||||
`Terrain` (its `heights: Vec<f32>` **and** `colors: Vec<Vec4f>`) and every
|
||||
static/kinematic `Entity` (208 bytes each). Terrain dominated — a 257² field is
|
||||
1.3 MB/tick, i.e. **79 MB/s of memcpy at 60 Hz on a world with seven entities
|
||||
in it**. Splitting the struct borrow removes the terrain copy entirely; the
|
||||
statics snapshot has to stay a copy (movers must sweep against *last* tick's
|
||||
kinematic poses — that ordering is load-bearing) but now copies a 48-byte
|
||||
`Solid` view instead of the full entity.
|
||||
|
||||
| scene | ms/tick before → after | B/tick before → after |
|
||||
|---|---|---|
|
||||
| demo (arcade) | 0.002 → 0.003 | 15,140 → 4,796 (−68%) |
|
||||
| demo + terrain 65 | 0.003 → 0.004 | 99,640 → 4,796 (−95%) |
|
||||
| racing-ish (129 terrain) | 0.007 → 0.002 (−71%) | 362,316 → 8,576 (−98%) |
|
||||
| terrain 129 only | 0.005 → 0.000 | 335,804 → 896 (−99.7%) |
|
||||
| terrain 257 only | 0.019 → 0.001 (−95%) | 1,323,964 → 896 (−99.93%) |
|
||||
| large (500 static) | 0.063 → 0.056 (−11%) | 591,386 → 82,382 (−86%) |
|
||||
| stress (2000 static) | 0.583 → 0.457 (−22%) | 2,353,936 → 327,812 (−86%) |
|
||||
|
||||
Allocations/tick fell from 6–14 to 3–11; the residual is the statics snapshot,
|
||||
the box3d reconcile and touch collection.
|
||||
|
||||
**Leak check**: `--soak` runs 10 simulated minutes (36,000 ticks) of a busy
|
||||
world with projectiles spawning and expiring throughout. RSS is flat at 3.8 MB
|
||||
from warmup to the end (+0.4% drift, 242 entities alive) — the tick path does
|
||||
not leak.
|
||||
|
||||
**Result-neutrality** is gated by `libs/game/sim/tests/mover_golden.rs`: the
|
||||
golden world-state hash is byte-identical before and after this optimisation
|
||||
(verified by reverting the source and re-running), and it covers the terrain,
|
||||
sweep, platform-carry, attach, projectile-lifetime and auto-face paths that
|
||||
`rigid_dynamics.rs` doesn't reach.
|
||||
|
||||
## Memory and binary, whole app (measured 2026-08-03)
|
||||
|
||||
| | value |
|
||||
|---|---|
|
||||
| sim core only, all 7 scenes incl. 2000-static stress | 13.4 MB RSS |
|
||||
| sim soak, busy world, steady state | 3.8 MB RSS |
|
||||
| `hello_world` (baseline makepad + widgets + headless) | 195 MB RSS, 13.5 MB binary |
|
||||
| `makepad-arcade` (headless) | 948 MB RSS, 25.1 MB binary |
|
||||
|
||||
The engine core is genuinely small; the weight is above it. Two findings worth
|
||||
acting on, both outside the sim/render/script crates:
|
||||
|
||||
1. **~750 MB of Arcade's RSS is not the sim** (13 MB) and not the framebuffer
|
||||
(unchanged when the headless size changes). It needs a profiler pass to
|
||||
attribute properly — candidates are the script isolates (each one
|
||||
re-evaluates the *entire* widgets DSL, `widget_async.rs:317`, and a game
|
||||
isolate needs none of those prototypes), the glyph/texture atlases, and the
|
||||
offscreen pass chain.
|
||||
2. **The voice stack links unconditionally.** `makepad-converse` is a plain
|
||||
dependency of `apps/arcade`, not feature-gated, so Kokoro TTS is compiled in
|
||||
and initialised (`tts: backend Kokoro` appears in every boot log) even at the
|
||||
`chatbox` tier where it can never be used. `voice`/`local-llm` gate the
|
||||
*models*, not the crate. Gating this is the obvious binary-size win for a
|
||||
Quest build; the binary carries whisper/kokoro/silero symbols today.
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
[package]
|
||||
name = "makepad-arcade"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Makepad Arcade — networked AI game sandbox (see game.md)"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
[lib]
|
||||
name = "makepad_arcade"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "makepad-arcade"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
makepad-widgets = { path = "../../widgets", version = "2.0.0" }
|
||||
makepad-game-math = { path = "../../libs/game/math" }
|
||||
makepad-game-sim = { path = "../../libs/game/sim" }
|
||||
makepad-game-session = { path = "../../libs/game/session" }
|
||||
makepad-game-blocks = { path = "../../libs/game/blocks" }
|
||||
makepad-game-render = { path = "../../libs/game/render" }
|
||||
makepad-game-assets = { path = "../../libs/game/assets" }
|
||||
makepad-game-gen = { path = "../../libs/game/gen" }
|
||||
makepad-game-script = { path = "../../libs/game/script" }
|
||||
makepad-game-net = { path = "../../libs/game/net" }
|
||||
makepad-game-coedit = { path = "../../libs/game/coedit" }
|
||||
makepad-game-pkg = { path = "../../libs/game/pkg" }
|
||||
makepad-ai = { path = "../../libs/makepad_ai" }
|
||||
# default-features off drops speech synthesis (~10 MB of binary, ~327 MB
|
||||
# resident). Re-enabled by `voice` below: a device that can capture speech is
|
||||
# the one that has any use for producing it.
|
||||
makepad-converse = { path = "../../libs/converse", default-features = false }
|
||||
|
||||
[features]
|
||||
# Mic capture + Whisper, via the widgets VoiceWave. Separate from `local-llm`
|
||||
# because transcription and the local judge are different models: a device can
|
||||
# have one without the other.
|
||||
voice = ["makepad-widgets/voice", "makepad-converse/tts"]
|
||||
# Local model compute (Whisper/Silero/Qwen). Off by default so Quest/mobile
|
||||
# builds — which have no backend — never try to link it. Implies `voice`:
|
||||
# the judge only has something to judge once there is a mic.
|
||||
local-llm = ["makepad-converse/local-llm", "voice"]
|
||||
|
||||
[dev-dependencies]
|
||||
makepad-test = { path = "../../libs/makepad_test", version = "0.1.0" }
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
# Makepad Arcade
|
||||
|
||||
Networked AI game sandbox (plan: repo-root game.md). Run with:
|
||||
|
||||
cargo run -p makepad-arcade
|
||||
|
||||
## Stock assets
|
||||
|
||||
Arcade ships with a searchable library of CC0 models and sounds. The binaries
|
||||
are **not** vendored in git — fetch them once:
|
||||
|
||||
./apps/arcade/download_assets.sh # core packs, ~75 MB
|
||||
./apps/arcade/download_assets.sh --packs=all # everything, ~185 MB
|
||||
./apps/arcade/download_assets.sh --list # show packs, download nothing
|
||||
|
||||
The default fetches a **core** set (~1900 models) so a fresh clone isn't forced
|
||||
to pull the lot; `--packs=all` gets the full Kenney 3D catalogue (**4669 models
|
||||
across 47 packs**), and `--packs=nature-kit,car-kit` picks specific ones. Add
|
||||
the 556 sounds and the nine rigged KayKit characters and the full library is
|
||||
~5000 searchable assets — of which **36 are rigged and animated**.
|
||||
|
||||
Everything is gitignored and pinned — starter kits to exact GitHub commits, the
|
||||
rest to content-hashed kenney.nl URLs — and every file is sha256-verified, so a
|
||||
moved or tampered upstream fails loudly instead of silently changing the
|
||||
library. Downloads are sequential with a delay: this is someone else's
|
||||
bandwidth.
|
||||
|
||||
### Mirroring
|
||||
|
||||
`resources/MIRROR.toml` lists every pack with its canonical URL, sha256, size
|
||||
and model count. Because these assets are CC0, anyone may re-host them, and
|
||||
that file is what makes a mirror reproducible and verifiable. Point at one with:
|
||||
|
||||
ARCADE_ASSET_MIRROR=https://your.host/assets ./apps/arcade/download_assets.sh
|
||||
./apps/arcade/download_assets.sh --mirror=https://your.host/assets
|
||||
|
||||
A mirror is expected to serve `<base>/<slug>.zip`. **The sha256 from
|
||||
MIRROR.toml is verified identically whichever host served the bytes** — a
|
||||
mirror is never trusted more than upstream; the hash is the authority.
|
||||
|
||||
Everything degrades gracefully without them: the demo runs with primitive
|
||||
shapes, and tests that need real assets skip with a hint.
|
||||
|
||||
**Audio is Ogg Vorbis.** Every Kenney audio pack ships `.ogg` only — no WAV
|
||||
variant exists upstream — and this tree has no vorbis decoder. The sounds are
|
||||
therefore indexed and searchable by the AI but **not yet playable**; adding a
|
||||
decoder (or running `download_assets.sh --transcode`, which converts to WAV
|
||||
when ffmpeg is installed) is what closes that gap.
|
||||
|
||||
### Finding assets
|
||||
|
||||
The library is queried by *description*, never by filename, via
|
||||
`makepad-game-assets`. The agent gets a `find_model` tool and a one-paragraph
|
||||
summary — it searches, it never receives the catalogue (5000 entries would not
|
||||
fit in a prompt, and the summary stays ~480 characters however large the
|
||||
library grows). Ids look like `kenney/racing/vehicle-truck-yellow` and are
|
||||
stable across re-downloads, because generated game code writes them.
|
||||
|
||||
Findability is built in three layers so it scales past 4000 models:
|
||||
per-pack theme curation (~55 rows, giving every model its setting), filename
|
||||
token parsing (free, and Kenney's names are systematic), and a hand-curated
|
||||
query-time synonym table (~240 rows) that applies to the whole catalogue at
|
||||
once. Item-level curation is spent only on the few hundred most-requested
|
||||
things. At the full 4,999-entry catalogue: index build ~120 ms, a search
|
||||
~0.2 ms, ~2.1 MB of heap (release).
|
||||
|
||||
Query-side stemming means an inflected request still reaches a base-form
|
||||
alias ("smashing" → the `smash` alias), and when two entries tie on score the
|
||||
kind the query implies wins — an unqualified noun like "spaceship" is an
|
||||
object request, while "metal clang" or "win music" wants something audible.
|
||||
|
||||
## Credits
|
||||
|
||||
Every asset here is CC0 (public domain). Attribution isn't required by the
|
||||
licence — we credit anyway, because these libraries exist because someone chose
|
||||
to give them away.
|
||||
|
||||
- **Kenney** — <https://kenney.nl/assets> — 47 model packs totalling ~4,670
|
||||
models (nature, city, castle, space, food, furniture, vehicles, dungeons,
|
||||
characters and more), the five starter kits, and all seven sound packs
|
||||
(impact, interface, sci-fi, music jingles, UI, RPG and digital audio). Thank
|
||||
you for the extraordinary breadth of free, consistent, genuinely usable game
|
||||
assets — this library is most of what Arcade can build with.
|
||||
- **KayKit / Kay Lousberg** — <https://kaylousberg.itch.io/> — nine rigged and
|
||||
animated characters: five adventurers (Knight, Barbarian, Mage, Rogue,
|
||||
Rogue_Hooded) and four skeletons (Warrior, Mage, Rogue, Minion). All nine
|
||||
share one 41-joint skeleton, so an animation authored for any of them plays
|
||||
on every one — the adventurers ship 76 clips and the skeletons those same 76
|
||||
plus 19 undead extras. Thank you for giving away rigs and animation sets,
|
||||
which are the expensive part.
|
||||
|
||||
### Rigged characters
|
||||
|
||||
`find_cast` lists the animated casts. A cast is a set of characters sharing one
|
||||
skeleton, so any state works on any member and a part can be recast without
|
||||
touching animation code:
|
||||
|
||||
| rig | members | clips | notable states |
|
||||
|-----|---------|-------|----------------|
|
||||
| 41 joints (KayKit) | 9 | 76–95 | block, dodge, hurt, dance, sleep, carry |
|
||||
| 7 joints (Kenney mini) | 22 | 25–32 | walk, run, jump, attack, sit, wave |
|
||||
| 6 joints (Kenney platformer) | 5 | 25 | walk, run, jump, attack |
|
||||
|
||||
Pick members from ONE cast for a scene, and use different members so a crowd
|
||||
isn't clones — the same rule that applies to props.
|
||||
|
||||
Machine-readable attribution lives in `resources/CREDITS.toml` so a published
|
||||
game package can carry credit with it.
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
fn main() {
|
||||
// Mirror platform/build.rs: MAKEPAD=headless builds get cfg(headless), so
|
||||
// the view can stub the gamepad path (the headless platform backend has no
|
||||
// game-input implementation). Without this arcade cannot build headless,
|
||||
// and the render-to-PNG test path goes with it.
|
||||
println!("cargo:rustc-check-cfg=cfg(headless)");
|
||||
println!("cargo:rerun-if-env-changed=MAKEPAD");
|
||||
if let Ok(configs) = std::env::var("MAKEPAD") {
|
||||
if configs.split(['+', ',']).any(|c| c == "headless") {
|
||||
println!("cargo:rustc-cfg=headless");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,448 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Fetch the CC0 model packs Makepad Arcade uses for its stock asset library.
|
||||
# Nothing here is vendored in git (see resources/*/.gitignore) — run this once
|
||||
# after checkout. Every file is pinned to an exact upstream commit and verified
|
||||
# by sha256, so a moved or tampered upstream fails loudly instead of silently
|
||||
# changing the library.
|
||||
#
|
||||
# ./download_assets.sh fetch everything (idempotent)
|
||||
# ./download_assets.sh --list show packs, counts and licences, download nothing
|
||||
#
|
||||
# ----------------------------------------------------------------------------
|
||||
# CREDITS — all assets below are CC0 (public domain). Attribution is not
|
||||
# required by the licence; we credit anyway, because these libraries exist
|
||||
# because someone chose to give them away.
|
||||
#
|
||||
# Kenney — https://kenney.nl/assets
|
||||
# Vehicles, city, arena, platformer and FPS kits. Thank you @KenneyNL.
|
||||
#
|
||||
# KayKit / Kay Lousberg — https://kaylousberg.itch.io/
|
||||
# Rigged + animated characters: 5 adventurers (Knight, Barbarian, Mage,
|
||||
# Rogue, Rogue_Hooded) and 4 skeletons (Warrior, Mage, Rogue, Minion).
|
||||
# All nine share one 41-joint rig. CC0 verified in each pack's LICENSE.txt
|
||||
# at the pinned commit. Thank you Kay.
|
||||
#
|
||||
# NOTE ON AUDIO FORMAT: every Kenney audio pack ships Ogg Vorbis only (no WAV
|
||||
# variant exists upstream — checked all seven packs). This tree has no vorbis
|
||||
# decoder, so the sounds are downloaded and indexed (searchable by the AI) but
|
||||
# not yet playable; a decoder, or an opt-in local transcode, is the missing
|
||||
# piece. `--transcode` below does the latter when ffmpeg is installed.
|
||||
# ----------------------------------------------------------------------------
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/resources"
|
||||
MODELS="$ROOT/models/kenney"
|
||||
CHARS="$ROOT/characters"
|
||||
AUDIO="$ROOT/audio/kenney"
|
||||
MANIFEST="$ROOT/MIRROR.toml"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3D model packs (the full Kenney 3D catalogue) are described by
|
||||
# resources/MIRROR.toml rather than inlined here: 50 packs is too many to keep
|
||||
# readable in shell, and that file is also what makes an independent mirror
|
||||
# reproducible.
|
||||
#
|
||||
# Source selection:
|
||||
# ARCADE_ASSET_MIRROR=https://our.host/path or --mirror=<base>
|
||||
# A mirror is expected to serve <base>/<slug>.zip. Whichever host serves the
|
||||
# bytes, the sha256 from MIRROR.toml is verified identically — a mirror is
|
||||
# never trusted more than upstream.
|
||||
#
|
||||
# Selection:
|
||||
# (default) the "core" packs — ~1900 models, ~60 MB
|
||||
# --packs=all every usable pack — 4669 models, ~166 MB
|
||||
# --packs=a,b,c named packs
|
||||
# ---------------------------------------------------------------------------
|
||||
MIRROR="${ARCADE_ASSET_MIRROR:-}"
|
||||
PACKSEL="core"
|
||||
fetched=0
|
||||
cached=0
|
||||
|
||||
|
||||
# Kenney audio packs, pinned by content-hashed download URL.
|
||||
# Each entry: <pack>|<url-hash-segment>|<sha256 of zip>|<sound count>
|
||||
KENNEY_AUDIO=(
|
||||
"impact-sounds|87b4ddecda-1677589768|029d734af1582474edf3a694d1b0cebc97c1c152f2f39fa34d4c2bafc5de77f8|130"
|
||||
"interface-sounds|fa43c1dd4d-1677589452|f2193d072726d6758a5f7871b2dcc54dcce0d5c35c6f0a62f92549b327c81232|100"
|
||||
"sci-fi-sounds|6b296f9ecf-1677589334|119340f351a5098ad814f78719438c0da355a9ce8a4c8a3af6a8d48aa3d49e04|73"
|
||||
"music-jingles|f37e530b9e-1677590399|b729ba57959bd58793d2c5cafa348aaf2655d354f3da35ec4729e03ec77197b8|86"
|
||||
"ui-audio|490d233f68-1677590494|946fc23a63d535d693eb31b2eabb80c8c28d6351e2186b344ceb71b2cb1d5eb6|52"
|
||||
"rpg-audio|8e99002d76-1677590336|6dbeaf8544da958d8f2adcb4a4a4b76c1ade34a05f8ab9edccd327da7375f38b|52"
|
||||
"digital-audio|216eac4753-1677590265|24e6ce28b76a6d8c89cff4d331e0965ff5c3de8a73c612028e9d363cc64e4f06|63"
|
||||
)
|
||||
|
||||
# Kenney starter kits, pinned. Each entry: <pack> <repo> <commit> <subdir>
|
||||
KENNEY_PACKS=(
|
||||
"arena|Starter-Kit-Basic-Scene|a6927e66ff8dd8e173660ce4825abe773c65f683|sample/Mini Arena/Models/GLB format"
|
||||
"city|Starter-Kit-City-Builder|4535092b740b378b700efd9df9e27a631815b84a|models"
|
||||
"fps|Starter-Kit-FPS|185fd2326d74a5cf858cffc616f87cf9696f9cc0|models"
|
||||
"platformer|Starter-Kit-3D-Platformer|3fa8a04b1c01ab23db43123d4ce814a34c3fc7f0|models"
|
||||
"racing|Starter-Kit-Racing|f5241ebdf00c25bc951bf4fdb7950bb1b78b4bcc|models"
|
||||
)
|
||||
|
||||
# KayKit character packs, pinned. CC0 verified in each repo's LICENSE.txt at
|
||||
# the pinned commit ("License: (Creative Commons Zero, CC0)").
|
||||
#
|
||||
# ALL NINE CHARACTERS SHARE ONE RIG — 41 joints, identical names in identical
|
||||
# order, verified by hashing the joint-name list of every file (both packs
|
||||
# produce the same digest). So one animation-driving code path serves the whole
|
||||
# cast, and a clip authored against any of them plays on all of them.
|
||||
# Adventurers ship 76 clips; skeletons ship those same 76 plus 19 undead extras
|
||||
# (awaken, resurrect, spawn, taunt) — a strict superset, no clip is lost.
|
||||
#
|
||||
# The GLBs EMBED their texture (image/png in a bufferView), so unlike the Kenney
|
||||
# packs there is no external URI to resolve and no path to get wrong. The
|
||||
# sidecar *_texture.png files are fetched anyway because the skin loader
|
||||
# deliberately ignores materials ("the caller binds its own texture"), so the
|
||||
# app supplies the atlas itself.
|
||||
KAYKIT_ADV_COMMIT="672074b73ba276876a19e8816ecdc5241817ab47"
|
||||
KAYKIT_ADV_BASE="https://raw.githubusercontent.com/KayKit-Game-Assets/KayKit-Character-Pack-Adventures-1.0/$KAYKIT_ADV_COMMIT"
|
||||
KAYKIT_ADV_DIR="addons/kaykit_character_pack_adventures/Characters/gltf"
|
||||
KAYKIT_SKEL_COMMIT="15b62b9bad122f72926c10fb14d622c73819fa54"
|
||||
KAYKIT_SKEL_BASE="https://raw.githubusercontent.com/KayKit-Game-Assets/KayKit-Character-Pack-Skeletons-1.0/$KAYKIT_SKEL_COMMIT"
|
||||
KAYKIT_SKEL_DIR="addons/kaykit_character_pack_skeletons/Characters/gltf"
|
||||
|
||||
# <local-name>|<pack>|<upstream file>|<sha256>|<texture local-name>
|
||||
# Rogue_Hooded has no atlas of its own upstream — it re-skins rogue_texture.
|
||||
KAYKIT_CHARS=(
|
||||
"knight|adv|Knight.glb|60428e3abc09ba83e595d256e3af8c5c976b46cdae599f0802fc82b4a3445168|knight_texture.png"
|
||||
"barbarian|adv|Barbarian.glb|cefc311a0e10c7858b6141f5ada7e33268727564fb8ac1347aab97d000669cc6|barbarian_texture.png"
|
||||
"mage|adv|Mage.glb|cf898585da33fab50c724d31605fb931eb2912e6d2280092141e98ca81ad507d|mage_texture.png"
|
||||
"rogue|adv|Rogue.glb|e825437cd4d2ee9c1960b517a74a69101e33eb409ae7fa8cedc7134a998fbb7d|rogue_texture.png"
|
||||
"rogue_hooded|adv|Rogue_Hooded.glb|93e6e25213009952276d9cf34f5d96a243767334c66f280db0433ddfabb91545|rogue_texture.png"
|
||||
"skeleton_warrior|skel|Skeleton_Warrior.glb|178b6fda810b814c250d8a2010c24dfd9b458b9006dd323353e620b7ff118bbe|skeleton_texture.png"
|
||||
"skeleton_mage|skel|Skeleton_Mage.glb|e05b0f5cfa395271c9f75fd07c0a0613c56f401ece4ab644e080001a79971075|skeleton_texture.png"
|
||||
"skeleton_rogue|skel|Skeleton_Rogue.glb|4003f2b77891bb56f7e0de7d555abcb497aebbcaee35b614211f16275f9ccae3|skeleton_texture.png"
|
||||
"skeleton_minion|skel|Skeleton_Minion.glb|6ffc003f895bed0b074791e0e490846210a2e2f8fc7da300aba53cc185f95968|skeleton_texture.png"
|
||||
)
|
||||
|
||||
# <local-name>|<pack>|<upstream file>|<sha256>
|
||||
KAYKIT_TEXTURES=(
|
||||
"knight_texture.png|adv|knight_texture.png|5d250ccc5da020e6126bfa3839f83bd9a465a951ed223e4d13c08b1925e154d4"
|
||||
"barbarian_texture.png|adv|barbarian_texture.png|7329b2ff9709e8d54c886d5ef49c08bd42b12be3bbb2facb1488a6b48b1e8a80"
|
||||
"mage_texture.png|adv|mage_texture.png|ea49f094b960402635fe51db9f1864960c97271b17f2e3554e5aca1b2bbba144"
|
||||
"rogue_texture.png|adv|rogue_texture.png|a4032e877c3b91939f5cdbb630349c1998fdbc3211bbd587c111125500fe4cc5"
|
||||
"skeleton_texture.png|skel|skeleton_texture.png|15741a25c53e04fa9bf3beac3bc0de442359404b1ff9be863b892cb551ad3657"
|
||||
)
|
||||
|
||||
kaykit_base() { case "$1" in adv) echo "$KAYKIT_ADV_BASE/$KAYKIT_ADV_DIR" ;; skel) echo "$KAYKIT_SKEL_BASE/$KAYKIT_SKEL_DIR" ;; esac; }
|
||||
|
||||
# Per-pack file manifest: "<filename> <sha256>" lines.
|
||||
pack_files() {
|
||||
case "$1" in
|
||||
arena)
|
||||
echo "banner.glb 96e1cf7924fcc871be0e7e182c9b918de381b9783ca9e96922f8db62af384056"
|
||||
echo "block.glb 656e07f0a3e7d0620892aca6fc40683b4266ff6b9672ecf89f3553e5ed85a1fb"
|
||||
echo "border-corner.glb 120ce330ca50fe50e51bf693eadb957abaca972c42d573a07d8e8e404560bc3c"
|
||||
echo "border-straight.glb 76ef69e8858f3d17c652d92033a553db58296135a5810d1bd5e54f47b5aa40c4"
|
||||
echo "bricks.glb fd1d2c033c7db8b7352ac1becdab447d09cabcc11c73adf9bb6790f9680eaa86"
|
||||
echo "character-soldier.glb b0410b4a34068e56ac1af3a2571f4497f84f1e481cced895b618d1fe8136529c"
|
||||
echo "column-damaged.glb 442a6856e7e2d042c9e3af155dabf6d394f7ba91b2e271ee6392da22555a072c"
|
||||
echo "column.glb 5c815b5fd43d613a21afddaaa90dc2f90b80e31f03dbdde98921cb7424e836c0"
|
||||
echo "floor-detail.glb 804224caf3008fcdbd57d7ccc2c4734320c58ca880895699307670d2443e324b"
|
||||
echo "floor.glb 15512c5ca6e9d3eb60efd237bbbf5b8010fdea63e0d8e6623f72b59de20279ab"
|
||||
echo "stairs-corner-inner.glb 2e232cecda1d633acc1b78707ee50089fd40a167293c173536d61ef064697a5c"
|
||||
echo "stairs-corner.glb af8eb87dc8826e184f585ebccf0592c909da7370559cd840b218593f4b1c8a38"
|
||||
echo "stairs.glb 54a3f12c720f9cb83027b5429e330d7c50578f70771fd1bf9a2e521fe131ca6f"
|
||||
echo "statue.glb 5ba8ecba963f6b3ec974809e2bf0b615da7e2701cd0efc6a3c0e946efb147397"
|
||||
echo "tree.glb f8632c9758a78f60aa4ca68bf0e5ac972770167a5608e179293a12552d8fc2ef"
|
||||
echo "trophy.glb 253f6d5f321ab20f8f505dfe35b7404c98d2d492728e6df5f69dd29e936b8be5"
|
||||
echo "wall-corner.glb 4dc0e770106a122885f41be0bb4024466a7f58bc29a13f07b6600c4babbeb777"
|
||||
echo "wall-gate.glb f70a86344f202b3a8057e3a6ee9cd632ba51a5c16795cfde34cd10e906f14697"
|
||||
echo "wall.glb b05ab8645136cc26e761ae2189f660a585dca88c11721771bca3fb4c1f7a807f"
|
||||
echo "weapon-rack.glb bb95da3746098e36f3d87a1e1036ca70ead7fe95860191871e02f7c58ff1efe5"
|
||||
echo "weapon-spear.glb 9634e12abce01a07d40d5156454bfa1123eba72af6449fccf577980e331adbeb"
|
||||
echo "weapon-sword.glb e4d2219d954148dd8b04fccc7602726c34eafad58c0dc5a45268155fcf6cc8c4"
|
||||
;;
|
||||
city)
|
||||
echo "building-garage.glb 7373b558fc9b1e27e60b60ca1ce53000af7e132d7304eeb6d49b47528a3b6806"
|
||||
echo "building-small-a.glb 22ce989013bd16b1732e81798e343cf85f947e92b6c93825b18131feded48e07"
|
||||
echo "building-small-b.glb 0bc8459045975158524753d6b648ff77e5f91fe8c033bc4886930b6d55543a2b"
|
||||
echo "building-small-c.glb 3ea0f46fbed4e0acba7fda365cd968b3f23a6e02b9fee79dd54c99c42ffadf2f"
|
||||
echo "building-small-d.glb 0ab476fbd7956cea52e590a8e68e351ce2f6fc04df158aa3f5a76b7744bf37a0"
|
||||
echo "grass-trees-tall.glb d23e3b722453236bf6f4f8922b7bf64f81a7f3f804848a89c4b100ccbf13abef"
|
||||
echo "grass-trees.glb cb06d03cc1c64ca7e7692c0aef3628d8e7cd4afee69093025f9ddd668653a4ce"
|
||||
echo "grass.glb 3e3ec91132ad8519967aa2e4c0bedbab2aeed39b5714d66068bb75648ae73a52"
|
||||
echo "pavement-fountain.glb ea2996089e90a79ba13d764106d374486bb8137666cf1cdbaebd1927ef365185"
|
||||
echo "pavement.glb 60152776325d436761a13eb0c2ec9368b393bc82cf754b63cab810d79639ade9"
|
||||
echo "road-corner.glb 85aec60d66c5084bb658274a1468139a5def1e9e9575c01286ed6c2069916789"
|
||||
echo "road-intersection.glb 0212ffe9945b933535503372855c54dee20d54ddfc07e1c72963e67558a14183"
|
||||
echo "road-split.glb 31bf582953db8315edfade4ba49d81145a78bda9757b6442dc417e14cc84b5ff"
|
||||
echo "road-straight-lightposts.glb fc5340621fefdd43ab0bbf4bdaa41de9822dc545ce787fbfd92b75ce85d51e62"
|
||||
echo "road-straight.glb 008a6305de778439d1a99be78a3e0945c72a9bc9cc0bade1b51a666d5db01d0b"
|
||||
;;
|
||||
fps)
|
||||
echo "blaster-repeater.glb e76220e5ad3877d879e70fe21bd5bc76a5988b8682aa5f7976739e71caf70e05"
|
||||
echo "blaster.glb 9c2110d94c1bd7e01bfe6827a3d87e14962a1f721d8b300c67bdd36f492140a1"
|
||||
echo "cloud.glb 4c667239958ae8950b6bc2f55bdb59814b24fc9f6d8531eb09411150e09c87f2"
|
||||
echo "enemy-flying.glb e4868f6d8cac2a8f728430229789206841ca64727d94de2d808b468e4f182f8c"
|
||||
echo "grass-small.glb 8dfe00761fa833daab3192148b9b3d573f2a10aa2eff2c01a8a386dd304e7fda"
|
||||
echo "grass.glb 8e11283ca11894860a4dcb8f523165eda5de39f68fda43d892efc3a3d3eb567e"
|
||||
echo "platform-large-grass.glb 19ee4b9e745f757d87d584094433e3f4d86be958bcd7eeba586874f088d2697b"
|
||||
echo "platform.glb 6eac72ffbda64b4414833eb49d3b160fddf169c2caccca237a07e15a20731764"
|
||||
echo "wall-high.glb 946a0f875bd515323afd883553729a29ac764b405e382bf9a30d6527dac74589"
|
||||
echo "wall-low.glb c42c89dead3835fde97d1ede232bf1a69d297cd41e1d6bfe7e25c87b233a2ec0"
|
||||
;;
|
||||
platformer)
|
||||
echo "block-coin.glb ebe3a7be051566513039e6ba7d83d976fb3bf3e363fc8ee0633bdb872bb2d501"
|
||||
echo "brick-particle.glb 981a84073e1dfa745a93db4ba550210049a144a4ac2c6cd333f289ff95630a72"
|
||||
echo "brick.glb c9011dd30254c7a5dfdb29c61c22b6b3b4ad82186a096b90b824ab0b8dd58db8"
|
||||
echo "character.glb 7112f6a08400914f9da546f3e6029e947cc9eab2b4a6da5eb99776111289efb1"
|
||||
echo "cloud.glb 09c6f071d3a9ab64993248ee5c8d50debcd9c6cc4369236a01155ff7ff87f30a"
|
||||
echo "coin.glb a3fb8f779a6af1cd75e5a02da77a87546ca8a59f6fe52f79f25179c4a68fd0e5"
|
||||
echo "dust.glb 2f2ce449aca7791829bf2d93a7af534549f0bad1ee904d1a00df8d5329fc31dc"
|
||||
echo "flag.glb 0ba84ff43f0fea0a9f0b080cfa1be54ebda36c8c35174d1ac2553ae6da9f65ae"
|
||||
echo "grass-small.glb 4b94e8dd9cbdc3eedff99cbf614fe828f0c2342d21a7d0edb125b129d0422d51"
|
||||
echo "grass.glb acb29df8a75b08e985e27a0ed8170b8f3d135abd24ccf5aa4d631cb2cd15cfda"
|
||||
echo "platform-falling.glb d6dc9baba80af659e6d0fffc435bd1f08b1a7945942ccbb85dd9bb103b7a574d"
|
||||
echo "platform-grass-large-round.glb 73c2b66eca6a36df6b5c25bb2c0594cf870066627275993a3a7857acc5a844bc"
|
||||
echo "platform-large.glb d01f4eae24c895dfe467ef71c2ea510ca6bd6d8462f319741537936eae0b3f65"
|
||||
echo "platform-medium.glb 64f81c8bd8bf07cc450dfcb25d18eedcc122d690028cca098bcdd48b00a02e46"
|
||||
echo "platform.glb 78c5ed4da30c5a97f0747a54248bc9de05ba1b3fb6b94be527dd48599f5ef44c"
|
||||
;;
|
||||
racing)
|
||||
echo "decoration-empty.glb 3815b26a5274173934d37cf605e320450efad4e0034040868b6aea761cfd74bc"
|
||||
echo "decoration-forest.glb 664a53f0f709fef9096af3bbfb1aa76536527a616b05170c0cf4e27e33358a00"
|
||||
echo "decoration-tents.glb 19dbf2a778ad75f95c7d61f12866ef7174ab69cad169d3749c5400f5e14db8a3"
|
||||
echo "track-bump.glb 6db020edc53532ebc0971c9d4ad9afb11c4de67c510649ca843db4616514f43c"
|
||||
echo "track-corner.glb 0ffb3d83b60456fc5a2962447111a1a64bfe943f4ad95462e1edba107be81c80"
|
||||
echo "track-finish.glb 2fec3b681658d6e77e20c4342d1e3ceeaab3e8d2fbf340942cde25cc2b21975b"
|
||||
echo "track-straight.glb 2d8080df1fe27e39981480809f36eba7f239e814f96bff61bb4c99793221b701"
|
||||
echo "track-tents.glb eaf68bbb44e362e291b71d13015fbafc4ce22a9102bda029614972d6cc843c7f"
|
||||
echo "vehicle-motorcycle.glb c97911b8dbc2d5dd9d1961b46eb4d1f132b67a6c5a84f45593fea44e0595dd92"
|
||||
echo "vehicle-truck-green.glb df362d027a09b19395be0e96abd7cc2dc54608dfb920b592e64357b589f9fcd6"
|
||||
echo "vehicle-truck-purple.glb 03e32f5abcbbd03da3591f42fdaae2cf7d27ed379df7ce0d1f233bea02998565"
|
||||
echo "vehicle-truck-red.glb eca99bd9ab0a2b02125f915e65d1ec8f1c5a93be7c6d4840efdac6633f47772c"
|
||||
echo "vehicle-truck-yellow.glb 1ebd83174eab6d2fdf69eb5d8e32bd06a23a86d9edc4fe915a4160430a7c36a5"
|
||||
;;
|
||||
*)
|
||||
echo "unknown pack: $1" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
pack_count() { pack_files "$1" | wc -l | tr -d ' '; }
|
||||
|
||||
# Read MIRROR.toml into parallel arrays: slug, url, sha, models, core, usable.
|
||||
manifest_rows() {
|
||||
awk '
|
||||
/^\[\[pack\]\]/ { slug=url=sha=""; models=0; core="false"; usable="false"; next }
|
||||
/^slug =/ { gsub(/.*= "|"/, ""); slug=$0; next }
|
||||
/^url =/ { gsub(/.*= "|"/, ""); url=$0; next }
|
||||
/^sha256 =/ { gsub(/.*= "|"/, ""); sha=$0; next }
|
||||
/^models =/ { gsub(/[^0-9]/, ""); models=$0; next }
|
||||
/^core =/ { gsub(/.*= /, ""); core=$0; next }
|
||||
/^usable =/ { gsub(/.*= /, ""); usable=$0
|
||||
if (slug != "") print slug "|" url "|" sha "|" models "|" core "|" usable
|
||||
next }
|
||||
' "$MANIFEST"
|
||||
}
|
||||
|
||||
want_pack() { # <slug> <core>
|
||||
case "$PACKSEL" in
|
||||
all) return 0 ;;
|
||||
core) [[ "$2" == "true" ]] ;;
|
||||
*) [[ ",$PACKSEL," == *",$1,"* ]] ;;
|
||||
esac
|
||||
}
|
||||
|
||||
if [[ "${1:-}" == "--list" ]]; then
|
||||
echo "Makepad Arcade stock asset library"
|
||||
echo
|
||||
printf '%-12s %-6s %-10s %s\n' "PACK" "FILES" "LICENCE" "SOURCE"
|
||||
for entry in "${KENNEY_PACKS[@]}"; do
|
||||
IFS='|' read -r pack repo _commit _dir <<<"$entry"
|
||||
printf '%-12s %-6s %-10s %s\n' "$pack" "$(pack_count "$pack")" "CC0-1.0" "kenney.nl (KenneyNL/$repo)"
|
||||
done
|
||||
printf '%-12s %-6s %-10s %s\n' "characters" "${#KAYKIT_CHARS[@]}" "CC0-1.0" \
|
||||
"kaylousberg.com (KayKit Adventurers + Skeletons, one shared rig)"
|
||||
|
||||
for entry in "${KENNEY_AUDIO[@]}"; do
|
||||
IFS='|' read -r pack _hash _sha count <<<"$entry"
|
||||
printf '%-12s %-6s %-10s %s\n' "$pack" "$count" "CC0-1.0" "kenney.nl (ogg)"
|
||||
done
|
||||
if [[ -f "$MANIFEST" ]]; then
|
||||
echo
|
||||
echo "3D packs (from MIRROR.toml; * = in the default core set):"
|
||||
tm=0; tp=0
|
||||
while IFS='|' read -r slug _url _sha models core usable; do
|
||||
[[ -z "$slug" ]] && continue
|
||||
mark=" "; [[ "$core" == "true" ]] && mark="*"
|
||||
note=""; [[ "$usable" == "true" ]] || note=" (FBX only — not fetched)"
|
||||
printf '%s %-34s %5s models%s\n' "$mark" "$slug" "$models" "$note"
|
||||
[[ "$usable" == "true" ]] || continue
|
||||
tm=$((tm + models)); tp=$((tp + 1))
|
||||
done < <(manifest_rows)
|
||||
echo " ${tp} usable packs, ${tm} models total"
|
||||
fi
|
||||
echo
|
||||
echo "Run without --list to download. Files land in resources/models/kenney/<pack>/,"
|
||||
echo "resources/audio/kenney/<pack>/ and resources/characters/ — all gitignored."
|
||||
echo
|
||||
echo "Audio is Ogg Vorbis (Kenney ships no WAV). This tree has no vorbis decoder,"
|
||||
echo "so sounds index and search but do not play yet; --transcode converts them to"
|
||||
echo "WAV with ffmpeg if you have it installed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
TRANSCODE=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--transcode) TRANSCODE=1 ;;
|
||||
--packs=*) PACKSEL="${arg#--packs=}" ;;
|
||||
--mirror=*) MIRROR="${arg#--mirror=}" ;;
|
||||
--list) : ;;
|
||||
*) echo "unknown option: $arg" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
|
||||
|
||||
fetch() { # <url> <dest> <sha256> <label>
|
||||
local url="$1" dest="$2" sha="$3" label="$4"
|
||||
if [[ -f "$dest" ]] && echo "$sha $dest" | shasum -a 256 -c - >/dev/null 2>&1; then
|
||||
cached=$((cached + 1))
|
||||
return 0
|
||||
fi
|
||||
if ! curl -sSfL "$url" -o "$dest.tmp"; then
|
||||
echo "ERROR: download failed: $label" >&2
|
||||
echo " $url" >&2
|
||||
rm -f "$dest.tmp"
|
||||
exit 1
|
||||
fi
|
||||
if ! echo "$sha $dest.tmp" | shasum -a 256 -c - >/dev/null 2>&1; then
|
||||
echo "ERROR: sha256 mismatch for $label (upstream changed or download corrupted)" >&2
|
||||
echo " expected: $sha" >&2
|
||||
echo " got: $(shasum -a 256 "$dest.tmp" | awk '{print $1}')" >&2
|
||||
rm -f "$dest.tmp"
|
||||
exit 1
|
||||
fi
|
||||
mv "$dest.tmp" "$dest"
|
||||
fetched=$((fetched + 1))
|
||||
}
|
||||
|
||||
# URL-encode spaces only; upstream paths use no other unsafe characters.
|
||||
urlenc() { printf '%s' "$1" | sed 's/ /%20/g'; }
|
||||
|
||||
for entry in "${KENNEY_PACKS[@]}"; do
|
||||
IFS='|' read -r pack repo commit dir <<<"$entry"
|
||||
mkdir -p "$MODELS/$pack"
|
||||
n=$(pack_count "$pack")
|
||||
echo "kenney/$pack ($n models)"
|
||||
while read -r name sha; do
|
||||
[[ -z "$name" ]] && continue
|
||||
url="https://raw.githubusercontent.com/KenneyNL/$repo/$commit/$(urlenc "$dir/$name")"
|
||||
fetch "$url" "$MODELS/$pack/$name" "$sha" "kenney/$pack/$name"
|
||||
done < <(pack_files "$pack")
|
||||
done
|
||||
|
||||
mkdir -p "$CHARS"
|
||||
echo "kaykit/characters (${#KAYKIT_CHARS[@]} rigged models, one shared 41-joint rig)"
|
||||
for entry in "${KAYKIT_CHARS[@]}"; do
|
||||
IFS='|' read -r local pack file sha _tex <<<"$entry"
|
||||
fetch "$(kaykit_base "$pack")/$file" "$CHARS/$local.glb" "$sha" "kaykit/$local.glb"
|
||||
done
|
||||
for entry in "${KAYKIT_TEXTURES[@]}"; do
|
||||
IFS='|' read -r local pack file sha <<<"$entry"
|
||||
fetch "$(kaykit_base "$pack")/$file" "$CHARS/$local" "$sha" "kaykit/$local"
|
||||
done
|
||||
|
||||
# ---- 3D model packs (manifest-driven) -------------------------------------
|
||||
if [[ -f "$MANIFEST" ]]; then
|
||||
while IFS='|' read -r slug url sha models core usable; do
|
||||
[[ -z "$slug" ]] && continue
|
||||
[[ "$usable" == "true" ]] || continue
|
||||
want_pack "$slug" "$core" || continue
|
||||
dest="$MODELS/$slug"
|
||||
# The marker is written only after a COMPLETE extraction (models plus
|
||||
# any texture atlas). Counting models alone declared a pack cached when
|
||||
# its colormap.png had never been extracted at all — 48 of 52 packs
|
||||
# rendered untextured while the script cheerfully reported them cached.
|
||||
if [[ -f "$dest/.extracted" ]] && [[ $(find "$dest" -iname '*.glb' -o -iname '*.gltf' | wc -l | tr -d ' ') -ge "$models" ]]; then
|
||||
cached=$((cached + 1))
|
||||
continue
|
||||
fi
|
||||
# A mirror serves <base>/<slug>.zip; upstream keeps its own layout.
|
||||
src="$url"
|
||||
[[ -n "$MIRROR" ]] && src="$MIRROR/$slug.zip"
|
||||
echo "kenney/$slug ($models models)"
|
||||
zip="$MODELS/.$slug.zip"
|
||||
mkdir -p "$MODELS"
|
||||
fetch "$src" "$zip" "$sha" "models/$slug"
|
||||
mkdir -p "$dest"
|
||||
unzip -qo "$zip" -d "$zip.d" 2>/dev/null
|
||||
chmod -R u+w "$zip.d" 2>/dev/null
|
||||
# Prefer GLB and skip the OBJ/FBX/DAE copies — they are most of the
|
||||
# archive and we cannot load them. NOTE GLB is NOT self-contained here:
|
||||
# Kenney materials reference an external `Textures/colormap.png` shared
|
||||
# by the whole pack, and that URI is RELATIVE TO THE GLB — so the
|
||||
# directory structure has to survive extraction. Flattening the PNGs
|
||||
# into the pack root leaves every model looking for a path that no
|
||||
# longer exists, which is indistinguishable from having no texture.
|
||||
# Only the atlas is wanted: the archives also carry Preview/Sample and
|
||||
# per-model thumbnails, which are ~200 MB of images nothing loads.
|
||||
# The GLB's URI is `Textures/colormap.png` relative to itself, and GLBs
|
||||
# land in the pack root — so keep the `Textures/` tail and drop
|
||||
# everything above it (archives nest it under e.g. `FBX format/`,
|
||||
# which varies per pack and must not survive).
|
||||
while IFS= read -r tex; do
|
||||
rel="Textures/${tex##*/Textures/}"
|
||||
mkdir -p "$dest/$(dirname "$rel")"
|
||||
mv -f "$tex" "$dest/$rel"
|
||||
done < <(find "$zip.d" -ipath '*/Textures/*.png' ! -iname 'Preview*' ! -iname 'Sample*')
|
||||
if [[ $(find "$zip.d" -iname '*.glb' | wc -l | tr -d ' ') -gt 0 ]]; then
|
||||
find "$zip.d" -iname '*.glb' -exec sh -c 'mv -f "$1" "$2/$(basename "$1")"' _ {} "$dest" \;
|
||||
else
|
||||
find "$zip.d" \( -iname '*.gltf' -o -iname '*.bin' \) -exec sh -c 'mv -f "$1" "$2/$(basename "$1")"' _ {} "$dest" \;
|
||||
fi
|
||||
rm -rf "$zip.d" "$zip"
|
||||
: >"$dest/.extracted"
|
||||
# Be a good guest: pace requests so a full run is a trickle, not a flood.
|
||||
sleep 1
|
||||
done < <(manifest_rows)
|
||||
fi
|
||||
|
||||
for entry in "${KENNEY_AUDIO[@]}"; do
|
||||
IFS='|' read -r pack hash sha count <<<"$entry"
|
||||
dest="$AUDIO/$pack"
|
||||
zip="$AUDIO/.$pack.zip"
|
||||
if [[ -d "$dest" ]] && [[ $(find "$dest" -name '*.ogg' | wc -l | tr -d ' ') -ge "$count" ]]; then
|
||||
cached=$((cached + 1))
|
||||
continue
|
||||
fi
|
||||
echo "kenney/$pack ($count sounds)"
|
||||
mkdir -p "$AUDIO"
|
||||
fetch "https://kenney.nl/media/pages/assets/$pack/$hash/kenney_$pack.zip" "$zip" "$sha" "audio/$pack"
|
||||
mkdir -p "$dest"
|
||||
# Flatten: the packs nest under Audio/ or Sounds/, and we only want the
|
||||
# sound files — never the bundled .url/.txt/preview cruft.
|
||||
unzip -qo "$zip" -d "$zip.d"
|
||||
# Some packs ship directories without the owner write bit (sci-fi-sounds
|
||||
# is mode r-xr-xr-x), which blocks both the move out and the cleanup.
|
||||
chmod -R u+w "$zip.d"
|
||||
find "$zip.d" -name '*.ogg' -exec mv -f {} "$dest/" \;
|
||||
rm -rf "$zip.d" "$zip"
|
||||
done
|
||||
|
||||
if [[ $TRANSCODE == 1 ]]; then
|
||||
if command -v ffmpeg >/dev/null 2>&1; then
|
||||
echo "transcoding ogg -> wav (ffmpeg)"
|
||||
find "$AUDIO" -name '*.ogg' | while read -r f; do
|
||||
w="${f%.ogg}.wav"
|
||||
[[ -f "$w" ]] || ffmpeg -loglevel error -y -i "$f" "$w"
|
||||
done
|
||||
else
|
||||
echo "WARNING: --transcode needs ffmpeg on PATH; skipping" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "done — $fetched fetched, $cached already cached"
|
||||
echo
|
||||
echo "These assets are CC0 (public domain). Attribution isn't required, but is"
|
||||
echo "deserved:"
|
||||
echo " Kenney https://kenney.nl/assets"
|
||||
echo " KayKit / Kay Lousberg https://kaylousberg.itch.io/"
|
||||
echo
|
||||
echo "Thank you both for giving so many high-quality assets away for free."
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
//! Print what the Zelda-scale plan actually builds.
|
||||
//!
|
||||
//! Layout is pure, so the whole world can be inspected without a window —
|
||||
//! which is the point of splitting `plan` from `realise`. Run with:
|
||||
//! cargo run -p makepad-arcade --example bigworld_probe --release
|
||||
|
||||
use makepad_arcade::bigworld::{self, Region};
|
||||
|
||||
fn main() {
|
||||
let root = std::path::Path::new("apps/arcade/resources");
|
||||
if !root.join("models/kenney").is_dir() {
|
||||
eprintln!("run apps/arcade/download_assets.sh first");
|
||||
return;
|
||||
}
|
||||
let t = std::time::Instant::now();
|
||||
let index = makepad_game_assets::AssetIndex::build(root);
|
||||
let index_ms = t.elapsed().as_millis();
|
||||
|
||||
let plan = bigworld::plan(&index, 7);
|
||||
let s = &plan.stats;
|
||||
println!(
|
||||
"index {} entries in {index_ms} ms\nplan {} props ({} distinct models), {} tiles, {} npcs, {} pois, {} interactables in {} us",
|
||||
index.len(),
|
||||
s.props,
|
||||
s.distinct_models,
|
||||
s.tiles,
|
||||
s.npcs,
|
||||
plan.pois.len(),
|
||||
plan.interactables.len(),
|
||||
s.gen_us,
|
||||
);
|
||||
println!("\nper region:");
|
||||
for (r, n) in &s.per_region {
|
||||
println!(" {:<9} {n:>5}", r.name());
|
||||
}
|
||||
|
||||
println!("\ncast:");
|
||||
let (cj, civ) = bigworld::civilian_cast(&index);
|
||||
let (hj, hero) = bigworld::hero_cast(&index);
|
||||
println!(" civilians {cj} joints, {} members", civ.len());
|
||||
println!(" heroes {hj} joints, {} members", hero.len());
|
||||
|
||||
println!("\nnpcs by region:");
|
||||
for r in [Region::Village, Region::Castle, Region::Dungeon] {
|
||||
let n: Vec<&str> = plan
|
||||
.npcs
|
||||
.iter()
|
||||
.filter(|n| n.region == r)
|
||||
.filter_map(|n| n.character.as_deref())
|
||||
.collect();
|
||||
println!(" {:<8} {}", r.name(), n.join(", "));
|
||||
}
|
||||
|
||||
println!("\nsample models per region:");
|
||||
for (r, _) in &s.per_region {
|
||||
let mut ids: Vec<&str> = plan
|
||||
.placements
|
||||
.iter()
|
||||
.filter(|p| p.region == *r)
|
||||
.map(|p| p.model.as_str())
|
||||
.collect();
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
println!(" {:<9} {}", r.name(), ids.iter().take(5).cloned().collect::<Vec<_>>().join(", "));
|
||||
}
|
||||
|
||||
// Reachability: every region centre must be within a short walk of a road
|
||||
// tile, or a player cannot get there on foot.
|
||||
println!("\nreachability (nearest road tile to each region centre):");
|
||||
for (name, c) in [
|
||||
("village", bigworld::VILLAGE),
|
||||
("castle", bigworld::CASTLE),
|
||||
("woods", bigworld::WOODS),
|
||||
("dungeon", bigworld::DUNGEON),
|
||||
("quarry", bigworld::QUARRY),
|
||||
] {
|
||||
let best = plan
|
||||
.placements
|
||||
.iter()
|
||||
.filter(|p| p.region == Region::Roads)
|
||||
.map(|p| ((p.pos.x - c.x).powi(2) + (p.pos.z - c.y).powi(2)).sqrt())
|
||||
.fold(f32::INFINITY, f32::min);
|
||||
println!(" {name:<8} {best:.1} units");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
# Machine-readable attribution for the stock asset library.
|
||||
#
|
||||
# A published game package carries this so credit travels with the game, even
|
||||
# though CC0 does not require it. Generated games should copy the rows for the
|
||||
# sources they actually use.
|
||||
|
||||
[[source]]
|
||||
name = "Kenney"
|
||||
url = "https://kenney.nl/assets"
|
||||
license = "CC0-1.0"
|
||||
credit = "Kenney (kenney.nl) — CC0"
|
||||
packs = [
|
||||
"arena (Starter-Kit-Basic-Scene)",
|
||||
"city (Starter-Kit-City-Builder)",
|
||||
"fps (Starter-Kit-FPS)",
|
||||
"platformer (Starter-Kit-3D-Platformer)",
|
||||
"racing (Starter-Kit-Racing)",
|
||||
"impact-sounds",
|
||||
"interface-sounds",
|
||||
"sci-fi-sounds",
|
||||
"music-jingles",
|
||||
"ui-audio",
|
||||
"rpg-audio",
|
||||
"digital-audio",
|
||||
]
|
||||
|
||||
[[source]]
|
||||
name = "KayKit / Kay Lousberg"
|
||||
url = "https://kaylousberg.itch.io/"
|
||||
license = "CC0-1.0"
|
||||
credit = "KayKit / Kay Lousberg (kaylousberg.com) — CC0"
|
||||
packs = [
|
||||
"characters (KayKit Character Pack: Adventurers) — Knight, Barbarian, Mage, Rogue, Rogue_Hooded",
|
||||
"characters (KayKit Character Pack: Skeletons) — Warrior, Mage, Rogue, Minion",
|
||||
]
|
||||
# All nine share one 41-joint rig, so an animation authored against any of them
|
||||
# plays on every one. Adventurers ship 76 clips; skeletons ship the same 76 plus
|
||||
# 19 undead extras (awaken, resurrect, spawn, taunt).
|
||||
rig = "kaykit-adventurers-41j"
|
||||
|
|
@ -1,522 +0,0 @@
|
|||
# Mirror manifest for Makepad Arcade's Kenney asset library.
|
||||
#
|
||||
# Every pack below is CC0 (public domain), which is what makes mirroring legal:
|
||||
# anyone can re-host these bytes. This file is the reproducible description of
|
||||
# the mirror — slug, canonical upstream URL, sha256 and size — so a mirror can
|
||||
# be rebuilt or verified by anyone, and so a mirrored byte stream is checked
|
||||
# exactly as strictly as an upstream one. A mirror is never trusted more than
|
||||
# upstream; the hash is the authority, not the host.
|
||||
#
|
||||
# Generated by tools/scrape (see README). Columns are fixed-width for diffing.
|
||||
#
|
||||
# core = fetched by default; the rest need --packs=all or --packs=<slug>,...
|
||||
# usable = ships glTF/GLB. The three animated-character packs are FBX-only and
|
||||
# we have no FBX loader, so they are recorded but never fetched.
|
||||
|
||||
[meta]
|
||||
source = "kenney.nl"
|
||||
license = "CC0-1.0"
|
||||
credit = "Kenney (kenney.nl) — CC0"
|
||||
packs = 50
|
||||
models = 4669
|
||||
bytes = 173943741
|
||||
|
||||
[[pack]]
|
||||
slug = "3d-road-tiles"
|
||||
title = "3D Road Tiles"
|
||||
url = "https://kenney.nl/media/pages/assets/3d-road-tiles/3fbfaa7ec8-1677581262/kenney_3d-road-tiles.zip"
|
||||
sha256 = "93af3af6d2cef287a0eca7efb936a515574151d3cb32c202f14d8f6a99518c40"
|
||||
bytes = 3683526
|
||||
models = 302
|
||||
core = false
|
||||
usable = true
|
||||
|
||||
[[pack]]
|
||||
slug = "animated-characters-protagonists"
|
||||
title = "Animated Characters Protagonists"
|
||||
url = "https://kenney.nl/media/pages/assets/animated-characters-protagonists/608191acc4-1774773108/kenney_animated-characters-protagonists.zip"
|
||||
sha256 = "ec3787de70fa2200256848d74201b10f6b6c3126594e9857bf989753312c2b84"
|
||||
bytes = 581441
|
||||
models = 0
|
||||
core = false
|
||||
usable = false
|
||||
|
||||
[[pack]]
|
||||
slug = "animated-characters-retro"
|
||||
title = "Animated Characters Retro"
|
||||
url = "https://kenney.nl/media/pages/assets/animated-characters-retro/93305a3c49-1774772819/kenney_animated-characters-retro.zip"
|
||||
sha256 = "1d03f1fb001f3cf629425b69c898df58261a316616220ea25b58bd35298e6882"
|
||||
bytes = 706472
|
||||
models = 0
|
||||
core = false
|
||||
usable = false
|
||||
|
||||
[[pack]]
|
||||
slug = "animated-characters-survivors"
|
||||
title = "Animated Characters Survivors"
|
||||
url = "https://kenney.nl/media/pages/assets/animated-characters-survivors/27b16052a7-1774772958/kenney_animated-characters-survivors.zip"
|
||||
sha256 = "fdadced07a0454c9b7f0b46507be6144a072b4d03b4ffa37f225893c76c62845"
|
||||
bytes = 718027
|
||||
models = 0
|
||||
core = false
|
||||
usable = false
|
||||
|
||||
[[pack]]
|
||||
slug = "blaster-kit"
|
||||
title = "Blaster Kit"
|
||||
url = "https://kenney.nl/media/pages/assets/blaster-kit/261d80a716-1753959510/kenney_blaster-kit_2.1.zip"
|
||||
sha256 = "91e3093e95427d59625e7e2ce2d0399b861600160fd0b4ada7714796b67cea8c"
|
||||
bytes = 1724676
|
||||
models = 40
|
||||
core = false
|
||||
usable = true
|
||||
|
||||
[[pack]]
|
||||
slug = "blocky-characters"
|
||||
title = "Blocky Characters"
|
||||
url = "https://kenney.nl/media/pages/assets/blocky-characters/8369c0cf30-1749547469/kenney_blocky-characters_20.zip"
|
||||
sha256 = "5e123859aa0c1598342b600c6db197024a1d63eb9ec531398b310725f589887e"
|
||||
bytes = 2148510
|
||||
models = 18
|
||||
core = true
|
||||
usable = true
|
||||
|
||||
[[pack]]
|
||||
slug = "brick-kit"
|
||||
title = "Brick Kit"
|
||||
url = "https://kenney.nl/media/pages/assets/brick-kit/46a22f3d08-1716981002/kenney_brick-kit.zip"
|
||||
sha256 = "b303d293c278fab713eed28395829b18513b171d2562f7f518c3355c2896861a"
|
||||
bytes = 4668567
|
||||
models = 296
|
||||
core = false
|
||||
usable = true
|
||||
|
||||
[[pack]]
|
||||
slug = "building-kit"
|
||||
title = "Building Kit"
|
||||
url = "https://kenney.nl/media/pages/assets/building-kit/0de7aaa492-1743244741/kenney_building-kit.zip"
|
||||
sha256 = "2740ef5772fb5fb3d7aab881db22d129f6b68afe711b1a79e6d5e9e19cf3eec6"
|
||||
bytes = 1598905
|
||||
models = 79
|
||||
core = false
|
||||
usable = true
|
||||
|
||||
[[pack]]
|
||||
slug = "car-kit"
|
||||
title = "Car Kit"
|
||||
url = "https://kenney.nl/media/pages/assets/car-kit/1a312ec241-1775131960/kenney_car-kit.zip"
|
||||
sha256 = "fac7dacac5c7874348cf19729af3ef205f3d366493edaf0a827d93f4fdf3d0c4"
|
||||
bytes = 4814237
|
||||
models = 50
|
||||
core = true
|
||||
usable = true
|
||||
|
||||
[[pack]]
|
||||
slug = "castle-kit"
|
||||
title = "Castle Kit"
|
||||
url = "https://kenney.nl/media/pages/assets/castle-kit/a395102d20-1711543616/kenney_castle-kit.zip"
|
||||
sha256 = "921f3f73927bb23106cae34bc21d5ab4b033a9fc120475e96f714a406e3169df"
|
||||
bytes = 2232589
|
||||
models = 76
|
||||
core = true
|
||||
usable = true
|
||||
|
||||
[[pack]]
|
||||
slug = "city-kit-commercial"
|
||||
title = "City Kit (Commercial)"
|
||||
url = "https://kenney.nl/media/pages/assets/city-kit-commercial/a742d900eb-1753115042/kenney_city-kit-commercial_2.1.zip"
|
||||
sha256 = "f8b09b081c2bb88bcc126e2dec1cb40fd0dad7e7e591b6c26aaefe96fb35276b"
|
||||
bytes = 4096974
|
||||
models = 41
|
||||
core = false
|
||||
usable = true
|
||||
|
||||
[[pack]]
|
||||
slug = "city-kit-industrial"
|
||||
title = "City Kit (Industrial)"
|
||||
url = "https://kenney.nl/media/pages/assets/city-kit-industrial/5fcb837741-1750838303/kenney_city-kit-industrial_1.0.zip"
|
||||
sha256 = "99a09ff148056678c0c3b7977ad9dbce55d2f243e06fbfc7c28642accef4dd9e"
|
||||
bytes = 3805564
|
||||
models = 25
|
||||
core = false
|
||||
usable = true
|
||||
|
||||
[[pack]]
|
||||
slug = "city-kit-roads"
|
||||
title = "City Kit (Roads)"
|
||||
url = "https://kenney.nl/media/pages/assets/city-kit-roads/74288c9459-1741864740/kenney_city-kit-roads.zip"
|
||||
sha256 = "2c1644a293a85d98837ef788b0cbc4b9d53dffb1280fbe9a4f927b644aaba4b0"
|
||||
bytes = 1716227
|
||||
models = 72
|
||||
core = true
|
||||
usable = true
|
||||
|
||||
[[pack]]
|
||||
slug = "city-kit-suburban"
|
||||
title = "City Kit (Suburban)"
|
||||
url = "https://kenney.nl/media/pages/assets/city-kit-suburban/2c871b7af2-1745479373/kenney_city-kit-suburban_20.zip"
|
||||
sha256 = "5869c35cf30b1c87bdb2d197b6d325eebadd2ef08ea27f04797e8e08d77a9a39"
|
||||
bytes = 3038740
|
||||
models = 40
|
||||
core = true
|
||||
usable = true
|
||||
|
||||
[[pack]]
|
||||
slug = "coaster-kit"
|
||||
title = "Coaster Kit"
|
||||
url = "https://kenney.nl/media/pages/assets/coaster-kit/546fdc554f-1731487890/kenney_coaster-kit.zip"
|
||||
sha256 = "7723d02e3ea822371e12e14722e72d572fdb0384c1379c1469e0598a371f5c7a"
|
||||
bytes = 6697401
|
||||
models = 183
|
||||
core = false
|
||||
usable = true
|
||||
|
||||
[[pack]]
|
||||
slug = "cube-pets"
|
||||
title = "Cube Pets"
|
||||
url = "https://kenney.nl/media/pages/assets/cube-pets/44e58e945f-1774520254/kenney_cube-pets_1.0.zip"
|
||||
sha256 = "b3bdc99a2ec92c687b875718c5d01e9231d2711ed0e2845f295b474bb42a1283"
|
||||
bytes = 2812444
|
||||
models = 24
|
||||
core = true
|
||||
usable = true
|
||||
|
||||
[[pack]]
|
||||
slug = "factory-kit"
|
||||
title = "Factory Kit"
|
||||
url = "https://kenney.nl/media/pages/assets/factory-kit/edaac9d4f6-1777639602/kenney_factory-kit_3.0.zip"
|
||||
sha256 = "7e31fb2308e90304672bd15cd18fa9d9f02c03731a8cbc57a8e3e1c181dfb0a7"
|
||||
bytes = 4511890
|
||||
models = 143
|
||||
core = false
|
||||
usable = true
|
||||
|
||||
[[pack]]
|
||||
slug = "fantasy-town-kit"
|
||||
title = "Fantasy Town Kit"
|
||||
url = "https://kenney.nl/media/pages/assets/fantasy-town-kit/efe948d309-1754222374/kenney_fantasy-town-kit_2.0.zip"
|
||||
sha256 = "1a7530c09f4d2fa2cdee259876f089334f8b1f27fa86a0c4f54ef86cdd8676ef"
|
||||
bytes = 3854691
|
||||
models = 167
|
||||
core = false
|
||||
usable = true
|
||||
|
||||
[[pack]]
|
||||
slug = "food-kit"
|
||||
title = "Food Kit"
|
||||
url = "https://kenney.nl/media/pages/assets/food-kit/83086fa91c-1719418518/kenney_food-kit.zip"
|
||||
sha256 = "cdad90853682499b94c9fda2f87678b24bfd8f3264e0ed323f6b6a27fd7c6f6f"
|
||||
bytes = 4606270
|
||||
models = 200
|
||||
core = true
|
||||
usable = true
|
||||
|
||||
[[pack]]
|
||||
slug = "furniture-kit"
|
||||
title = "Furniture Kit"
|
||||
url = "https://kenney.nl/media/pages/assets/furniture-kit/440e0608a4-1677580847/kenney_furniture-kit.zip"
|
||||
sha256 = "e67652d0932cee41683f74711c03d3e192a2af9979ef8e6b237711f5482d46b0"
|
||||
bytes = 5130729
|
||||
models = 140
|
||||
core = true
|
||||
usable = true
|
||||
|
||||
[[pack]]
|
||||
slug = "graveyard-kit"
|
||||
title = "Graveyard Kit"
|
||||
url = "https://kenney.nl/media/pages/assets/graveyard-kit/ba8d4b4517-1760691807/kenney_graveyard-kit_5.0.zip"
|
||||
sha256 = "1a93613f2e5675f3310acf49ec9ef13ae7adeb756ac3b205bfb6cc9311a81062"
|
||||
bytes = 3623408
|
||||
models = 91
|
||||
core = false
|
||||
usable = true
|
||||
|
||||
[[pack]]
|
||||
slug = "hexagon-kit"
|
||||
title = "Hexagon Kit"
|
||||
url = "https://kenney.nl/media/pages/assets/hexagon-kit/fd3b63101b-1706007730/kenney_hexagon-kit.zip"
|
||||
sha256 = "d14f7643852c4dd854c7530de8514c9eb77314fe9963636077f0de1da8901236"
|
||||
bytes = 2448931
|
||||
models = 72
|
||||
core = false
|
||||
usable = true
|
||||
|
||||
[[pack]]
|
||||
slug = "holiday-kit"
|
||||
title = "Holiday Kit"
|
||||
url = "https://kenney.nl/media/pages/assets/holiday-kit/3976a6496a-1733923970/kenney_holiday-kit.zip"
|
||||
sha256 = "fde4d514d7297388d98058e8933ff614e071886f7ce57f9aea4b00d7698dd769"
|
||||
bytes = 4482244
|
||||
models = 99
|
||||
core = true
|
||||
usable = true
|
||||
|
||||
[[pack]]
|
||||
slug = "marble-kit"
|
||||
title = "Marble Kit"
|
||||
url = "https://kenney.nl/media/pages/assets/marble-kit/56fd69e5ed-1716385090/kenney_marble-kit.zip"
|
||||
sha256 = "4a289fe77fa4e71b943fb9a0df828a7987e62f665083ea9237d0bd9acd0b6dcb"
|
||||
bytes = 5339532
|
||||
models = 162
|
||||
core = false
|
||||
usable = true
|
||||
|
||||
[[pack]]
|
||||
slug = "mini-arcade"
|
||||
title = "Mini Arcade"
|
||||
url = "https://kenney.nl/media/pages/assets/mini-arcade/ece1e8f320-1721638600/kenney_mini-arcade.zip"
|
||||
sha256 = "2acfe5cb44d392e834f77cf5488528c7cd45427cdf5fb941ce99856276158c19"
|
||||
bytes = 1317439
|
||||
models = 20
|
||||
core = false
|
||||
usable = true
|
||||
|
||||
[[pack]]
|
||||
slug = "mini-arena"
|
||||
title = "Mini Arena"
|
||||
url = "https://kenney.nl/media/pages/assets/mini-arena/88f977a0cb-1709220730/kenney_mini-arena.zip"
|
||||
sha256 = "514760c2bc2457027451534a0b06a23f98beece166fb11ed2321226276b83c2c"
|
||||
bytes = 731420
|
||||
models = 22
|
||||
core = false
|
||||
usable = true
|
||||
|
||||
[[pack]]
|
||||
slug = "mini-characters"
|
||||
title = "Mini Characters"
|
||||
url = "https://kenney.nl/media/pages/assets/mini-characters/bfc7e272b4-1774770718/kenney_mini-characters.zip"
|
||||
sha256 = "9e1d48e6d7b8479ebbe84df71eb5bd8e1b3f0da546dea641890dccc8a02d0999"
|
||||
bytes = 2403059
|
||||
models = 26
|
||||
core = false
|
||||
usable = true
|
||||
|
||||
[[pack]]
|
||||
slug = "mini-dungeon"
|
||||
title = "Mini Dungeon"
|
||||
url = "https://kenney.nl/media/pages/assets/mini-dungeon/6cd72dc849-1785314274/kenney_mini-dungeon.zip"
|
||||
sha256 = "19c4648680cb1d2e8836cade96cbf9781c0c1f45fbc6d2ce41cee8239a3ec4d8"
|
||||
bytes = 1796820
|
||||
models = 30
|
||||
core = true
|
||||
usable = true
|
||||
|
||||
[[pack]]
|
||||
slug = "mini-forest"
|
||||
title = "Mini Forest"
|
||||
url = "https://kenney.nl/media/pages/assets/mini-forest/44a89aed7f-1784024079/kenney_mini-forest_1.0.zip"
|
||||
sha256 = "8691614018075a66458e35915b8c358c2e6178648aedadafcdf313b924aa6581"
|
||||
bytes = 1118081
|
||||
models = 22
|
||||
core = false
|
||||
usable = true
|
||||
|
||||
[[pack]]
|
||||
slug = "mini-market"
|
||||
title = "Mini Market"
|
||||
url = "https://kenney.nl/media/pages/assets/mini-market/463f38da51-1729865423/kenney_mini-market.zip"
|
||||
sha256 = "6f2e69090e359204ace43e4c98dc3fb8639b75e423109672b5c890d194c45203"
|
||||
bytes = 1135859
|
||||
models = 20
|
||||
core = false
|
||||
usable = true
|
||||
|
||||
[[pack]]
|
||||
slug = "mini-skate"
|
||||
title = "Mini Skate"
|
||||
url = "https://kenney.nl/media/pages/assets/mini-skate/00b0c2b304-1709221152/kenney_mini-skate.zip"
|
||||
sha256 = "82582f6de507e93090c16bb4802a7c361015fe246eddd571e6e96f816b12e8c6"
|
||||
bytes = 721232
|
||||
models = 20
|
||||
core = false
|
||||
usable = true
|
||||
|
||||
[[pack]]
|
||||
slug = "minigolf-kit"
|
||||
title = "Minigolf Kit"
|
||||
url = "https://kenney.nl/media/pages/assets/minigolf-kit/3ae60d8b01-1741163874/kenney_minigolf-kit.zip"
|
||||
sha256 = "74b7bf5cf82bf8a5225319ade1284600c64f9482ae66f0ccf1f22d2212d5a952"
|
||||
bytes = 3163116
|
||||
models = 126
|
||||
core = false
|
||||
usable = true
|
||||
|
||||
[[pack]]
|
||||
slug = "modular-buildings"
|
||||
title = "Modular Buildings"
|
||||
url = "https://kenney.nl/media/pages/assets/modular-buildings/3253b4219a-1707397411/kenney_modular-buildings.zip"
|
||||
sha256 = "47e1614686b4c0fe55190e88b22ff2b8b10935edf61f37f478a9b6a99477edc6"
|
||||
bytes = 1825490
|
||||
models = 108
|
||||
core = false
|
||||
usable = true
|
||||
|
||||
[[pack]]
|
||||
slug = "modular-cave-kit"
|
||||
title = "Modular Cave Kit"
|
||||
url = "https://kenney.nl/media/pages/assets/modular-cave-kit/37ec3cb12d-1783667097/kenney_modular-cave-kit_1.0.zip"
|
||||
sha256 = "48f37a6d4f241124cd7da17da1c6d4ed1bf1820bb149dcb233fbd5ebdd8ba996"
|
||||
bytes = 7025078
|
||||
models = 40
|
||||
core = false
|
||||
usable = true
|
||||
|
||||
[[pack]]
|
||||
slug = "modular-dungeon-kit"
|
||||
title = "Modular Dungeon Kit"
|
||||
url = "https://kenney.nl/media/pages/assets/modular-dungeon-kit/7bed87605b-1771926065/kenney_modular-dungeon-kit_1.0.zip"
|
||||
sha256 = "dd0aa6776db8912283cdca60161dee6a8839bbda3558eba2ea501419eb5b4623"
|
||||
bytes = 6886434
|
||||
models = 39
|
||||
core = false
|
||||
usable = true
|
||||
|
||||
[[pack]]
|
||||
slug = "modular-space-kit"
|
||||
title = "Modular Space Kit"
|
||||
url = "https://kenney.nl/media/pages/assets/modular-space-kit/8261428a47-1771146076/kenney_modular-space-kit_1.0.zip"
|
||||
sha256 = "f394f7fd9eaf29c9de7e090e55b69926f699841af33b0b116f5cc0088de8a4dc"
|
||||
bytes = 6966606
|
||||
models = 40
|
||||
core = false
|
||||
usable = true
|
||||
|
||||
[[pack]]
|
||||
slug = "nature-kit"
|
||||
title = "Nature Kit"
|
||||
url = "https://kenney.nl/media/pages/assets/nature-kit/37ac38a37b-1677698939/kenney_nature-kit.zip"
|
||||
sha256 = "fa7974a0d342bfe63c38664ba9f8ec1a4aab8ea25f099bdc56870e33588c4d9d"
|
||||
bytes = 10537521
|
||||
models = 329
|
||||
core = true
|
||||
usable = true
|
||||
|
||||
[[pack]]
|
||||
slug = "pirate-kit"
|
||||
title = "Pirate Kit"
|
||||
url = "https://kenney.nl/media/pages/assets/pirate-kit/e6d4bb1525-1771333093/kenney_pirate-kit.zip"
|
||||
sha256 = "667ed2caf92954ddb98f7b7cede831fe99ab75063c26b25e23d32715bee9c943"
|
||||
bytes = 3154665
|
||||
models = 72
|
||||
core = true
|
||||
usable = true
|
||||
|
||||
[[pack]]
|
||||
slug = "platformer-kit"
|
||||
title = "Platformer Kit"
|
||||
url = "https://kenney.nl/media/pages/assets/platformer-kit/1585cf62b4-1775122253/kenney_platformer-kit.zip"
|
||||
sha256 = "899605d237367688c6b42e41fff2206c0fb8d626163158294556353f7baa7c1b"
|
||||
bytes = 4640135
|
||||
models = 153
|
||||
core = true
|
||||
usable = true
|
||||
|
||||
[[pack]]
|
||||
slug = "prototype-kit"
|
||||
title = "Prototype Kit"
|
||||
url = "https://kenney.nl/media/pages/assets/prototype-kit/4d3b7073ed-1724832076/kenney_prototype-kit.zip"
|
||||
sha256 = "213b522fb12bcc9b9ac66c4f7581f7c74623293272212e40a70c39936ad3da95"
|
||||
bytes = 2961396
|
||||
models = 145
|
||||
core = false
|
||||
usable = true
|
||||
|
||||
[[pack]]
|
||||
slug = "racing-kit"
|
||||
title = "Racing Kit"
|
||||
url = "https://kenney.nl/media/pages/assets/racing-kit/933b8fd9fd-1677580949/kenney_racing-kit.zip"
|
||||
sha256 = "8a71ea16219315a01d00d5a90c4f6b5c090faddbc56d80ecf727e2b3b853c6c0"
|
||||
bytes = 6082755
|
||||
models = 112
|
||||
core = true
|
||||
usable = true
|
||||
|
||||
[[pack]]
|
||||
slug = "retro-fantasy-kit"
|
||||
title = "Retro Fantasy Kit"
|
||||
url = "https://kenney.nl/media/pages/assets/retro-fantasy-kit/cf3f41b752-1774770658/kenney_retro-fantasy-kit.zip"
|
||||
sha256 = "d3462b453018d0d150406d85af3c4ae1322ceef8f1754c4eb4e6355adfeaab0f"
|
||||
bytes = 2024232
|
||||
models = 105
|
||||
core = false
|
||||
usable = true
|
||||
|
||||
[[pack]]
|
||||
slug = "retro-urban-kit"
|
||||
title = "Retro Urban Kit"
|
||||
url = "https://kenney.nl/media/pages/assets/retro-urban-kit/8314d4db22-1738147509/kenney_retro-urban-kit.zip"
|
||||
sha256 = "19201cbcb0d90080dfc33b54f5b4be2089de927c2a23274180798e7166d6b6ca"
|
||||
bytes = 2161463
|
||||
models = 124
|
||||
core = false
|
||||
usable = true
|
||||
|
||||
[[pack]]
|
||||
slug = "space-kit"
|
||||
title = "Space Kit"
|
||||
url = "https://kenney.nl/media/pages/assets/space-kit/20874c75ac-1677698978/kenney_space-kit.zip"
|
||||
sha256 = "d5d7cdf2635ed5a43a9187deaf409b6f47484e402321128341d3c3698e9ef4d9"
|
||||
bytes = 6677531
|
||||
models = 153
|
||||
core = true
|
||||
usable = true
|
||||
|
||||
[[pack]]
|
||||
slug = "space-station-kit"
|
||||
title = "Space Station Kit"
|
||||
url = "https://kenney.nl/media/pages/assets/space-station-kit/6475288f2e-1712749919/kenney_space-station-kit.zip"
|
||||
sha256 = "215e79bd5415cff93665183390f0343ed9acf87780306331013b78520170c6d8"
|
||||
bytes = 1835875
|
||||
models = 97
|
||||
core = false
|
||||
usable = true
|
||||
|
||||
[[pack]]
|
||||
slug = "survival-kit"
|
||||
title = "Survival Kit"
|
||||
url = "https://kenney.nl/media/pages/assets/survival-kit/4065a8185b-1712149243/kenney_survival-kit.zip"
|
||||
sha256 = "c3586341b5932c87eb43d75d915434f47daed168b17ed36a03e8ca9977c7443e"
|
||||
bytes = 1948174
|
||||
models = 80
|
||||
core = false
|
||||
usable = true
|
||||
|
||||
[[pack]]
|
||||
slug = "tower-defense-kit"
|
||||
title = "Tower Defense Kit"
|
||||
url = "https://kenney.nl/media/pages/assets/tower-defense-kit/a402493eaa-1726471567/kenney_tower-defense-kit.zip"
|
||||
sha256 = "d4c887680b709218315e4e1c17ae18c160635dcdfc4199763eeba97c02c77f00"
|
||||
bytes = 5404433
|
||||
models = 160
|
||||
core = true
|
||||
usable = true
|
||||
|
||||
[[pack]]
|
||||
slug = "toy-car-kit"
|
||||
title = "Toy Car Kit"
|
||||
url = "https://kenney.nl/media/pages/assets/toy-car-kit/42e19cc426-1736346027/kenney_toy-car-kit.zip"
|
||||
sha256 = "26c11bbb77102b8dd00cdaf7b2c7ab692416d750dd064de886d809acec346782"
|
||||
bytes = 5245353
|
||||
models = 157
|
||||
core = false
|
||||
usable = true
|
||||
|
||||
[[pack]]
|
||||
slug = "train-kit"
|
||||
title = "Train Kit"
|
||||
url = "https://kenney.nl/media/pages/assets/train-kit/cf8521d625-1727040883/kenney_train-kit.zip"
|
||||
sha256 = "cf50d77e8cbacbf38dd50826d4bce5392db8e4f67373d3c4e583b0ed0e474475"
|
||||
bytes = 5266588
|
||||
models = 103
|
||||
core = true
|
||||
usable = true
|
||||
|
||||
[[pack]]
|
||||
slug = "watercraft-kit"
|
||||
title = "Watercraft Kit"
|
||||
url = "https://kenney.nl/media/pages/assets/watercraft-kit/a335cfed49-1713519620/kenney_watercraft-pack.zip"
|
||||
sha256 = "cd1470c1cf441c7f46d0944ae6d0d897242365dc97677c5079b3238965d659f3"
|
||||
bytes = 1870991
|
||||
models = 46
|
||||
core = true
|
||||
usable = true
|
||||
6
apps/arcade/resources/audio/.gitignore
vendored
6
apps/arcade/resources/audio/.gitignore
vendored
|
|
@ -1,6 +0,0 @@
|
|||
# Downloaded by download_assets.sh — never committed (see repo policy in game.md).
|
||||
#
|
||||
# Allow-list rather than deny-list, so a pack shipping an unlisted format
|
||||
# cannot slip hundreds of asset files into a commit.
|
||||
*
|
||||
!.gitignore
|
||||
7
apps/arcade/resources/characters/.gitignore
vendored
7
apps/arcade/resources/characters/.gitignore
vendored
|
|
@ -1,7 +0,0 @@
|
|||
# Downloaded by download_assets.sh — never committed (see repo policy in game.md).
|
||||
#
|
||||
# Allow-list rather than deny-list: LICENSE-CC0.txt is checked in deliberately,
|
||||
# everything else here is fetched.
|
||||
*
|
||||
!.gitignore
|
||||
!LICENSE-CC0.txt
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
|
||||
KayKit : Adventurers Character Pack (1.0)
|
||||
|
||||
Created/distributed by Kay Lousberg (www.kaylousberg.com)
|
||||
Creation date: 13/03/2023 09:00
|
||||
|
||||
------------------------------
|
||||
|
||||
License: (Creative Commons Zero, CC0)
|
||||
http://creativecommons.org/publicdomain/zero/1.0/
|
||||
|
||||
This content is free to use in personal, educational and commercial projects.
|
||||
|
||||
Support me by using a brand resource provided in this pack or by crediting Kay Lousberg, www.kaylousberg.com (this is not mandatory)
|
||||
|
||||
------------------------------
|
||||
|
||||
This asset pack is here thanks to all the wonderful people who support KayKit on Patreon and those who buy our EXTRA or SOURCE packs on itch.io.
|
||||
|
||||
And a big, special thank you to my Super Supporters:
|
||||
|
||||
- Brian McBarron
|
||||
- Silva
|
||||
|
||||
|
||||
|
||||
------------------------------
|
||||
|
||||
|
||||
Patreon: http://patreon.com/kaylousberg
|
||||
|
||||
Follow on Twitter for updates:
|
||||
http://twitter.com/KayLousberg
|
||||
7
apps/arcade/resources/models/.gitignore
vendored
7
apps/arcade/resources/models/.gitignore
vendored
|
|
@ -1,7 +0,0 @@
|
|||
# Downloaded by download_assets.sh — never committed (see repo policy in game.md).
|
||||
#
|
||||
# Allow-list rather than deny-list: a new pack shipping a format nobody
|
||||
# listed here (3d-road-tiles ships .gltf, not .glb) would otherwise put
|
||||
# hundreds of asset files into a commit by accident.
|
||||
*
|
||||
!.gitignore
|
||||
|
|
@ -1,151 +0,0 @@
|
|||
// Makepad Arcade — the starting world.
|
||||
//
|
||||
// This is a TEMPLATE, not a demo: it is what a new world begins as, and it is
|
||||
// meant to be edited. Everything here is a `game.*` verb, so the AI editing
|
||||
// this file can change any of it — which is the whole point. Nothing about
|
||||
// this world lives in Rust.
|
||||
//
|
||||
// It is deliberately readable top to bottom: sky, ground, town, cars, a
|
||||
// player, something to climb. If you are adding to it, add a section.
|
||||
|
||||
// ---------------------------------------------------------------- sky & sun
|
||||
game.sky({})
|
||||
// A FIXED sun, given as a direction rather than a time of day. 38 degrees of
|
||||
// elevation casts a shadow about 1.3x its caster's height: long enough to show
|
||||
// what shape threw it and which way the ground slopes, short enough that the
|
||||
// village does not disappear into its own shade. A moving sun re-bakes the
|
||||
// world's shadows continuously and nothing on screen ever settles.
|
||||
game.sun({dir: vec3(0.55, 0.62, 0.56)})
|
||||
|
||||
// ------------------------------------------------------------------ terrain
|
||||
// `feature` is how far apart the hills are, in world units. `rim` grows the
|
||||
// relief toward the edge of the map: a playable basin with a scenic horizon,
|
||||
// which is the answer to terrain being either boring or unbuildable. The
|
||||
// plaza keeps the town's ground genuinely flat — a road is one long box and a
|
||||
// house has square feet, and neither can follow a slope.
|
||||
game.terrain({
|
||||
size: 260,
|
||||
cells: 129,
|
||||
smooth: true,
|
||||
seed: 1466247766,
|
||||
amp: 5,
|
||||
feature: 90,
|
||||
flatten: 1.25,
|
||||
rim: 5,
|
||||
rim_start: 0.34,
|
||||
plaza: {r: 66, ramp: 26, h: 0},
|
||||
color: #5a8f46
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------- roads
|
||||
// A crossroads, not a corridor. A junction is the smallest thing that makes a
|
||||
// place feel like it has somewhere else to be. `collide: false` makes these
|
||||
// surfaces rather than kerbs the car has to climb.
|
||||
game.box({pos: vec3(0, 0.02, 0), size: vec3(116, 0.1, 14), color: #4d4d52, tag: "road", collide: false})
|
||||
game.box({pos: vec3(0, 0.02, 0), size: vec3(14, 0.1, 104), color: #4d4d52, tag: "road", collide: false})
|
||||
game.box({pos: vec3(-20, 0.02, 11), size: vec3(8, 0.1, 22), color: #52525a, tag: "road", collide: false})
|
||||
|
||||
// ------------------------------------------------------------------- houses
|
||||
// `find_model` returns DISTINCT models. Ask for several and place a different
|
||||
// one at each lot — five identical houses read as wallpaper however good the
|
||||
// model is. Four, not more: this kit runs out of houses and starts returning
|
||||
// lot pieces, one of which is a swimming pool.
|
||||
let houses = game.find_model("suburban house building", {count: 4, spread: "variants"})
|
||||
|
||||
game.model(houses[0], {pos: vec3(-30, 0, -10), yaw: 0, scale: 4.2, tag: "house"})
|
||||
game.model(houses[1], {pos: vec3(-19, 0, -10), yaw: 0, scale: 4.5, tag: "house"})
|
||||
game.model(houses[2], {pos: vec3(-10, 0, -10), yaw: 0, scale: 4.2, tag: "house"})
|
||||
game.model(houses[3], {pos: vec3(0.5, 0, -10), yaw: 0, scale: 4.5, tag: "house"})
|
||||
game.model(houses[0], {pos: vec3(10, 0, -10), yaw: 0, scale: 4.2, tag: "house"})
|
||||
game.model(houses[1], {pos: vec3(19, 0, -10), yaw: 0, scale: 4.5, tag: "house"})
|
||||
game.model(houses[2], {pos: vec3(30, 0, -10), yaw: 0, scale: 4.2, tag: "house"})
|
||||
// North side, set well back: the third-person camera sits about 9 units behind
|
||||
// you, so a row any closer is in the shot rather than behind it.
|
||||
game.model(houses[3], {pos: vec3(-25, 0, 26), yaw: 3.14159, scale: 4.0, tag: "house"})
|
||||
game.model(houses[0], {pos: vec3(-14.5, 0, 26), yaw: 3.14159, scale: 4.4, tag: "house"})
|
||||
game.model(houses[1], {pos: vec3(14.5, 0, 26), yaw: 3.14159, scale: 4.0, tag: "house"})
|
||||
game.model(houses[2], {pos: vec3(25, 0, 26), yaw: 3.14159, scale: 4.4, tag: "house"})
|
||||
|
||||
// ------------------------------------------------------------------ scenery
|
||||
let trees = game.find_model("tree pine", {count: 4, spread: "kinds"})
|
||||
game.model(trees[0], {pos: vec3(-34, 0, -26), scale: 7, tag: "tree"})
|
||||
game.model(trees[1], {pos: vec3(-24, 0, -30), scale: 6, tag: "tree"})
|
||||
game.model(trees[2], {pos: vec3(12, 0, -28), scale: 7, tag: "tree"})
|
||||
game.model(trees[3], {pos: vec3(28, 0, -24), scale: 6, tag: "tree"})
|
||||
game.model(trees[0], {pos: vec3(38, 0, 8), scale: 7, tag: "tree"})
|
||||
|
||||
// --------------------------------------------------------------------- cars
|
||||
// `spread: "kinds"` gives one model per FAMILY — one of each type. "variants"
|
||||
// would give six of the same kind, which is right for a terrace of houses and
|
||||
// wrong for traffic.
|
||||
//
|
||||
// Every one of these is a real vehicle: walk up to any of them and the engine
|
||||
// offers to let you in. Cars need no `game.interactable` — the prompt is
|
||||
// derived from the car itself.
|
||||
let cars = game.find_model("ambulance delivery taxi police sedan hatchback", {count: 6, spread: "kinds"})
|
||||
|
||||
game.car({pos: vec3(-6, 1.2, 1.6), model: cars[0], player: true})
|
||||
game.car({pos: vec3(-24, 1.2, 5.2), model: cars[1]})
|
||||
game.car({pos: vec3(-13, 1.2, 5.2), model: cars[2]})
|
||||
game.car({pos: vec3(7.5, 1.2, 5.2), model: cars[3]})
|
||||
game.car({pos: vec3(22, 1.2, 5.2), model: cars[4]})
|
||||
game.car({pos: vec3(16.5, 1.2, -4.5), model: cars[5], rot_y: 3.14159})
|
||||
|
||||
// ------------------------------------------------------------------- people
|
||||
// A bearded hero on the full hero rig. `player_character` is the whole
|
||||
// third-person binding — walker, follow camera, and getting in and out of
|
||||
// cars — with crafted defaults you never have to name.
|
||||
let heroes = game.find_model("barbarian", {count: 1, rigged: true})
|
||||
let me = game.player_character({pos: vec3(-6, 2, 9.5), model: heroes[0]})
|
||||
game.label(me, "You")
|
||||
|
||||
// Townsfolk who wander their own patch.
|
||||
let folk = game.find_model("character person", {count: 4, spread: "kinds", rigged: true})
|
||||
let a = game.character({pos: vec3(-14, 2, 8), model: folk[0], tag: "villager"})
|
||||
game.wander(a, {home: vec3(-14, 0, 8), range: 14, speed: 2.0})
|
||||
let b = game.character({pos: vec3(4, 2, 10), model: folk[1], tag: "villager"})
|
||||
game.wander(b, {home: vec3(4, 0, 10), range: 14, speed: 2.2})
|
||||
let c = game.character({pos: vec3(18, 2, 6), model: folk[2], tag: "villager"})
|
||||
game.wander(c, {home: vec3(18, 0, 6), range: 12, speed: 1.9})
|
||||
let d = game.character({pos: vec3(-26, 2, 2), model: folk[3], tag: "villager"})
|
||||
game.wander(d, {home: vec3(-26, 0, 2), range: 12, speed: 2.1})
|
||||
|
||||
// -------------------------------------------------------------------- climb
|
||||
// A tower you spiral around rather than a line of pads: the thing you are
|
||||
// climbing stays in view, so your progress is legible from the ground.
|
||||
//
|
||||
// Three rules make a course feel fair rather than fiddly — every jump is
|
||||
// survivable from a standstill, the whole route is visible from the bottom,
|
||||
// and falling costs height rather than a life.
|
||||
game.box({pos: vec3(-36, 8, 34), size: vec3(6, 16, 6), color: #857569, tag: "tower"})
|
||||
|
||||
// Nine ledges, easing outward as they rise so the last steps are the boldest.
|
||||
game.box({pos: vec3(-30.1, 1.6, 36.5), size: vec3(4.2, 0.7, 4.2), color: #99855c, tag: "ledge"})
|
||||
game.box({pos: vec3(-31.3, 3.35, 40.4), size: vec3(4.2, 0.7, 4.2), color: #99855c, tag: "ledge"})
|
||||
game.box({pos: vec3(-35.6, 5.1, 42.3), size: vec3(4.2, 0.7, 4.2), color: #99855c, tag: "ledge"})
|
||||
game.box({pos: vec3(-40.4, 6.85, 41.1), size: vec3(4.2, 0.7, 4.2), color: #99855c, tag: "ledge"})
|
||||
game.box({pos: vec3(-43.6, 8.6, 37.2), size: vec3(4.2, 0.7, 4.2), color: #99855c, tag: "ledge"})
|
||||
game.box({pos: vec3(-44.0, 10.35, 32.1), size: vec3(4.2, 0.7, 4.2), color: #99855c, tag: "ledge"})
|
||||
game.box({pos: vec3(-41.4, 12.1, 27.6), size: vec3(4.2, 0.7, 4.2), color: #99855c, tag: "ledge"})
|
||||
game.box({pos: vec3(-36.6, 13.85, 25.4), size: vec3(4.2, 0.7, 4.2), color: #99855c, tag: "ledge"})
|
||||
game.box({pos: vec3(-31.4, 15.6, 26.5), size: vec3(4.2, 0.7, 4.2), color: #99855c, tag: "ledge"})
|
||||
|
||||
// Two gaps are bridged by moving platforms instead — the timing beats.
|
||||
let plat_x = game.box({pos: vec3(-25, 6.5, 32), size: vec3(4.6, 0.7, 4.6), color: #5999cc, tag: "lift", body: "kinematic"})
|
||||
let plat_y = game.box({pos: vec3(-39, 11, 45), size: vec3(4.6, 0.7, 4.6), color: #6bb385, tag: "lift", body: "kinematic"})
|
||||
|
||||
// The summit: wide, still, and obviously the end.
|
||||
game.box({pos: vec3(-36, 16.8, 34), size: vec3(8, 1, 8), color: #d9b34d, tag: "goal"})
|
||||
|
||||
// A ramp at the base, so the climb starts from the ground rather than from a
|
||||
// jump you have to already know is there.
|
||||
game.box({pos: vec3(-28, 1, 25), size: vec3(6, 2, 10), color: #b89a6b, tag: "ramp", shape: "wedge"})
|
||||
|
||||
// The lifts. Different periods, so they are never the same crossing twice.
|
||||
game.on_tick(|dt| {
|
||||
let t = game.time()
|
||||
game.set_vel(plat_x, vec3(cos(t * 0.55) * 4.2, 0, 0))
|
||||
game.set_vel(plat_y, vec3(0, cos(t * 0.42) * 2.6, 0))
|
||||
})
|
||||
|
||||
game.text("hint", "WASD move · mouse look · space jump", {anchor: "top_left"})
|
||||
|
|
@ -1,434 +0,0 @@
|
|||
//! The AI shell: which agent backend to talk to, and how an utterance gets
|
||||
//! there (game.md §"AI tiers").
|
||||
//!
|
||||
//! Two tiers, one surface. The local judge — when this device can run one —
|
||||
//! decides whether the mic even heard a request; the cloud agent is the only
|
||||
//! thing that writes code. Where there is no local compute, the text box IS
|
||||
//! the gate and typing goes straight through.
|
||||
|
||||
use crate::capability::{Capabilities, Tier};
|
||||
use crate::library::{classify_deterministic, Action, Librarian, Library, Manifest};
|
||||
use crate::pairing::{has_key, load_key, Provider};
|
||||
use makepad_ai::agent::{Agent, SessionConfig, StatelessBackendAdapter};
|
||||
use makepad_ai::backend::BackendConfig;
|
||||
use makepad_ai::backends::claude::ClaudeBackend;
|
||||
use makepad_ai::backends::claude_code::ClaudeCodeAgent;
|
||||
use makepad_ai::backends::gemini::GeminiBackend;
|
||||
use makepad_ai::backends::openai::OpenAiBackend;
|
||||
use makepad_converse::filter::{FilterDecision, PassthroughFilter, TranscriptFilter};
|
||||
use makepad_converse::pipeline::ConversePipeline;
|
||||
|
||||
/// Tools the authoring session may use. Without this the CLI is launched with
|
||||
/// `--tools ""` and the agent is chat-only: it answers, sounds correct, and
|
||||
/// never writes game.splash.
|
||||
pub const AUTHORING_TOOLS: &[&str] = &["Read", "Write", "Edit", "Glob", "Grep"];
|
||||
|
||||
/// Inline settings rather than a file in `cwd`: workspace settings are ignored
|
||||
/// until the user accepts a trust dialog, which no kid will ever see. Edits are
|
||||
/// confined to the game dir — `../**` is denied so a game cannot rewrite the
|
||||
/// library around it.
|
||||
pub const PERMISSION_POLICY: &str = r#"{"permissions":{
|
||||
"allow":["Read","Glob","Grep","Edit(./**)","Write(./**)"],
|
||||
"deny":["Bash","Edit(../**)","Write(../**)"]}}"#;
|
||||
|
||||
/// The session config for authoring one game in `dir`.
|
||||
pub fn authoring_session(dir: Option<String>, api: &str, model: Option<String>) -> SessionConfig {
|
||||
SessionConfig {
|
||||
cwd: dir,
|
||||
system_prompt: Some(system_prompt(api)),
|
||||
model,
|
||||
allowed_tools: AUTHORING_TOOLS.iter().map(|t| t.to_string()).collect(),
|
||||
permission_mode: Some("dontAsk".to_string()),
|
||||
settings_json: Some(PERMISSION_POLICY.to_string()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// A worked example is worth more than any amount of prose: this is the whole
|
||||
/// idiom — build the world at the top, configure engine blocks, drive it from
|
||||
/// one `on_tick` — in the shape we want back.
|
||||
const WORKED_EXAMPLE: &str = r#"let SPEED = 7.0
|
||||
|
||||
game.sky({})
|
||||
game.sun({time_of_day: 10.0})
|
||||
game.terrain({size: 160, cells: 129, smooth: true, seed: 3, amp: 8})
|
||||
|
||||
let hero = game.character({pos: vec3(0, 6, 0), color: #4a7fd6, player: true, view: "third"})
|
||||
game.label(hero, "You")
|
||||
|
||||
let pig = game.mover({pos: vec3(6, 6, 4), size: vec3(0.9, 0.7, 1.4), color: #ffb3c1, tag: "animal"})
|
||||
game.wander(pig, {home: vec3(6, 0, 4), range: 12, speed: 2.5})
|
||||
|
||||
let score = 0
|
||||
game.text("score", "Caught: 0", {anchor: "top_left"})
|
||||
|
||||
game.on_touch(|a, b| {
|
||||
if game.tag(b) == "animal" {
|
||||
score = score + 1
|
||||
game.text("score", "Caught: " + score)
|
||||
game.sfx("pickup")
|
||||
game.burst(game.pos(b), {kind: "spark", count: 12})
|
||||
game.remove(b)
|
||||
}
|
||||
})
|
||||
"#;
|
||||
|
||||
/// The system prompt is the whole game-authoring contract: the agent writes
|
||||
/// splash against the `game.*` verbs and nothing else.
|
||||
///
|
||||
/// The rules below are not style advice — each one is a failure the eval
|
||||
/// harness caught the model making (`tools/arcade_eval`). Types coerce
|
||||
/// silently in a few places, so a wrong literal produces a game that reads
|
||||
/// correctly and renders an empty world; saying so up front is cheaper than
|
||||
/// letting it discover that through an error loop.
|
||||
pub fn system_prompt(api: &str) -> String {
|
||||
format!(
|
||||
"You build small 3D games for kids in the Makepad Arcade engine.\n\
|
||||
You edit ONE file: game.splash. Write splash script that calls the \
|
||||
`game.*` verbs below — the engine owns physics, vehicles, characters, \
|
||||
AI brains and race logic, so never reimplement those.\n\
|
||||
Keep games short and readable. Prefer engine blocks (game.car, \
|
||||
game.character, game.plane, game.wander/chase/patrol, game.checkpoint/\
|
||||
race) over hand-written movement.\n\n\
|
||||
RULES THAT BREAK GAMES IF IGNORED:\n\
|
||||
1. Positions and sizes are `vec3(x, y, z)`. An array `[x, y, z]` is \
|
||||
NOT a position — it silently becomes vec3(0,0,0) and every object \
|
||||
stacks at the origin.\n\
|
||||
2. Colors are bare hex literals: `#ff8800`. A quoted string \
|
||||
\"#ff8800\" is NOT a color. If a digit is followed by `e` or `E`, use \
|
||||
the `#x` prefix (`#x2ecc71`, `#x1e1e2e`) — otherwise the tokenizer \
|
||||
reads it as scientific notation and the file will not parse.\n\
|
||||
3. `game.terrain` needs `smooth: true` for a landscape mesh. Without \
|
||||
it the engine spawns one static box per cell (cells:48 = 2304 \
|
||||
entities) which is slow and can destabilise physics. Keep `cells` \
|
||||
between 33 and 129.\n\
|
||||
4. Only use verbs from the list below. Inventing an option name is \
|
||||
warned about; inventing a verb is a hard error that stops the game.\n\
|
||||
5. Budget: aim well under ~400 entities. Prefer one `game.terrain` \
|
||||
over a field of boxes.\n\n\
|
||||
A COMPLETE GAME LOOKS LIKE THIS:\n```\n{WORKED_EXAMPLE}```\n\n\
|
||||
Verbs:\n{api}"
|
||||
)
|
||||
}
|
||||
|
||||
/// Which backend this device should use, and why — the reason is worth showing
|
||||
/// in settings, because "no key" and "no CLI" need different fixes.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum BackendChoice {
|
||||
Ready(Provider),
|
||||
/// A direct-API provider is selected but has no key yet: this device can
|
||||
/// still play and join, and in a hosted room it can still ASK the host's
|
||||
/// agent — it just cannot create on its own.
|
||||
NeedsKey(Provider),
|
||||
/// Nothing configured at all.
|
||||
None,
|
||||
}
|
||||
|
||||
/// Prefer a CLI agent when one exists (it runs its own tool loop and needs no
|
||||
/// key); otherwise fall back to whichever direct API has a key.
|
||||
pub fn choose_backend(preferred: Option<Provider>) -> BackendChoice {
|
||||
if let Some(p) = preferred {
|
||||
if p == Provider::ClaudeCode && !ClaudeCodeAgent::is_available() {
|
||||
// Asked for the CLI but it is not installed — say so rather than
|
||||
// silently using something else.
|
||||
return BackendChoice::None;
|
||||
}
|
||||
return if has_key(p) {
|
||||
BackendChoice::Ready(p)
|
||||
} else {
|
||||
BackendChoice::NeedsKey(p)
|
||||
};
|
||||
}
|
||||
if ClaudeCodeAgent::is_available() {
|
||||
return BackendChoice::Ready(Provider::ClaudeCode);
|
||||
}
|
||||
for p in [Provider::Anthropic, Provider::OpenAi, Provider::Gemini] {
|
||||
if has_key(p) {
|
||||
return BackendChoice::Ready(p);
|
||||
}
|
||||
}
|
||||
BackendChoice::None
|
||||
}
|
||||
|
||||
/// Default models per provider: small edits should not cost flagship prices
|
||||
/// (game.md: "default small edits to cheaper models").
|
||||
pub fn default_model(provider: Provider) -> &'static str {
|
||||
match provider {
|
||||
Provider::ClaudeCode => "claude-sonnet-5",
|
||||
Provider::Anthropic => "claude-sonnet-5",
|
||||
Provider::OpenAi => "gpt-5",
|
||||
Provider::Gemini => "gemini-2.5-flash",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_agent(provider: Provider, model: Option<String>) -> Option<Box<dyn Agent>> {
|
||||
let model = model.unwrap_or_else(|| default_model(provider).to_string());
|
||||
Some(match provider {
|
||||
// The CLI runs its own tool loop, so it is an Agent directly.
|
||||
Provider::ClaudeCode => Box::new(ClaudeCodeAgent::new()) as Box<dyn Agent>,
|
||||
// The HTTP backends are stateless; the adapter owns the session and
|
||||
// executes tool calls on our side (the Quest/mobile path).
|
||||
Provider::Anthropic => Box::new(StatelessBackendAdapter::new(Box::new(
|
||||
ClaudeBackend::new(BackendConfig::Claude {
|
||||
api_key: Some(load_key(provider)?),
|
||||
oauth_token: None,
|
||||
model,
|
||||
}),
|
||||
))) as Box<dyn Agent>,
|
||||
Provider::OpenAi => Box::new(StatelessBackendAdapter::new(Box::new(
|
||||
OpenAiBackend::new(BackendConfig::OpenAI {
|
||||
api_key: load_key(provider)?,
|
||||
model,
|
||||
base_url: None,
|
||||
reasoning_effort: None,
|
||||
}),
|
||||
))) as Box<dyn Agent>,
|
||||
Provider::Gemini => Box::new(StatelessBackendAdapter::new(Box::new(
|
||||
GeminiBackend::new(BackendConfig::Gemini {
|
||||
api_key: load_key(provider)?,
|
||||
model,
|
||||
}),
|
||||
))) as Box<dyn Agent>,
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the conversational pipeline for this device's tier.
|
||||
///
|
||||
/// The filter is constructed ON the worker thread (a local LLM session is not
|
||||
/// Send), which is why this takes capabilities rather than a built filter.
|
||||
pub fn build_pipeline(
|
||||
agent: Box<dyn Agent>,
|
||||
caps: &Capabilities,
|
||||
voice: &str,
|
||||
) -> ConversePipeline {
|
||||
let tier = caps.tier();
|
||||
#[cfg(feature = "local-llm")]
|
||||
let qwen = caps.qwen_model.clone();
|
||||
let make_filter = move || -> Box<dyn TranscriptFilter> {
|
||||
#[cfg(feature = "local-llm")]
|
||||
{
|
||||
if matches!(tier, Tier::Voice) {
|
||||
if let Some(path) = qwen {
|
||||
if let Some(f) = makepad_converse::qwen_filter::QwenFilter::new(&path) {
|
||||
return Box::new(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// No judge available: every transcript passes. Safe because in this
|
||||
// tier the mic is push-to-talk, so the human is the gate.
|
||||
let _ = tier;
|
||||
Box::new(PassthroughFilter)
|
||||
};
|
||||
ConversePipeline::new(agent, make_filter, voice)
|
||||
}
|
||||
|
||||
/// Route one utterance. Voice goes through the pipeline's filter; typed text
|
||||
/// bypasses it (typing IS the gate) — but BOTH go through the librarian first,
|
||||
/// so "play the racing game" never costs a cloud call.
|
||||
pub struct Router {
|
||||
pub tier: Tier,
|
||||
}
|
||||
|
||||
impl Router {
|
||||
/// Returns the action; `Action::AskAgent` means it must reach the cloud.
|
||||
pub fn route(
|
||||
&self,
|
||||
utterance: &str,
|
||||
library: &Library,
|
||||
current: Option<&Manifest>,
|
||||
librarian: Option<&mut dyn Librarian>,
|
||||
) -> Action {
|
||||
crate::library::route(utterance, library, current, librarian)
|
||||
}
|
||||
}
|
||||
|
||||
/// A transcript filter that defers to the deterministic librarian: anything
|
||||
/// the librarian can answer locally is SKIPped from the agent's point of view,
|
||||
/// because it never needs to reach the cloud at all.
|
||||
pub struct LibrarianGate {
|
||||
pub library: Library,
|
||||
pub current: Option<Manifest>,
|
||||
}
|
||||
|
||||
impl TranscriptFilter for LibrarianGate {
|
||||
fn judge(&mut self, utterance: &str, _recent_dialog: &[String]) -> FilterDecision {
|
||||
match classify_deterministic(utterance, &self.library, self.current.as_ref()) {
|
||||
Action::AskAgent(text) => FilterDecision::Forward { instruction: text },
|
||||
// Handled on-device; the cloud never sees it.
|
||||
other => FilterDecision::Drop {
|
||||
reason: format!("handled locally: {other:?}"),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_missing_key_is_reported_not_silently_swapped() {
|
||||
let _guard = crate::pairing::tests::KEY_STORE_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
// Point the key store at an empty dir so nothing is configured.
|
||||
let dir = std::env::temp_dir().join(format!("arcade-ai-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::env::set_var("ARCADE_CONFIG_DIR", &dir);
|
||||
for var in ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GOOGLE_API_KEY"] {
|
||||
std::env::remove_var(var);
|
||||
}
|
||||
assert_eq!(
|
||||
choose_backend(Some(Provider::Anthropic)),
|
||||
BackendChoice::NeedsKey(Provider::Anthropic)
|
||||
);
|
||||
std::env::set_var("ANTHROPIC_API_KEY", "sk-test");
|
||||
assert_eq!(
|
||||
choose_backend(Some(Provider::Anthropic)),
|
||||
BackendChoice::Ready(Provider::Anthropic)
|
||||
);
|
||||
std::env::remove_var("ANTHROPIC_API_KEY");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
std::env::remove_var("ARCADE_CONFIG_DIR");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_librarian_gate_keeps_local_requests_off_the_wire() {
|
||||
let library = Library {
|
||||
games: vec![crate::library::GameEntry {
|
||||
dir: std::path::PathBuf::from("/g/racing"),
|
||||
manifest: Manifest {
|
||||
name: "racing".into(),
|
||||
description: "cars on a track".into(),
|
||||
players: 4,
|
||||
knobs: vec![],
|
||||
},
|
||||
}],
|
||||
};
|
||||
let mut gate = LibrarianGate { library, current: None };
|
||||
assert!(matches!(
|
||||
gate.judge("play racing", &[]),
|
||||
FilterDecision::Drop { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
gate.judge("restart", &[]),
|
||||
FilterDecision::Drop { .. }
|
||||
));
|
||||
match gate.judge("add a giant ramp over the lake", &[]) {
|
||||
FilterDecision::Forward { instruction } => assert!(instruction.contains("ramp")),
|
||||
other => panic!("creative work must reach the agent, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_prompt_carries_the_verb_table() {
|
||||
let p = system_prompt(&makepad_game_script::api_text());
|
||||
assert!(p.contains("game.car"), "prompt must list the blocks");
|
||||
assert!(p.contains("game.checkpoint"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod pair_endpoint_tests {
|
||||
use crate::pair_server::{PairEvent, PairServer};
|
||||
use crate::pairing::{load_key, PairError, Pairing, Provider};
|
||||
use std::io::Write;
|
||||
use std::net::TcpStream;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Drive the real /pair endpoint over HTTP (not a browser): the right code
|
||||
/// stores a key, the wrong one stores nothing.
|
||||
///
|
||||
/// The server only answers while we poll it, so the client must not wait
|
||||
/// for a response — write, then pump until the event lands.
|
||||
#[test]
|
||||
fn pair_endpoint_accepts_the_right_code_and_refuses_the_wrong_one() {
|
||||
let _guard = crate::pairing::tests::KEY_STORE_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let dir = std::env::temp_dir().join(format!("arcade-pair-http-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::env::set_var("ARCADE_CONFIG_DIR", &dir);
|
||||
std::env::remove_var("OPENAI_API_KEY");
|
||||
|
||||
// The server cannot report its bound port back (the app shows a fixed
|
||||
// one), so claim a free port first and hand it over.
|
||||
let port = {
|
||||
let probe = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
probe.local_addr().unwrap().port()
|
||||
};
|
||||
let mut server = PairServer::start(Provider::OpenAi, 4242, port).expect("pair server");
|
||||
let code = server.pairing.code.clone();
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
|
||||
// Hold the connection open across the pump: dropping it early races
|
||||
// the server's body read.
|
||||
let conn = post(port, "code=0000&key=sk-should-not-store");
|
||||
let events = pump(&mut server);
|
||||
drop(conn);
|
||||
assert_eq!(
|
||||
events,
|
||||
vec![PairEvent::Rejected(PairError::WrongCode)],
|
||||
"wrong code must be rejected"
|
||||
);
|
||||
assert_eq!(load_key(Provider::OpenAi), None, "a wrong code stored a key");
|
||||
|
||||
let conn = post(port, &format!("code={code}&key=sk-correct-value"));
|
||||
let events = pump(&mut server);
|
||||
drop(conn);
|
||||
assert_eq!(events, vec![PairEvent::Stored(Provider::OpenAi)]);
|
||||
assert_eq!(
|
||||
load_key(Provider::OpenAi).as_deref(),
|
||||
Some("sk-correct-value")
|
||||
);
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
std::env::remove_var("ARCADE_CONFIG_DIR");
|
||||
}
|
||||
|
||||
/// Write the request and hand back the live socket; the response needs our
|
||||
/// own poll loop, so the caller must hold this until after pumping.
|
||||
fn post(port: u16, body: &str) -> TcpStream {
|
||||
let mut s = TcpStream::connect(("127.0.0.1", port)).expect("connect");
|
||||
s.set_write_timeout(Some(Duration::from_secs(2))).ok();
|
||||
// Headers and body go in SEPARATE writes on purpose: the server's
|
||||
// header parser wraps the socket in a BufReader, which swallows any
|
||||
// body bytes that arrive in the same segment (platform bug, noted in
|
||||
// the M4 report) — browsers happen to split them, so this mirrors a
|
||||
// real client rather than papering over it.
|
||||
let head = format!(
|
||||
"POST /pair HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
body.len()
|
||||
);
|
||||
s.write_all(head.as_bytes()).unwrap();
|
||||
s.flush().unwrap();
|
||||
std::thread::sleep(Duration::from_millis(120));
|
||||
s.write_all(body.as_bytes()).unwrap();
|
||||
s.flush().unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn pump(server: &mut PairServer) -> Vec<PairEvent> {
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
let events = server.poll();
|
||||
if !events.is_empty() {
|
||||
return events;
|
||||
}
|
||||
if Instant::now() > deadline {
|
||||
return events;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pairing_page_is_served_and_self_contained() {
|
||||
let p = Pairing::new(Provider::Anthropic, 7);
|
||||
let html = p.page_html();
|
||||
// No external requests: a key must never transit a third party.
|
||||
assert!(!html.contains("http://") && !html.contains("https://"), "{html}");
|
||||
assert!(html.contains("action=/pair"));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,260 +0,0 @@
|
|||
//! The bridge from the script's audio queue to the synth.
|
||||
//!
|
||||
//! `game_script` never touches a synth — it queues [`AudioRequest`]s and the
|
||||
//! host decides what they sound like (that is the hook that lets the crate
|
||||
//! stay free of an audio dependency). This module is Arcade's half: drain the
|
||||
//! queue each frame, resolve positional requests against the local listener,
|
||||
//! and push voices.
|
||||
//!
|
||||
//! Positional audio is Local tier by construction (game.md): the listener is
|
||||
//! *this* device's camera, so two players in the same room hear the same game
|
||||
//! differently and none of it reaches the wire.
|
||||
|
||||
use crate::synth;
|
||||
use makepad_game_script::audio3d::{place, Listener};
|
||||
use makepad_game_script::dispatch::{AudioRequest, ToneWave};
|
||||
|
||||
fn wave(w: ToneWave) -> synth::Wave {
|
||||
match w {
|
||||
ToneWave::Sine => synth::Wave::Sine,
|
||||
ToneWave::Square => synth::Wave::Square,
|
||||
ToneWave::Saw => synth::Wave::Saw,
|
||||
ToneWave::Triangle => synth::Wave::Triangle,
|
||||
ToneWave::Noise => synth::Wave::Noise,
|
||||
}
|
||||
}
|
||||
|
||||
/// Play one request. Returns false when a named sound was not in the bank —
|
||||
/// the caller logs it, because a silent typo costs an agent a whole test cycle.
|
||||
pub fn play(request: &AudioRequest, listener: &Listener) -> bool {
|
||||
match request {
|
||||
AudioRequest::Sfx { name, pitch } => synth::play_named(name, *pitch),
|
||||
AudioRequest::SfxAt {
|
||||
name,
|
||||
pitch,
|
||||
at,
|
||||
range,
|
||||
} => {
|
||||
let placement = place(listener, *at, *range);
|
||||
// Out of range: nothing is queued at all, so a busy world does not
|
||||
// burn its 24 voices on sounds the player cannot hear.
|
||||
if placement.gain <= 0.0 {
|
||||
return true;
|
||||
}
|
||||
synth::play_named_at(name, *pitch, placement.gain, placement.pan)
|
||||
}
|
||||
AudioRequest::Beep {
|
||||
freq,
|
||||
to,
|
||||
ms,
|
||||
wave: w,
|
||||
gain,
|
||||
} => {
|
||||
synth::beep(*freq, *to, *ms / 1000.0, wave(*w), *gain, 0.0);
|
||||
true
|
||||
}
|
||||
AudioRequest::Jingle { notes, ms } => {
|
||||
synth::jingle(notes, *ms / 1000.0, synth::Wave::Triangle, 0.22);
|
||||
true
|
||||
}
|
||||
AudioRequest::Tone {
|
||||
id,
|
||||
freq,
|
||||
wave: w,
|
||||
gain,
|
||||
} => {
|
||||
synth::tone(*id, *freq, wave(*w), *gain);
|
||||
true
|
||||
}
|
||||
AudioRequest::ToneSet { id, freq, gain } => {
|
||||
synth::tone_set(*id, *freq, *gain);
|
||||
true
|
||||
}
|
||||
AudioRequest::ToneStop { id } => {
|
||||
synth::tone_stop(*id);
|
||||
true
|
||||
}
|
||||
AudioRequest::StopAllTones => {
|
||||
synth::stop_all_tones();
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain a frame's worth of requests. Unknown names are collected rather than
|
||||
/// logged here, so the caller can route them wherever it routes diagnostics.
|
||||
pub fn play_all(requests: &[AudioRequest], listener: &Listener) -> Vec<String> {
|
||||
let mut unknown = Vec::new();
|
||||
for request in requests {
|
||||
if !play(request, listener) {
|
||||
let name = match request {
|
||||
AudioRequest::Sfx { name, .. } | AudioRequest::SfxAt { name, .. } => name.clone(),
|
||||
_ => continue,
|
||||
};
|
||||
unknown.push(name);
|
||||
}
|
||||
}
|
||||
unknown
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use makepad_widgets::makepad_platform::audio::AudioBuffer;
|
||||
use makepad_widgets::*;
|
||||
|
||||
fn v(x: f32, y: f32, z: f32) -> Vec3f {
|
||||
Vec3f { x, y, z }
|
||||
}
|
||||
|
||||
fn buffer() -> AudioBuffer {
|
||||
let mut b = AudioBuffer::new_with_size(256, 2);
|
||||
b.zero();
|
||||
b
|
||||
}
|
||||
|
||||
fn peaks(buf: &AudioBuffer) -> (f32, f32) {
|
||||
let p = |c: usize| {
|
||||
buf.channel(c)
|
||||
.iter()
|
||||
.fold(0.0f32, |acc, s| acc.max(s.abs()))
|
||||
};
|
||||
(p(0), p(1))
|
||||
}
|
||||
|
||||
/// Facing -z at the origin, so +x is to the listener's right.
|
||||
fn listener() -> Listener {
|
||||
Listener::from_yaw(v(0.0, 0.0, 0.0), 0.0)
|
||||
}
|
||||
|
||||
fn render(requests: &[AudioRequest]) -> (f32, f32) {
|
||||
synth::reset();
|
||||
play_all(requests, &listener());
|
||||
let mut buf = buffer();
|
||||
synth::mix_into(&mut buf, 44100.0);
|
||||
let out = peaks(&buf);
|
||||
synth::reset();
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_queued_sfx_reaches_the_mixer() {
|
||||
let _guard = synth::SYNTH_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let (l, r) = render(&[AudioRequest::Sfx {
|
||||
name: "jump".into(),
|
||||
pitch: 1.0,
|
||||
}]);
|
||||
assert!(l > 0.0 && r > 0.0, "2D sfx is audible in both channels");
|
||||
assert_eq!(l, r, "and centred");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sfx_at_pans_by_direction_and_fades_with_distance() {
|
||||
let _guard = synth::SYNTH_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let at = |x: f32, z: f32| AudioRequest::SfxAt {
|
||||
name: "jump".into(),
|
||||
pitch: 1.0,
|
||||
at: v(x, 0.0, z),
|
||||
range: 40.0,
|
||||
};
|
||||
|
||||
let (l, r) = render(&[at(10.0, 0.0)]);
|
||||
assert!(r > l, "a sound to the right is louder on the right");
|
||||
|
||||
let (l, r) = render(&[at(-10.0, 0.0)]);
|
||||
assert!(l > r, "and mirrored on the left");
|
||||
|
||||
// Straight ahead is centred; further away is quieter.
|
||||
let (near_l, near_r) = render(&[at(0.0, -4.0)]);
|
||||
assert_eq!(near_l, near_r, "dead ahead is centred");
|
||||
let (far_l, _) = render(&[at(0.0, -30.0)]);
|
||||
assert!(far_l < near_l, "far {far_l} must be quieter than near {near_l}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sound_past_its_range_queues_no_voice_at_all() {
|
||||
let _guard = synth::SYNTH_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
synth::reset();
|
||||
play_all(
|
||||
&[AudioRequest::SfxAt {
|
||||
name: "jump".into(),
|
||||
pitch: 1.0,
|
||||
at: v(0.0, 0.0, -500.0),
|
||||
range: 40.0,
|
||||
}],
|
||||
&listener(),
|
||||
);
|
||||
assert_eq!(
|
||||
synth::live_counts().0,
|
||||
0,
|
||||
"out of range must not spend a voice slot"
|
||||
);
|
||||
synth::reset();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_name_is_reported_to_the_caller() {
|
||||
let _guard = synth::SYNTH_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
synth::reset();
|
||||
let unknown = play_all(
|
||||
&[
|
||||
AudioRequest::Sfx {
|
||||
name: "jump".into(),
|
||||
pitch: 1.0,
|
||||
},
|
||||
AudioRequest::Sfx {
|
||||
name: "nonsense".into(),
|
||||
pitch: 1.0,
|
||||
},
|
||||
],
|
||||
&listener(),
|
||||
);
|
||||
assert_eq!(unknown, vec!["nonsense".to_string()]);
|
||||
synth::reset();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tone_lifecycle_runs_through_the_queue() {
|
||||
let _guard = synth::SYNTH_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
synth::reset();
|
||||
play_all(
|
||||
&[AudioRequest::Tone {
|
||||
id: 3,
|
||||
freq: 220.0,
|
||||
wave: ToneWave::Saw,
|
||||
gain: 0.4,
|
||||
}],
|
||||
&listener(),
|
||||
);
|
||||
assert_eq!(synth::live_counts().1, 1);
|
||||
|
||||
// Retune, then stop: the tone releases rather than vanishing.
|
||||
play_all(
|
||||
&[
|
||||
AudioRequest::ToneSet {
|
||||
id: 3,
|
||||
freq: Some(440.0),
|
||||
gain: None,
|
||||
},
|
||||
AudioRequest::ToneStop { id: 3 },
|
||||
],
|
||||
&listener(),
|
||||
);
|
||||
for _ in 0..40 {
|
||||
let mut buf = buffer();
|
||||
synth::mix_into(&mut buf, 44100.0);
|
||||
}
|
||||
assert_eq!(synth::live_counts().1, 0);
|
||||
synth::reset();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,262 +0,0 @@
|
|||
//! Turning an agent's file edit into a coedit transaction.
|
||||
//!
|
||||
//! The agent writes `game.splash` on disk (a CLI agent does it with its own
|
||||
//! tools; the HTTP backends do it through the adapter). That edit is **not**
|
||||
//! the source of truth: it is a *proposal* against whatever generation the
|
||||
//! turn started from. This module reads the file back, submits it to the
|
||||
//! intent log, and hands out whatever the merge decided.
|
||||
//!
|
||||
//! Going through the log even for the local agent is the whole point — a
|
||||
//! shortcut here would leave the merge path exercised only by remote authors,
|
||||
//! which is exactly backwards (game.md §"Collaborative editing").
|
||||
|
||||
use crate::coedit::{CoeditBridge, PendingEval};
|
||||
use makepad_game_net::protocol::CoeditResponse;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// What the host should do after a proposal was merged.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum Applied {
|
||||
/// The head moved: evaluate this source and hot-reload.
|
||||
Reload(PendingEval),
|
||||
/// Nothing to do (the edit was refused, rebased, or changed nothing) —
|
||||
/// the responses say why.
|
||||
Nothing,
|
||||
}
|
||||
|
||||
pub struct Authoring {
|
||||
bridge: CoeditBridge,
|
||||
path: PathBuf,
|
||||
/// The generation the running turn was written against. Captured when the
|
||||
/// turn starts, so an edit that lands after somebody else's is correctly
|
||||
/// treated as stale rather than silently clobbering them.
|
||||
turn_base: u64,
|
||||
}
|
||||
|
||||
impl Authoring {
|
||||
/// Start from whatever is on disk; a missing file is an empty game.
|
||||
pub fn new(path: impl Into<PathBuf>) -> Self {
|
||||
let path = path.into();
|
||||
let source = std::fs::read_to_string(&path).unwrap_or_default();
|
||||
let bridge = CoeditBridge::new(source);
|
||||
Self {
|
||||
bridge,
|
||||
path,
|
||||
turn_base: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
pub fn bridge(&mut self) -> &mut CoeditBridge {
|
||||
&mut self.bridge
|
||||
}
|
||||
|
||||
pub fn head_generation(&self) -> u64 {
|
||||
self.bridge.head_generation()
|
||||
}
|
||||
|
||||
/// Called when a turn begins: everything the agent writes during it is a
|
||||
/// proposal against this generation.
|
||||
pub fn begin_turn(&mut self) {
|
||||
self.turn_base = self.bridge.head_generation();
|
||||
}
|
||||
|
||||
/// Read the agent's edit off disk and submit it as a transaction.
|
||||
///
|
||||
/// Returns `Nothing` when the file is unreadable or unchanged — an agent
|
||||
/// that replies without editing is normal, not an error.
|
||||
pub fn submit_from_disk(&mut self, intent: &str) -> Applied {
|
||||
let Ok(source) = std::fs::read_to_string(&self.path) else {
|
||||
return Applied::Nothing;
|
||||
};
|
||||
self.bridge.submit_local(intent, self.turn_base, &source);
|
||||
match self.bridge.process() {
|
||||
Some(pending) => {
|
||||
// The merge may have produced a source that differs from what
|
||||
// the agent wrote (someone else's hunk landed too). Disk must
|
||||
// match the head, or the next mtime poll would re-propose the
|
||||
// agent's stale text as if it were new.
|
||||
let _ = std::fs::write(&self.path, &pending.source);
|
||||
Applied::Reload(pending)
|
||||
}
|
||||
None => Applied::Nothing,
|
||||
}
|
||||
}
|
||||
|
||||
/// A remote author's transactions arrive through the same queue; process
|
||||
/// them and reload if the head moved.
|
||||
pub fn process_remote(&mut self) -> Applied {
|
||||
match self.bridge.process() {
|
||||
Some(pending) => {
|
||||
let _ = std::fs::write(&self.path, &pending.source);
|
||||
Applied::Reload(pending)
|
||||
}
|
||||
None => Applied::Nothing,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn note_eval_ok(&mut self, generation: u64) {
|
||||
self.bridge.note_eval_ok(generation);
|
||||
}
|
||||
|
||||
pub fn note_eval_error(&mut self, generation: u64, message: impl Into<String>) {
|
||||
self.bridge.note_eval_error(generation, message);
|
||||
}
|
||||
|
||||
/// Responses addressed to the local agent — eval errors it must fix,
|
||||
/// rebases it must re-derive.
|
||||
pub fn drain_local(&mut self) -> Vec<CoeditResponse> {
|
||||
self.bridge.drain_local()
|
||||
}
|
||||
|
||||
/// Turn one response into a line for the chat. `None` for responses that
|
||||
/// are pure bookkeeping (an accept needs no announcement).
|
||||
pub fn describe(response: &CoeditResponse) -> Option<String> {
|
||||
match response {
|
||||
CoeditResponse::Accepted { .. } => None,
|
||||
CoeditResponse::EvalError {
|
||||
message,
|
||||
last_good_generation,
|
||||
..
|
||||
} => Some(format!(
|
||||
"That edit didn't load, so the game is still running the last \
|
||||
version that worked (v{last_good_generation}):\n{message}"
|
||||
)),
|
||||
CoeditResponse::Rebase { generation, .. } => Some(format!(
|
||||
"Someone else changed the game while you were working \
|
||||
(now at v{generation}) — re-read it and try that edit again."
|
||||
)),
|
||||
CoeditResponse::Refused { reason } => {
|
||||
Some(format!("That edit was refused: {reason:?}"))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use makepad_game_net::endpoint::HostEvent;
|
||||
use makepad_game_net::protocol::{CoeditRequest, PlayerId};
|
||||
|
||||
const GAME: &str = "cars {\n count: 4\n}\nrules {\n laps: 3\n}\n";
|
||||
|
||||
fn temp_game(tag: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"arcade-authoring-{}-{tag}",
|
||||
std::process::id()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("game.splash");
|
||||
std::fs::write(&path, GAME).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
/// The load-bearing test: a typed request must reach the intent log, not
|
||||
/// a private path that skips merging.
|
||||
#[test]
|
||||
fn an_agent_edit_becomes_a_transaction_in_the_intent_log() {
|
||||
let path = temp_game("submit");
|
||||
let mut authoring = Authoring::new(&path);
|
||||
assert_eq!(authoring.head_generation(), 0);
|
||||
|
||||
authoring.begin_turn();
|
||||
// The agent edits the file, exactly as a CLI agent would.
|
||||
std::fs::write(&path, GAME.replace("count: 4", "count: 8")).unwrap();
|
||||
|
||||
let applied = authoring.submit_from_disk("more cars");
|
||||
let Applied::Reload(pending) = applied else {
|
||||
panic!("an edit must move the head, got {applied:?}");
|
||||
};
|
||||
assert_eq!(pending.generation, 1, "the log advanced by one generation");
|
||||
assert!(pending.source.contains("count: 8"));
|
||||
assert_eq!(
|
||||
authoring.head_generation(),
|
||||
1,
|
||||
"the head is the log's, not the file's"
|
||||
);
|
||||
std::fs::remove_dir_all(path.parent().unwrap()).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reply_with_no_edit_proposes_nothing() {
|
||||
let path = temp_game("noedit");
|
||||
let mut authoring = Authoring::new(&path);
|
||||
authoring.begin_turn();
|
||||
// Agent answered a question without touching the file.
|
||||
assert_eq!(authoring.submit_from_disk("what is this game?"), Applied::Nothing);
|
||||
assert_eq!(authoring.head_generation(), 0);
|
||||
std::fs::remove_dir_all(path.parent().unwrap()).ok();
|
||||
}
|
||||
|
||||
/// A remote author landing mid-turn must rebase the local agent, and the
|
||||
/// file on disk must end up matching the head — otherwise the next mtime
|
||||
/// poll would re-propose the agent's stale text.
|
||||
#[test]
|
||||
fn a_local_edit_that_loses_a_race_is_rebased_and_disk_follows_the_head() {
|
||||
let path = temp_game("race");
|
||||
let mut authoring = Authoring::new(&path);
|
||||
authoring.begin_turn();
|
||||
|
||||
// A remote Claude edits the same region first.
|
||||
authoring.bridge().absorb(
|
||||
&[HostEvent::Coedit {
|
||||
player: PlayerId(5),
|
||||
req: CoeditRequest::Submit {
|
||||
intent: "eight cars".into(),
|
||||
base_generation: 0,
|
||||
source: GAME.replace("count: 4", "count: 8"),
|
||||
},
|
||||
}],
|
||||
0.0,
|
||||
);
|
||||
let applied = authoring.process_remote();
|
||||
assert!(matches!(applied, Applied::Reload(_)), "remote edit lands");
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&path).unwrap(),
|
||||
GAME.replace("count: 4", "count: 8"),
|
||||
"disk follows the head so the watcher does not re-propose"
|
||||
);
|
||||
|
||||
// Now the local agent's conflicting edit, written against generation 0.
|
||||
std::fs::write(&path, GAME.replace("count: 4", "count: 2")).unwrap();
|
||||
authoring.submit_from_disk("two cars");
|
||||
|
||||
let responses = authoring.drain_local();
|
||||
let rebase = responses
|
||||
.iter()
|
||||
.find(|r| matches!(r, CoeditResponse::Rebase { .. }))
|
||||
.expect("the local agent must be rebased, not silently merged");
|
||||
let text = Authoring::describe(rebase).expect("a rebase is worth saying out loud");
|
||||
assert!(text.contains("try that edit again"), "{text}");
|
||||
std::fs::remove_dir_all(path.parent().unwrap()).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_eval_error_comes_back_as_a_line_for_the_chat() {
|
||||
let path = temp_game("evalerr");
|
||||
let mut authoring = Authoring::new(&path);
|
||||
authoring.note_eval_ok(0);
|
||||
authoring.begin_turn();
|
||||
std::fs::write(&path, GAME.replace("laps: 3", "laps: oops")).unwrap();
|
||||
|
||||
let Applied::Reload(pending) = authoring.submit_from_disk("longer race") else {
|
||||
panic!("the edit should have been accepted before it failed to eval");
|
||||
};
|
||||
authoring.drain_local();
|
||||
authoring.note_eval_error(pending.generation, "game.splash:5:9: expected a number");
|
||||
|
||||
let responses = authoring.drain_local();
|
||||
let text = responses
|
||||
.iter()
|
||||
.find_map(Authoring::describe)
|
||||
.expect("an eval error must reach the proposer");
|
||||
assert!(text.contains("expected a number"), "{text}");
|
||||
assert!(text.contains("last version that worked"), "{text}");
|
||||
std::fs::remove_dir_all(path.parent().unwrap()).ok();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,435 +0,0 @@
|
|||
//! The library browser: what is installed, what the registry offers, and the
|
||||
//! install / uninstall / publish buttons between them (game.md §"Game format
|
||||
//! and sharing").
|
||||
//!
|
||||
//! The packaging and verification live in `makepad-game-pkg`; this is the
|
||||
//! surface for them. Two rules it enforces on the way through:
|
||||
//!
|
||||
//! - A downloaded package is verified against the digest the index promised
|
||||
//! *before* it is unpacked — `Registry::download` does that, and this never
|
||||
//! installs bytes that skipped it.
|
||||
//! - An installed game is marked `Trust::Downloaded`, so when it runs its
|
||||
//! isolate is capability-stripped. A game from a stranger is untrusted code.
|
||||
|
||||
use makepad_game_pkg::{
|
||||
library::Library as PkgLibrary, registry::IndexEntry, PkgError, Registry,
|
||||
};
|
||||
use makepad_widgets::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
script_mod! {
|
||||
use mod.prelude.widgets_internal.*
|
||||
use mod.widgets.*
|
||||
|
||||
mod.widgets.ArcadeBrowserBase = #(ArcadeBrowser::register_widget(vm))
|
||||
mod.widgets.ArcadeBrowser = set_type_default() do mod.widgets.ArcadeBrowserBase{
|
||||
width: Fill
|
||||
height: Fill
|
||||
flow: Down
|
||||
spacing: 8
|
||||
padding: theme.space_2
|
||||
|
||||
View {
|
||||
width: Fill
|
||||
height: Fit
|
||||
flow: Right
|
||||
spacing: 8
|
||||
align: Align{y: 0.5}
|
||||
|
||||
Label {
|
||||
text: "Games"
|
||||
draw_text.text_style: theme.font_bold{font_size: 15}
|
||||
}
|
||||
View { width: Fill height: 1 }
|
||||
registry_input := TextInput {
|
||||
width: 190
|
||||
height: 32
|
||||
empty_text: "registry host:port"
|
||||
draw_text.text_style.font_size: 11
|
||||
}
|
||||
refresh_button := Button { text: "Browse" }
|
||||
publish_button := Button { text: "Publish current" }
|
||||
}
|
||||
|
||||
status_label := Label {
|
||||
text: ""
|
||||
draw_text.text_style: theme.font_regular{font_size: 11}
|
||||
}
|
||||
|
||||
list := PortalList {
|
||||
width: Fill
|
||||
height: Fill
|
||||
|
||||
Row := View {
|
||||
width: Fill
|
||||
height: Fit
|
||||
flow: Right
|
||||
spacing: 8
|
||||
padding: theme.space_1
|
||||
align: Align{y: 0.5}
|
||||
|
||||
title := Label {
|
||||
width: Fill
|
||||
text: ""
|
||||
draw_text.text_style: theme.font_regular{font_size: 12}
|
||||
}
|
||||
play_button := Button { text: "Play" }
|
||||
action_button := Button { text: "" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One row: either something installed, or something the registry offers.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Row {
|
||||
Installed {
|
||||
slug: String,
|
||||
name: String,
|
||||
description: String,
|
||||
players_max: u32,
|
||||
},
|
||||
Available {
|
||||
entry: IndexEntry,
|
||||
},
|
||||
}
|
||||
|
||||
impl Row {
|
||||
fn title(&self) -> String {
|
||||
match self {
|
||||
Row::Installed {
|
||||
name,
|
||||
description,
|
||||
players_max,
|
||||
..
|
||||
} => {
|
||||
let players = if *players_max > 1 {
|
||||
format!(" · up to {players_max} players")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
if description.is_empty() {
|
||||
format!("{name}{players}")
|
||||
} else {
|
||||
format!("{name} — {description}{players}")
|
||||
}
|
||||
}
|
||||
Row::Available { entry } => {
|
||||
let size = format!(" · {} KB", (entry.size / 1024).max(1));
|
||||
if entry.description.is_empty() {
|
||||
format!("{}{size}", entry.name)
|
||||
} else {
|
||||
format!("{} — {}{size}", entry.name, entry.description)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn action(&self) -> &'static str {
|
||||
match self {
|
||||
Row::Installed { .. } => "Remove",
|
||||
Row::Available { .. } => "Install",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum BrowserAction {
|
||||
/// The user asked to play an installed game.
|
||||
Play(String),
|
||||
None,
|
||||
}
|
||||
|
||||
#[derive(Script, ScriptHook, Widget)]
|
||||
pub struct ArcadeBrowser {
|
||||
#[source]
|
||||
source: ScriptObjectRef,
|
||||
#[deref]
|
||||
view: View,
|
||||
#[rust]
|
||||
rows: Vec<Row>,
|
||||
#[rust]
|
||||
initialized: bool,
|
||||
#[rust]
|
||||
registry_base: String,
|
||||
/// Set by the app so Publish knows what "current" means.
|
||||
#[rust]
|
||||
current_slug: Option<String>,
|
||||
}
|
||||
|
||||
/// Where games live. Shared with the librarian's view of the same directory.
|
||||
pub fn games_root() -> PathBuf {
|
||||
std::env::var("ARCADE_HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| {
|
||||
PathBuf::from(std::env::var("HOME").unwrap_or_default()).join("arcade-games")
|
||||
})
|
||||
}
|
||||
|
||||
impl ArcadeBrowser {
|
||||
pub fn set_current_game(&mut self, slug: Option<String>) {
|
||||
self.current_slug = slug;
|
||||
}
|
||||
|
||||
fn library(&self) -> PkgLibrary {
|
||||
PkgLibrary::new(games_root())
|
||||
}
|
||||
|
||||
fn set_status(&mut self, cx: &mut Cx, text: &str) {
|
||||
self.label(cx, ids!(status_label)).set_text(cx, text);
|
||||
}
|
||||
|
||||
/// Installed games, always shown — the registry is optional, the local
|
||||
/// library is not.
|
||||
fn reload_installed(&mut self) {
|
||||
let installed: Vec<Row> = self
|
||||
.library()
|
||||
.list()
|
||||
.into_iter()
|
||||
.map(|e| Row::Installed {
|
||||
slug: e.slug,
|
||||
name: e.manifest.name,
|
||||
description: e.manifest.description,
|
||||
players_max: e.manifest.players_max,
|
||||
})
|
||||
.collect();
|
||||
// Keep any registry rows that are not already installed.
|
||||
let slugs: Vec<String> = installed
|
||||
.iter()
|
||||
.filter_map(|r| match r {
|
||||
Row::Installed { slug, .. } => Some(slug.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let available: Vec<Row> = self
|
||||
.rows
|
||||
.drain(..)
|
||||
.filter(|r| match r {
|
||||
Row::Available { entry } => !slugs.contains(&entry.id),
|
||||
_ => false,
|
||||
})
|
||||
.collect();
|
||||
self.rows = installed;
|
||||
self.rows.extend(available);
|
||||
}
|
||||
|
||||
fn browse_registry(&mut self, cx: &mut Cx) {
|
||||
let base = self.text_input(cx, ids!(registry_input)).text();
|
||||
let base = if base.trim().is_empty() {
|
||||
self.registry_base.clone()
|
||||
} else {
|
||||
base.trim().to_string()
|
||||
};
|
||||
if base.is_empty() {
|
||||
self.set_status(cx, "enter a registry address first");
|
||||
return;
|
||||
}
|
||||
self.registry_base = base.clone();
|
||||
|
||||
// Blocking, deliberately: a registry index is a few KB on a LAN or a
|
||||
// fast CDN, and a background task here would need a whole async story
|
||||
// for a button nobody holds down. If this ever serves a slow remote,
|
||||
// it moves to the task pump.
|
||||
match Registry::new(&base).index() {
|
||||
Ok(entries) => {
|
||||
let n = entries.len();
|
||||
self.reload_installed();
|
||||
let installed: Vec<String> = self
|
||||
.rows
|
||||
.iter()
|
||||
.filter_map(|r| match r {
|
||||
Row::Installed { slug, .. } => Some(slug.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
for entry in entries {
|
||||
if !installed.contains(&entry.id) {
|
||||
self.rows.push(Row::Available { entry });
|
||||
}
|
||||
}
|
||||
self.set_status(cx, &format!("{n} game(s) in the registry"));
|
||||
}
|
||||
Err(e) => self.set_status(cx, &format!("registry unreachable: {e}")),
|
||||
}
|
||||
self.redraw(cx);
|
||||
}
|
||||
|
||||
fn install(&mut self, cx: &mut Cx, entry: IndexEntry) {
|
||||
let reg = Registry::new(&self.registry_base);
|
||||
// download() verifies the digest; a mismatch never reaches the unpacker.
|
||||
match reg.download(&entry).map_err(|e| e.to_string()).and_then(|bytes| {
|
||||
self.library()
|
||||
.install(&entry.id, &bytes)
|
||||
.map_err(|e: PkgError| e.to_string())
|
||||
}) {
|
||||
Ok(installed) => {
|
||||
self.set_status(
|
||||
cx,
|
||||
&format!("installed {} — runs sandboxed", installed.manifest.name),
|
||||
);
|
||||
self.reload_installed();
|
||||
}
|
||||
Err(e) => self.set_status(cx, &format!("install failed: {e}")),
|
||||
}
|
||||
self.redraw(cx);
|
||||
}
|
||||
|
||||
fn uninstall(&mut self, cx: &mut Cx, slug: &str) {
|
||||
match self.library().uninstall(slug) {
|
||||
Ok(()) => {
|
||||
self.set_status(cx, &format!("removed {slug}"));
|
||||
self.reload_installed();
|
||||
}
|
||||
Err(e) => self.set_status(cx, &format!("could not remove {slug}: {e}")),
|
||||
}
|
||||
self.redraw(cx);
|
||||
}
|
||||
|
||||
fn publish(&mut self, cx: &mut Cx) {
|
||||
let Some(slug) = self.current_slug.clone() else {
|
||||
self.set_status(cx, "no game loaded to publish");
|
||||
return;
|
||||
};
|
||||
if self.registry_base.is_empty() {
|
||||
self.set_status(cx, "enter a registry address first");
|
||||
return;
|
||||
}
|
||||
let packed = match self.library().pack(&slug) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
self.set_status(cx, &format!("could not pack {slug}: {e}"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
match Registry::new(&self.registry_base).publish(&packed) {
|
||||
Ok(id) => self.set_status(cx, &format!("published as {id} ({} KB)", packed.len() / 1024)),
|
||||
Err(e) => self.set_status(cx, &format!("publish failed: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain what the user clicked. Returns Play when an installed title was
|
||||
/// chosen, so the app can hand it to the ScriptHost.
|
||||
pub fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) -> BrowserAction {
|
||||
if self.button(cx, ids!(refresh_button)).clicked(actions) {
|
||||
self.browse_registry(cx);
|
||||
}
|
||||
if self.button(cx, ids!(publish_button)).clicked(actions) {
|
||||
self.publish(cx);
|
||||
}
|
||||
|
||||
// Row buttons: the list groups its items' actions under its own uid, so
|
||||
// this resolves which row was clicked without per-row widget ids.
|
||||
let list = self.portal_list(cx, ids!(list));
|
||||
let mut hit: Option<(usize, bool)> = None;
|
||||
for (index, item) in list.items_with_actions(actions) {
|
||||
if item.button(cx, ids!(action_button)).clicked(actions) {
|
||||
hit = Some((index, true));
|
||||
} else if item.button(cx, ids!(play_button)).clicked(actions) {
|
||||
hit = Some((index, false));
|
||||
}
|
||||
}
|
||||
let Some((index, is_action)) = hit else {
|
||||
return BrowserAction::None;
|
||||
};
|
||||
let Some(row) = self.rows.get(index).cloned() else {
|
||||
return BrowserAction::None;
|
||||
};
|
||||
match (row, is_action) {
|
||||
(Row::Available { entry }, true) => self.install(cx, entry),
|
||||
(Row::Installed { slug, .. }, true) => self.uninstall(cx, &slug),
|
||||
// Clicking the title of something installed plays it.
|
||||
(Row::Installed { slug, .. }, false) => return BrowserAction::Play(slug),
|
||||
(Row::Available { .. }, false) => {}
|
||||
}
|
||||
BrowserAction::None
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for ArcadeBrowser {
|
||||
fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep {
|
||||
if !self.initialized {
|
||||
self.initialized = true;
|
||||
self.reload_installed();
|
||||
if self.registry_base.is_empty() {
|
||||
if let Ok(base) = std::env::var("ARCADE_REGISTRY") {
|
||||
self.registry_base = base;
|
||||
}
|
||||
}
|
||||
}
|
||||
let rows = self.rows.clone();
|
||||
while let Some(item) = self.view.draw_walk(cx, scope, walk).step() {
|
||||
if let Some(mut list) = item.borrow_mut::<PortalList>() {
|
||||
list.set_item_range(cx, 0, rows.len());
|
||||
while let Some(index) = list.next_visible_item(cx) {
|
||||
let Some(row) = rows.get(index) else { continue };
|
||||
let item = list.item(cx, index, id!(Row));
|
||||
item.label(cx, ids!(title)).set_text(cx, &row.title());
|
||||
item.button(cx, ids!(action_button))
|
||||
.set_text(cx, row.action());
|
||||
// Only something already installed can be played.
|
||||
item.button(cx, ids!(play_button))
|
||||
.set_visible(cx, matches!(row, Row::Installed { .. }));
|
||||
item.draw_all(cx, &mut Scope::empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
DrawStep::done()
|
||||
}
|
||||
|
||||
fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
|
||||
self.view.handle_event(cx, event, scope);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use makepad_game_pkg::registry::IndexEntry;
|
||||
|
||||
#[test]
|
||||
fn rows_describe_themselves_for_the_list() {
|
||||
let installed = Row::Installed {
|
||||
slug: "speedway".into(),
|
||||
name: "Speedway".into(),
|
||||
description: "race, 4 cars".into(),
|
||||
players_max: 4,
|
||||
};
|
||||
let text = installed.title();
|
||||
assert!(text.contains("Speedway"));
|
||||
assert!(text.contains("race, 4 cars"));
|
||||
assert!(text.contains("4 players"));
|
||||
assert_eq!(installed.action(), "Remove");
|
||||
|
||||
let available = Row::Available {
|
||||
entry: IndexEntry {
|
||||
id: "dogfight".into(),
|
||||
name: "Dogfight".into(),
|
||||
description: "planes".into(),
|
||||
size: 4096,
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
assert!(available.title().contains("Dogfight"));
|
||||
assert!(available.title().contains("4 KB"));
|
||||
assert_eq!(available.action(), "Install");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_player_game_does_not_advertise_a_player_count() {
|
||||
let row = Row::Installed {
|
||||
slug: "solo".into(),
|
||||
name: "Solo".into(),
|
||||
description: String::new(),
|
||||
players_max: 1,
|
||||
};
|
||||
assert_eq!(row.title(), "Solo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn games_root_follows_the_env_override() {
|
||||
// ARCADE_HOME is what the tests and the app both use to relocate the
|
||||
// library; without it we fall back to the home directory.
|
||||
let root = games_root();
|
||||
assert!(root.is_absolute() || std::env::var("ARCADE_HOME").is_ok());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,211 +0,0 @@
|
|||
//! What this device can actually run (game.md §"Per-platform capability
|
||||
//! fallback").
|
||||
//!
|
||||
//! The local models (Silero/Whisper/Qwen) need a compute backend that does not
|
||||
//! exist everywhere. ONE code path, two configurations: where the chain
|
||||
//! exists, the mic is a talk-gate; where it does not, the text box IS the
|
||||
//! gate and typing goes straight to the cloud agent.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Which local models are present and runnable on this device.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Default)]
|
||||
pub struct Capabilities {
|
||||
/// A GPU/CPU backend makepad-ggml can actually run local models on.
|
||||
pub local_compute: bool,
|
||||
/// Silero VAD weights — else the mic falls back to an RMS gate.
|
||||
pub vad_model: Option<PathBuf>,
|
||||
/// Whisper weights — without these there is no transcription, so no voice.
|
||||
pub whisper_model: Option<PathBuf>,
|
||||
/// Local judge/librarian LLM — without it, typing is the gate and the
|
||||
/// librarian degrades to fuzzy name matching.
|
||||
pub qwen_model: Option<PathBuf>,
|
||||
/// Kokoro voice pack — else replies are text only.
|
||||
pub tts_voice: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// The tier the app actually runs in. Ordered: each tier is a superset of the
|
||||
/// one below for user-visible capability.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Tier {
|
||||
/// mic → VAD → Whisper → local judge → agent, spoken replies.
|
||||
Voice,
|
||||
/// mic → (RMS or VAD) → Whisper → agent, no local judge: every utterance
|
||||
/// that clears the gate costs a cloud call, so the mic is push-to-talk.
|
||||
VoiceUnfiltered,
|
||||
/// No local models: the text box is the talk-gate.
|
||||
Chatbox,
|
||||
}
|
||||
|
||||
impl Tier {
|
||||
pub fn shows_mic(self) -> bool {
|
||||
!matches!(self, Tier::Chatbox)
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Tier::Voice => "voice (VAD + local judge)",
|
||||
Tier::VoiceUnfiltered => "voice (push-to-talk, no local judge)",
|
||||
Tier::Chatbox => "chatbox (no local model compute)",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn find(candidates: &[PathBuf]) -> Option<PathBuf> {
|
||||
candidates.iter().find(|p| p.exists()).cloned()
|
||||
}
|
||||
|
||||
fn repo_root() -> PathBuf {
|
||||
// Models live untracked at the repo root (see game.md §assets rule).
|
||||
std::env::var("MAKEPAD_ROOT")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| PathBuf::from("."))
|
||||
}
|
||||
|
||||
impl Capabilities {
|
||||
/// Probe the device. Env overrides win so a test can force any tier.
|
||||
pub fn detect() -> Self {
|
||||
let root = repo_root();
|
||||
Self {
|
||||
local_compute: local_compute_available(),
|
||||
vad_model: env_or(
|
||||
"MAKEPAD_VAD_MODEL",
|
||||
&[root.join("silero_vad.onnx")],
|
||||
),
|
||||
whisper_model: env_or(
|
||||
"MAKEPAD_WHISPER_MODEL",
|
||||
&[root.join("ggml-large-v3-turbo.bin")],
|
||||
),
|
||||
qwen_model: env_or(
|
||||
"ARCADE_QWEN_MODEL",
|
||||
&[
|
||||
root.join("local/models/Qwen3.5-4B-Q5_K_M.gguf"),
|
||||
root.join("local/models/Qwen3.5-9B-UD-Q4_K_XL.gguf"),
|
||||
],
|
||||
),
|
||||
tts_voice: env_or("ARCADE_TTS_VOICE", &[root.join("bm_fable.mkvoice")]),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tier(&self) -> Tier {
|
||||
if !self.local_compute || self.whisper_model.is_none() {
|
||||
return Tier::Chatbox;
|
||||
}
|
||||
if self.qwen_model.is_some() {
|
||||
Tier::Voice
|
||||
} else {
|
||||
Tier::VoiceUnfiltered
|
||||
}
|
||||
}
|
||||
|
||||
/// One startup line naming the tier and exactly what is missing — the
|
||||
/// difference between "voice is broken" and "you did not download X".
|
||||
pub fn report(&self) -> String {
|
||||
let mut missing = Vec::new();
|
||||
if !self.local_compute {
|
||||
missing.push("local model compute");
|
||||
}
|
||||
if self.whisper_model.is_none() {
|
||||
missing.push("whisper weights");
|
||||
}
|
||||
if self.vad_model.is_none() {
|
||||
missing.push("silero VAD (RMS gate instead)");
|
||||
}
|
||||
if self.qwen_model.is_none() {
|
||||
missing.push("local judge/librarian LLM");
|
||||
}
|
||||
if self.tts_voice.is_none() {
|
||||
missing.push("kokoro voice (text replies only)");
|
||||
}
|
||||
if missing.is_empty() {
|
||||
format!("arcade: tier {} — all local models present", self.tier().label())
|
||||
} else {
|
||||
format!(
|
||||
"arcade: tier {} — missing: {}",
|
||||
self.tier().label(),
|
||||
missing.join(", ")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn env_or(var: &str, candidates: &[PathBuf]) -> Option<PathBuf> {
|
||||
if let Ok(v) = std::env::var(var) {
|
||||
if v.is_empty() {
|
||||
return None; // explicit "pretend it is absent", for tests
|
||||
}
|
||||
let p = PathBuf::from(v);
|
||||
return p.exists().then_some(p);
|
||||
}
|
||||
find(candidates)
|
||||
}
|
||||
|
||||
/// Local model compute: a metal/CUDA-class backend the ggml stack can use.
|
||||
/// `ARCADE_NO_LOCAL=1` forces the chatbox tier (the Quest/mobile shape) on a
|
||||
/// machine that does have it.
|
||||
pub fn local_compute_available() -> bool {
|
||||
if std::env::var("ARCADE_NO_LOCAL").is_ok() {
|
||||
return false;
|
||||
}
|
||||
// The local-llm feature is what actually links the compute backend; on
|
||||
// platforms where it is off, there is nothing to run models on.
|
||||
cfg!(feature = "local-llm") && cfg!(any(target_os = "macos", target_os = "linux", target_os = "windows"))
|
||||
}
|
||||
|
||||
/// Where games live: one directory per game.
|
||||
pub fn arcade_home() -> PathBuf {
|
||||
std::env::var("ARCADE_HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| {
|
||||
let home = std::env::var("HOME").unwrap_or_default();
|
||||
Path::new(&home).join("arcade-games")
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn caps(compute: bool, whisper: bool, qwen: bool) -> Capabilities {
|
||||
Capabilities {
|
||||
local_compute: compute,
|
||||
vad_model: None,
|
||||
whisper_model: whisper.then(|| PathBuf::from("w")),
|
||||
qwen_model: qwen.then(|| PathBuf::from("q")),
|
||||
tts_voice: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_falls_back_piece_by_piece() {
|
||||
// Everything present -> full voice chain.
|
||||
assert_eq!(caps(true, true, true).tier(), Tier::Voice);
|
||||
// No judge -> voice still works, but every utterance costs a call.
|
||||
assert_eq!(caps(true, true, false).tier(), Tier::VoiceUnfiltered);
|
||||
// No whisper -> nothing to transcribe, so typing is the gate.
|
||||
assert_eq!(caps(true, false, true).tier(), Tier::Chatbox);
|
||||
// No compute (Quest/mobile/web) -> chatbox regardless of files.
|
||||
assert_eq!(caps(false, true, true).tier(), Tier::Chatbox);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_text_box_exists_in_every_tier_but_the_mic_does_not() {
|
||||
assert!(caps(true, true, true).tier().shows_mic());
|
||||
assert!(caps(true, true, false).tier().shows_mic());
|
||||
assert!(!caps(false, true, true).tier().shows_mic());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_names_what_is_missing() {
|
||||
let r = caps(true, true, false).report();
|
||||
assert!(r.contains("local judge"), "{r}");
|
||||
let full = Capabilities {
|
||||
local_compute: true,
|
||||
vad_model: Some(PathBuf::from("v")),
|
||||
whisper_model: Some(PathBuf::from("w")),
|
||||
qwen_model: Some(PathBuf::from("q")),
|
||||
tts_voice: Some(PathBuf::from("t")),
|
||||
};
|
||||
assert!(full.report().contains("all local models present"), "{}", full.report());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,308 +0,0 @@
|
|||
//! The conversation: what the player said, what the AI is saying back, and
|
||||
//! what the running game is complaining about.
|
||||
//!
|
||||
//! The model is a process-global so the list widget can read it during draw
|
||||
//! without threading a scope through (the same shape gamemaker uses). It holds
|
||||
//! only presentation state — every edit the AI proposes goes through the
|
||||
//! intent log in [`crate::authoring`], never from here.
|
||||
|
||||
use makepad_widgets::*;
|
||||
|
||||
pub static CHAT: std::sync::RwLock<ChatData> = std::sync::RwLock::new(ChatData {
|
||||
messages: Vec::new(),
|
||||
streaming_text: String::new(),
|
||||
activity: String::new(),
|
||||
is_streaming: false,
|
||||
last_delta: None,
|
||||
});
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||
pub enum ChatRole {
|
||||
User,
|
||||
Assistant,
|
||||
/// Engine-side trouble (a failed eval, a missing sound). Shown differently
|
||||
/// because the player did not say it and the AI did not either.
|
||||
System,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ChatMessage {
|
||||
pub role: ChatRole,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
pub struct ChatData {
|
||||
pub messages: Vec<ChatMessage>,
|
||||
pub streaming_text: String,
|
||||
/// What the agent is doing right now, e.g. "Changing the game". Pinned
|
||||
/// under the streaming reply so a long silent tool call still moves.
|
||||
pub activity: String,
|
||||
pub is_streaming: bool,
|
||||
pub last_delta: Option<std::time::Instant>,
|
||||
}
|
||||
|
||||
impl ChatData {
|
||||
pub fn push(role: ChatRole, text: impl Into<String>) {
|
||||
if let Ok(mut data) = CHAT.write() {
|
||||
data.messages.push(ChatMessage {
|
||||
role,
|
||||
text: text.into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn begin_stream() {
|
||||
if let Ok(mut data) = CHAT.write() {
|
||||
data.streaming_text.clear();
|
||||
data.is_streaming = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push_delta(text: &str) {
|
||||
if let Ok(mut data) = CHAT.write() {
|
||||
data.streaming_text.push_str(text);
|
||||
data.last_delta = Some(std::time::Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
/// Land the streamed reply as a message. Returns the number of items the
|
||||
/// list should scroll to.
|
||||
pub fn end_stream() -> usize {
|
||||
let Ok(mut data) = CHAT.write() else { return 0 };
|
||||
let text = std::mem::take(&mut data.streaming_text);
|
||||
if !text.trim().is_empty() {
|
||||
data.messages.push(ChatMessage {
|
||||
role: ChatRole::Assistant,
|
||||
text,
|
||||
});
|
||||
}
|
||||
data.is_streaming = false;
|
||||
data.activity.clear();
|
||||
data.messages.len()
|
||||
}
|
||||
|
||||
pub fn set_activity(text: &str) {
|
||||
if let Ok(mut data) = CHAT.write() {
|
||||
data.activity = text.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn item_count() -> usize {
|
||||
match CHAT.read() {
|
||||
Ok(data) => data.messages.len() + data.is_streaming as usize,
|
||||
Err(_) => 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
script_mod! {
|
||||
use mod.prelude.widgets_internal.*
|
||||
use mod.widgets.*
|
||||
|
||||
mod.widgets.ArcadeChatBase = #(ArcadeChat::register_widget(vm))
|
||||
mod.widgets.ArcadeChat = set_type_default() do mod.widgets.ArcadeChatBase {
|
||||
width: Fill
|
||||
height: Fill
|
||||
|
||||
list := PortalList {
|
||||
width: Fill
|
||||
height: Fill
|
||||
flow: Down
|
||||
drag_scrolling: false
|
||||
auto_tail: true
|
||||
smooth_tail: true
|
||||
selectable: true
|
||||
|
||||
User := RoundedView {
|
||||
width: Fill
|
||||
height: Fit
|
||||
margin: Inset{top: 6 bottom: 6 left: 40 right: 4}
|
||||
padding: Inset{left: 12 top: 8 right: 12 bottom: 8}
|
||||
show_bg: true
|
||||
draw_bg +: {
|
||||
color: #x2a3a5a
|
||||
radius: 8.0
|
||||
}
|
||||
body := Label {
|
||||
width: Fill
|
||||
height: Fit
|
||||
text: ""
|
||||
draw_text.color: #xe8eef8
|
||||
draw_text.text_style: theme.font_regular{font_size: 13}
|
||||
}
|
||||
}
|
||||
|
||||
Assistant := RoundedView {
|
||||
width: Fill
|
||||
height: Fit
|
||||
margin: Inset{top: 6 bottom: 6 left: 4 right: 40}
|
||||
padding: Inset{left: 12 top: 8 right: 12 bottom: 8}
|
||||
show_bg: true
|
||||
draw_bg +: {
|
||||
color: #x232330
|
||||
radius: 8.0
|
||||
}
|
||||
body := Label {
|
||||
width: Fill
|
||||
height: Fit
|
||||
text: ""
|
||||
draw_text.color: #xd8dee8
|
||||
draw_text.text_style: theme.font_regular{font_size: 13}
|
||||
}
|
||||
}
|
||||
|
||||
System := RoundedView {
|
||||
width: Fill
|
||||
height: Fit
|
||||
margin: Inset{top: 6 bottom: 6 left: 4 right: 4}
|
||||
padding: Inset{left: 12 top: 8 right: 12 bottom: 8}
|
||||
show_bg: true
|
||||
draw_bg +: {
|
||||
color: #x3a2a24
|
||||
radius: 8.0
|
||||
}
|
||||
body := Label {
|
||||
width: Fill
|
||||
height: Fit
|
||||
text: ""
|
||||
draw_text.color: #xe8c9a0
|
||||
draw_text.text_style: theme.font_regular{font_size: 12}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Script, ScriptHook, Widget)]
|
||||
pub struct ArcadeChat {
|
||||
#[deref]
|
||||
view: View,
|
||||
}
|
||||
|
||||
impl Widget for ArcadeChat {
|
||||
fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep {
|
||||
let Ok(data) = CHAT.read() else {
|
||||
return DrawStep::done();
|
||||
};
|
||||
while let Some(item) = self.view.draw_walk(cx, scope, walk).step() {
|
||||
let portal = item.as_portal_list();
|
||||
let Some(mut list) = portal.borrow_mut() else {
|
||||
continue;
|
||||
};
|
||||
let msg_count = data.messages.len();
|
||||
list.set_item_range(cx, 0, msg_count + data.is_streaming as usize);
|
||||
|
||||
while let Some(item_id) = list.next_visible_item(cx) {
|
||||
// The streaming reply is a virtual item past the end.
|
||||
if data.is_streaming && item_id == msg_count {
|
||||
let mut text = data.streaming_text.clone();
|
||||
if !data.activity.is_empty() {
|
||||
if !text.is_empty() {
|
||||
text.push_str("\n\n");
|
||||
}
|
||||
text.push_str(&data.activity);
|
||||
}
|
||||
let widget = list.item(cx, item_id, id!(Assistant));
|
||||
widget.label(cx, ids!(body)).set_text(cx, &text);
|
||||
widget.draw_all_unscoped(cx);
|
||||
continue;
|
||||
}
|
||||
let Some(msg) = data.messages.get(item_id) else {
|
||||
continue;
|
||||
};
|
||||
let template = match msg.role {
|
||||
ChatRole::User => id!(User),
|
||||
ChatRole::Assistant => id!(Assistant),
|
||||
ChatRole::System => id!(System),
|
||||
};
|
||||
let widget = list.item(cx, item_id, template);
|
||||
widget.label(cx, ids!(body)).set_text(cx, &msg.text);
|
||||
widget.draw_all_unscoped(cx);
|
||||
}
|
||||
}
|
||||
DrawStep::done()
|
||||
}
|
||||
|
||||
fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
|
||||
self.view.handle_event(cx, event, scope);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The chat model is a process-global; tests must not interleave.
|
||||
pub static CHAT_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
fn clear() {
|
||||
let mut data = CHAT.write().unwrap();
|
||||
data.messages.clear();
|
||||
data.streaming_text.clear();
|
||||
data.activity.clear();
|
||||
data.is_streaming = false;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_turn_streams_then_lands_as_one_message() {
|
||||
let _guard = CHAT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
clear();
|
||||
ChatData::push(ChatRole::User, "make it rain");
|
||||
ChatData::begin_stream();
|
||||
assert_eq!(ChatData::item_count(), 2, "the streaming bubble counts");
|
||||
|
||||
ChatData::push_delta("Adding ");
|
||||
ChatData::push_delta("rain!");
|
||||
assert_eq!(CHAT.read().unwrap().streaming_text, "Adding rain!");
|
||||
|
||||
let count = ChatData::end_stream();
|
||||
assert_eq!(count, 2);
|
||||
let data = CHAT.read().unwrap();
|
||||
assert!(!data.is_streaming);
|
||||
assert_eq!(data.messages[1].role, ChatRole::Assistant);
|
||||
assert_eq!(data.messages[1].text, "Adding rain!");
|
||||
drop(data);
|
||||
clear();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_reply_does_not_leave_a_blank_bubble() {
|
||||
let _guard = CHAT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
clear();
|
||||
ChatData::begin_stream();
|
||||
ChatData::push_delta(" \n ");
|
||||
ChatData::end_stream();
|
||||
assert!(
|
||||
CHAT.read().unwrap().messages.is_empty(),
|
||||
"whitespace-only replies are dropped"
|
||||
);
|
||||
clear();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_are_injected_as_system_messages() {
|
||||
let _guard = CHAT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
clear();
|
||||
ChatData::push(ChatRole::System, "game.splash:4: unknown verb 'wobble'");
|
||||
let data = CHAT.read().unwrap();
|
||||
assert_eq!(data.messages[0].role, ChatRole::System);
|
||||
assert!(data.messages[0].text.contains("wobble"));
|
||||
drop(data);
|
||||
clear();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_rides_along_with_the_streaming_reply() {
|
||||
let _guard = CHAT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
clear();
|
||||
ChatData::begin_stream();
|
||||
ChatData::set_activity("Changing the game");
|
||||
assert_eq!(CHAT.read().unwrap().activity, "Changing the game");
|
||||
// Completing the turn clears it: a finished reply must not keep
|
||||
// claiming the AI is still working.
|
||||
ChatData::push_delta("Done!");
|
||||
ChatData::end_stream();
|
||||
assert!(CHAT.read().unwrap().activity.is_empty());
|
||||
clear();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,452 +0,0 @@
|
|||
//! Bridging the room's Claudes to the intent log.
|
||||
//!
|
||||
//! The host's own agent is **not** privileged: `submit_local` and a remote
|
||||
//! `CoeditRequest::Submit` both land in the same queue and are merged by the
|
||||
//! same rules. A shortcut for local edits would leave the merge path exercised
|
||||
//! only by the rarer case, which is exactly backwards — the local agent is the
|
||||
//! one editing every day (game.md §"Collaborative editing").
|
||||
//!
|
||||
//! Routing is decided in one place, [`CoeditBridge::route`]: a response for
|
||||
//! [`AuthorId::LOCAL`] goes to the local agent, anything else goes out on the
|
||||
//! wire addressed to its author. Nothing about an edit is broadcast to the room.
|
||||
|
||||
use makepad_game_coedit::{AuthorId, CoeditHost, LeaseOutcome, Limits, Outcome, Refusal, Transaction};
|
||||
use makepad_game_net::endpoint::HostEvent;
|
||||
use makepad_game_net::protocol::{
|
||||
CoeditChange, CoeditRefusal, CoeditRequest, CoeditResponse, PlayerId,
|
||||
};
|
||||
|
||||
/// Remote players map to authors above [`AuthorId::LOCAL`] so a client can
|
||||
/// never impersonate the host's agent by holding player id 0.
|
||||
fn author_of(player: PlayerId) -> AuthorId {
|
||||
AuthorId(player.0.wrapping_add(1))
|
||||
}
|
||||
|
||||
fn player_of(author: AuthorId) -> Option<PlayerId> {
|
||||
(author != AuthorId::LOCAL).then(|| PlayerId(author.0.wrapping_sub(1)))
|
||||
}
|
||||
|
||||
fn refusal_wire(reason: Refusal) -> CoeditRefusal {
|
||||
match reason {
|
||||
Refusal::EmptyIntent => CoeditRefusal::EmptyIntent,
|
||||
Refusal::IntentTooLong => CoeditRefusal::IntentTooLong,
|
||||
Refusal::SourceTooLong => CoeditRefusal::SourceTooLong,
|
||||
Refusal::UnknownBase => CoeditRefusal::UnknownBase,
|
||||
Refusal::QueueFull => CoeditRefusal::QueueFull,
|
||||
Refusal::NoChange => CoeditRefusal::NoChange,
|
||||
}
|
||||
}
|
||||
|
||||
/// A generation the host should evaluate and hot-reload.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PendingEval {
|
||||
pub generation: u64,
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
pub struct CoeditBridge {
|
||||
host: CoeditHost,
|
||||
outbox: Vec<(PlayerId, CoeditResponse)>,
|
||||
local: Vec<CoeditResponse>,
|
||||
to_eval: Option<PendingEval>,
|
||||
}
|
||||
|
||||
impl CoeditBridge {
|
||||
pub fn new(initial_source: impl Into<String>) -> Self {
|
||||
Self::with_limits(initial_source, Limits::default())
|
||||
}
|
||||
|
||||
pub fn with_limits(initial_source: impl Into<String>, limits: Limits) -> Self {
|
||||
Self {
|
||||
host: CoeditHost::with_limits(initial_source, limits),
|
||||
outbox: Vec::new(),
|
||||
local: Vec::new(),
|
||||
to_eval: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn head_generation(&self) -> u64 {
|
||||
self.host.head().number
|
||||
}
|
||||
|
||||
pub fn head_source(&self) -> &str {
|
||||
&self.host.head().source
|
||||
}
|
||||
|
||||
/// The source the world should be running: the newest generation that
|
||||
/// actually evaluated.
|
||||
pub fn last_good_source(&self) -> &str {
|
||||
self.host.last_good_source()
|
||||
}
|
||||
|
||||
/// Send one response to whoever it belongs to.
|
||||
fn route(&mut self, author: AuthorId, res: CoeditResponse) {
|
||||
match player_of(author) {
|
||||
Some(player) => self.outbox.push((player, res)),
|
||||
None => self.local.push(res),
|
||||
}
|
||||
}
|
||||
|
||||
/// The host's own agent proposing an edit — same queue, same merge.
|
||||
pub fn submit_local(&mut self, intent: &str, base_generation: u64, source: &str) {
|
||||
self.enqueue(AuthorId::LOCAL, intent, base_generation, source);
|
||||
}
|
||||
|
||||
fn enqueue(&mut self, author: AuthorId, intent: &str, base_generation: u64, source: &str) {
|
||||
let tx = Transaction {
|
||||
author,
|
||||
intent: intent.to_string(),
|
||||
base_generation,
|
||||
source: source.to_string(),
|
||||
};
|
||||
if let Err(reason) = self.host.enqueue(tx) {
|
||||
self.route(
|
||||
author,
|
||||
CoeditResponse::Refused {
|
||||
reason: refusal_wire(reason),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed one pump's host events.
|
||||
pub fn absorb(&mut self, events: &[HostEvent], now: f64) {
|
||||
for event in events {
|
||||
match event {
|
||||
HostEvent::Coedit { player, req } => {
|
||||
let author = author_of(*player);
|
||||
match req {
|
||||
CoeditRequest::GetBase => {
|
||||
let head = self.host.head();
|
||||
let res = CoeditResponse::Base {
|
||||
generation: head.number,
|
||||
source: head.source.clone(),
|
||||
};
|
||||
self.route(author, res);
|
||||
}
|
||||
CoeditRequest::Submit {
|
||||
intent,
|
||||
base_generation,
|
||||
source,
|
||||
} => self.enqueue(author, intent, *base_generation, source),
|
||||
CoeditRequest::AcquireLease { region, ttl } => {
|
||||
let outcome = self.host.leases().acquire(author, region, *ttl, now);
|
||||
let res = match outcome {
|
||||
LeaseOutcome::Granted { expires_at } => {
|
||||
CoeditResponse::LeaseGranted {
|
||||
region: region.clone(),
|
||||
expires_at,
|
||||
}
|
||||
}
|
||||
LeaseOutcome::Held { by, expires_at } => {
|
||||
CoeditResponse::LeaseHeld {
|
||||
region: region.clone(),
|
||||
by: by.0,
|
||||
expires_at,
|
||||
}
|
||||
}
|
||||
LeaseOutcome::TooMany => CoeditResponse::LeaseRefused {
|
||||
region: region.clone(),
|
||||
},
|
||||
};
|
||||
self.route(author, res);
|
||||
}
|
||||
CoeditRequest::ReleaseLease { region } => {
|
||||
self.host.leases().release(author, region);
|
||||
}
|
||||
}
|
||||
}
|
||||
HostEvent::Left { player, .. } => {
|
||||
self.host.forget_author(author_of(*player));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge everything queued. Returns the generation to evaluate, if the head
|
||||
/// moved — one eval per pump, because the world can only run one source.
|
||||
pub fn process(&mut self) -> Option<PendingEval> {
|
||||
let outcomes = self.host.process_queue();
|
||||
for (author, outcome) in outcomes {
|
||||
match outcome {
|
||||
Outcome::Accepted { generation, source } => {
|
||||
self.route(author, CoeditResponse::Accepted { generation });
|
||||
self.to_eval = Some(PendingEval { generation, source });
|
||||
}
|
||||
Outcome::Rebase {
|
||||
generation,
|
||||
base_source,
|
||||
intervening,
|
||||
conflict_regions,
|
||||
} => {
|
||||
let intervening = intervening
|
||||
.into_iter()
|
||||
.map(|change| CoeditChange {
|
||||
generation: change.generation,
|
||||
author: change.author.0,
|
||||
intent: change.intent,
|
||||
hunks: change
|
||||
.hunks
|
||||
.into_iter()
|
||||
.map(|h| (h.base_start as u32, h.removed as u32, h.added as u32))
|
||||
.collect(),
|
||||
})
|
||||
.collect();
|
||||
self.route(
|
||||
author,
|
||||
CoeditResponse::Rebase {
|
||||
generation,
|
||||
base_source,
|
||||
intervening,
|
||||
conflict_regions: conflict_regions as u32,
|
||||
},
|
||||
);
|
||||
}
|
||||
Outcome::Refused { reason } => self.route(
|
||||
author,
|
||||
CoeditResponse::Refused {
|
||||
reason: refusal_wire(reason),
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
self.to_eval.take()
|
||||
}
|
||||
|
||||
pub fn note_eval_ok(&mut self, generation: u64) {
|
||||
self.host.note_eval_ok(generation);
|
||||
}
|
||||
|
||||
/// The generation failed to load. The world stays on last-good and the
|
||||
/// author that proposed it hears about it — the room does not.
|
||||
pub fn note_eval_error(&mut self, generation: u64, message: impl Into<String>) {
|
||||
let Some(report) = self.host.note_eval_error(generation, message) else {
|
||||
return;
|
||||
};
|
||||
self.route(
|
||||
report.author,
|
||||
CoeditResponse::EvalError {
|
||||
generation: report.generation,
|
||||
message: report.message,
|
||||
last_good_generation: report.last_good_generation,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Responses for remote authors, ready for `Host::send_coedit`.
|
||||
pub fn drain_outbox(&mut self) -> Vec<(PlayerId, CoeditResponse)> {
|
||||
std::mem::take(&mut self.outbox)
|
||||
}
|
||||
|
||||
/// Responses for the host's own agent.
|
||||
pub fn drain_local(&mut self) -> Vec<CoeditResponse> {
|
||||
std::mem::take(&mut self.local)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const GAME: &str = "cars {\n count: 4\n}\nrules {\n laps: 3\n}\n";
|
||||
|
||||
fn submit_event(player: u64, base: u64, intent: &str, source: &str) -> HostEvent {
|
||||
HostEvent::Coedit {
|
||||
player: PlayerId(player),
|
||||
req: CoeditRequest::Submit {
|
||||
intent: intent.to_string(),
|
||||
base_generation: base,
|
||||
source: source.to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn edited(source: &str, from: &str, to: &str) -> String {
|
||||
source.replace(from, to)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_remote_submission_is_accepted_and_answered_to_its_author() {
|
||||
let mut bridge = CoeditBridge::new(GAME);
|
||||
let source = edited(GAME, "count: 4", "count: 8");
|
||||
bridge.absorb(&[submit_event(5, 0, "more cars", &source)], 0.0);
|
||||
|
||||
let pending = bridge.process().expect("head moved, so the host evaluates");
|
||||
assert_eq!(pending.generation, 1);
|
||||
assert_eq!(pending.source, source);
|
||||
|
||||
let outbox = bridge.drain_outbox();
|
||||
assert_eq!(outbox.len(), 1);
|
||||
assert_eq!(outbox[0].0, PlayerId(5), "addressed, not broadcast");
|
||||
assert_eq!(outbox[0].1, CoeditResponse::Accepted { generation: 1 });
|
||||
assert!(bridge.drain_local().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_local_agent_uses_the_same_queue_and_merge_as_a_remote_one() {
|
||||
let mut bridge = CoeditBridge::new(GAME);
|
||||
|
||||
// Remote lands first; the local agent wrote against the same old base.
|
||||
bridge.absorb(
|
||||
&[submit_event(5, 0, "more cars", &edited(GAME, "count: 4", "count: 8"))],
|
||||
0.0,
|
||||
);
|
||||
bridge.submit_local("longer race", 0, &edited(GAME, "laps: 3", "laps: 5"));
|
||||
bridge.process();
|
||||
|
||||
// Disjoint regions: both edits are in the head.
|
||||
assert!(bridge.head_source().contains("count: 8"));
|
||||
assert!(bridge.head_source().contains("laps: 5"));
|
||||
assert_eq!(bridge.head_generation(), 2);
|
||||
|
||||
let local = bridge.drain_local();
|
||||
assert_eq!(local, vec![CoeditResponse::Accepted { generation: 2 }]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_overlapping_local_edit_is_rebased_exactly_like_a_remote_one() {
|
||||
let mut bridge = CoeditBridge::new(GAME);
|
||||
bridge.absorb(
|
||||
&[submit_event(5, 0, "eight cars", &edited(GAME, "count: 4", "count: 8"))],
|
||||
0.0,
|
||||
);
|
||||
bridge.submit_local("two cars", 0, &edited(GAME, "count: 4", "count: 2"));
|
||||
bridge.process();
|
||||
|
||||
let local = bridge.drain_local();
|
||||
let [CoeditResponse::Rebase {
|
||||
generation,
|
||||
base_source,
|
||||
conflict_regions,
|
||||
..
|
||||
}] = &local[..]
|
||||
else {
|
||||
panic!("the local agent must be rebased too, got {local:?}");
|
||||
};
|
||||
assert_eq!(*generation, 1);
|
||||
assert_eq!(*conflict_regions, 1);
|
||||
assert!(base_source.contains("count: 8"), "handed the new base");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_eval_error_reaches_the_proposer_and_nobody_else() {
|
||||
let mut bridge = CoeditBridge::new(GAME);
|
||||
bridge.note_eval_ok(0);
|
||||
|
||||
bridge.absorb(
|
||||
&[submit_event(5, 0, "break it", &edited(GAME, "laps: 3", "laps: oops"))],
|
||||
0.0,
|
||||
);
|
||||
let pending = bridge.process().unwrap();
|
||||
bridge.drain_outbox();
|
||||
|
||||
bridge.note_eval_error(pending.generation, "game.splash:5:9: expected a number");
|
||||
|
||||
let outbox = bridge.drain_outbox();
|
||||
assert_eq!(outbox.len(), 1, "one recipient — the author");
|
||||
assert_eq!(outbox[0].0, PlayerId(5));
|
||||
let CoeditResponse::EvalError {
|
||||
generation,
|
||||
message,
|
||||
last_good_generation,
|
||||
} = &outbox[0].1
|
||||
else {
|
||||
panic!("expected an eval error, got {:?}", outbox[0].1);
|
||||
};
|
||||
assert_eq!(*generation, 1);
|
||||
assert_eq!(*last_good_generation, 0);
|
||||
assert!(message.contains("expected a number"));
|
||||
|
||||
assert!(bridge.drain_local().is_empty(), "the room is not told");
|
||||
assert_eq!(
|
||||
bridge.last_good_source(),
|
||||
GAME,
|
||||
"the world keeps running the last good source"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_client_cannot_impersonate_the_local_agent() {
|
||||
let mut bridge = CoeditBridge::new(GAME);
|
||||
// Player 0 would collide with AuthorId::LOCAL under a naive mapping.
|
||||
bridge.absorb(
|
||||
&[submit_event(0, 0, "sneaky", &edited(GAME, "count: 4", "count: 9"))],
|
||||
0.0,
|
||||
);
|
||||
bridge.process();
|
||||
|
||||
assert!(
|
||||
bridge.drain_local().is_empty(),
|
||||
"a remote submission must never be answered as local"
|
||||
);
|
||||
assert_eq!(bridge.drain_outbox().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leases_are_granted_reported_and_dropped_when_an_author_leaves() {
|
||||
let mut bridge = CoeditBridge::new(GAME);
|
||||
let acquire = |player: u64| HostEvent::Coedit {
|
||||
player: PlayerId(player),
|
||||
req: CoeditRequest::AcquireLease {
|
||||
region: "vehicles".to_string(),
|
||||
ttl: 30.0,
|
||||
},
|
||||
};
|
||||
|
||||
bridge.absorb(&[acquire(1)], 100.0);
|
||||
bridge.absorb(&[acquire(2)], 101.0);
|
||||
let outbox = bridge.drain_outbox();
|
||||
assert!(matches!(outbox[0].1, CoeditResponse::LeaseGranted { .. }));
|
||||
let CoeditResponse::LeaseHeld { by, .. } = outbox[1].1 else {
|
||||
panic!("second author must be told who holds it, got {:?}", outbox[1].1);
|
||||
};
|
||||
assert_eq!(by, author_of(PlayerId(1)).0);
|
||||
|
||||
bridge.absorb(
|
||||
&[HostEvent::Left {
|
||||
player: PlayerId(1),
|
||||
reason: makepad_game_net::protocol::LeaveReason::Explicit,
|
||||
}],
|
||||
102.0,
|
||||
);
|
||||
bridge.absorb(&[acquire(2)], 103.0);
|
||||
assert!(
|
||||
matches!(
|
||||
bridge.drain_outbox().last().map(|(_, r)| r),
|
||||
Some(CoeditResponse::LeaseGranted { .. })
|
||||
),
|
||||
"a departed author's lease must not outlive it"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flooding_author_is_refused_rather_than_growing_the_queue() {
|
||||
let mut bridge = CoeditBridge::with_limits(
|
||||
GAME,
|
||||
Limits {
|
||||
max_pending_per_author: 2,
|
||||
..Limits::default()
|
||||
},
|
||||
);
|
||||
for i in 0..6 {
|
||||
bridge.absorb(
|
||||
&[submit_event(5, 0, "spam", &format!("{GAME}// {i}\n"))],
|
||||
0.0,
|
||||
);
|
||||
}
|
||||
let refusals = bridge
|
||||
.drain_outbox()
|
||||
.into_iter()
|
||||
.filter(|(_, res)| {
|
||||
matches!(
|
||||
res,
|
||||
CoeditResponse::Refused {
|
||||
reason: CoeditRefusal::QueueFull
|
||||
}
|
||||
)
|
||||
})
|
||||
.count();
|
||||
assert_eq!(refusals, 4, "two queued, four refused with a reason");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,155 +0,0 @@
|
|||
//! Routing a keyless client's typed request to the host's agent
|
||||
//! (game.md §"AI tiers": "in a room with a host, creation requests can still
|
||||
//! route through the host's agent, so BYO-key is only needed for standalone
|
||||
//! creation").
|
||||
//!
|
||||
//! The host owns the mic, the key and the agent. A client types a request, it
|
||||
//! arrives as `Intent::Authoring`, and the host queues it for its own agent.
|
||||
//! The resulting edit reloads the game host-side and reaches everyone through
|
||||
//! the ordinary replication path — clients never need the source, or a key.
|
||||
|
||||
use makepad_game_net::endpoint::HostEvent;
|
||||
use makepad_game_net::protocol::{Intent, PlayerId, MAX_AUTHORING_TEXT};
|
||||
|
||||
/// One client's request, ready to hand to the agent.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct AuthoringRequest {
|
||||
pub player: PlayerId,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// Why a request was refused. Kept explicit so the host can tell the room
|
||||
/// something true rather than dropping requests silently.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum IntentRefusal {
|
||||
Empty,
|
||||
TooLong,
|
||||
TooManyPending,
|
||||
}
|
||||
|
||||
/// Collects authoring requests arriving from clients.
|
||||
///
|
||||
/// Bounded on purpose: this queue is filled by unauthenticated-in-spirit peers
|
||||
/// (they hold the lobby key, but a kid's tablet is not a trusted terminal), and
|
||||
/// every entry it holds eventually costs a paid agent call.
|
||||
pub struct AuthoringInbox {
|
||||
pending: Vec<AuthoringRequest>,
|
||||
max_pending: usize,
|
||||
refusals: Vec<(PlayerId, IntentRefusal)>,
|
||||
}
|
||||
|
||||
impl Default for AuthoringInbox {
|
||||
fn default() -> Self {
|
||||
Self::new(8)
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthoringInbox {
|
||||
pub fn new(max_pending: usize) -> Self {
|
||||
Self {
|
||||
pending: Vec::new(),
|
||||
max_pending,
|
||||
refusals: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed one pump's host events; non-authoring events are ignored.
|
||||
pub fn absorb(&mut self, events: &[HostEvent]) {
|
||||
for event in events {
|
||||
if let HostEvent::Intent {
|
||||
player,
|
||||
intent: Intent::Authoring { text },
|
||||
} = event
|
||||
{
|
||||
self.push(*player, text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn push(&mut self, player: PlayerId, text: &str) {
|
||||
let trimmed = text.trim();
|
||||
if trimmed.is_empty() {
|
||||
self.refusals.push((player, IntentRefusal::Empty));
|
||||
return;
|
||||
}
|
||||
if text.len() > MAX_AUTHORING_TEXT {
|
||||
self.refusals.push((player, IntentRefusal::TooLong));
|
||||
return;
|
||||
}
|
||||
if self.pending.len() >= self.max_pending {
|
||||
self.refusals.push((player, IntentRefusal::TooManyPending));
|
||||
return;
|
||||
}
|
||||
self.pending.push(AuthoringRequest {
|
||||
player,
|
||||
text: trimmed.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Take everything queued. The caller hands these to the agent.
|
||||
pub fn drain(&mut self) -> Vec<AuthoringRequest> {
|
||||
std::mem::take(&mut self.pending)
|
||||
}
|
||||
|
||||
pub fn drain_refusals(&mut self) -> Vec<(PlayerId, IntentRefusal)> {
|
||||
std::mem::take(&mut self.refusals)
|
||||
}
|
||||
|
||||
pub fn pending_len(&self) -> usize {
|
||||
self.pending.len()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn intent_event(player: PlayerId, text: &str) -> HostEvent {
|
||||
HostEvent::Intent {
|
||||
player,
|
||||
intent: Intent::Authoring {
|
||||
text: text.to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authoring_intents_are_queued_for_the_agent() {
|
||||
let mut inbox = AuthoringInbox::default();
|
||||
inbox.absorb(&[
|
||||
intent_event(PlayerId(2), " make the cars faster "),
|
||||
HostEvent::Intent {
|
||||
player: PlayerId(3),
|
||||
intent: Intent::Respawn,
|
||||
},
|
||||
]);
|
||||
let queued = inbox.drain();
|
||||
assert_eq!(queued.len(), 1, "Respawn must not reach the agent");
|
||||
assert_eq!(queued[0].player, PlayerId(2));
|
||||
assert_eq!(queued[0].text, "make the cars faster");
|
||||
assert!(inbox.drain().is_empty(), "drain must consume");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_and_oversized_requests_are_refused_not_forwarded() {
|
||||
let mut inbox = AuthoringInbox::default();
|
||||
let huge = "x".repeat(MAX_AUTHORING_TEXT + 1);
|
||||
inbox.absorb(&[intent_event(PlayerId(1), " "), intent_event(PlayerId(1), &huge)]);
|
||||
assert!(inbox.drain().is_empty());
|
||||
let refusals: Vec<_> = inbox.drain_refusals().into_iter().map(|(_, r)| r).collect();
|
||||
assert_eq!(
|
||||
refusals,
|
||||
vec![IntentRefusal::Empty, IntentRefusal::TooLong]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flooding_client_cannot_grow_the_queue_without_bound() {
|
||||
let mut inbox = AuthoringInbox::new(2);
|
||||
for i in 0..50 {
|
||||
inbox.absorb(&[intent_event(PlayerId(9), &format!("request {i}"))]);
|
||||
}
|
||||
assert_eq!(inbox.pending_len(), 2);
|
||||
assert_eq!(inbox.drain_refusals().len(), 48);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
//! Arcade as a library, so tools can reuse the REAL authoring context.
|
||||
//!
|
||||
//! The eval harness (`tools/arcade_eval`) must send the same system prompt and
|
||||
//! the same tool policy the app sends, or it measures a fiction. Exposing the
|
||||
//! modules here is what keeps the two from drifting; the app binary keeps its
|
||||
//! own `mod` declarations because `app_main!` owns the process entry point.
|
||||
pub use makepad_widgets;
|
||||
|
||||
pub mod ai;
|
||||
/// The Zelda-scale world build. Layout is pure and testable; see the module
|
||||
/// docs for why it is split from realisation.
|
||||
pub mod bigworld;
|
||||
pub mod capability;
|
||||
pub mod library;
|
||||
pub mod pair_server;
|
||||
pub mod pairing;
|
||||
|
|
@ -1,376 +0,0 @@
|
|||
//! The game library and the local librarian (game.md §"AI tiers").
|
||||
//!
|
||||
//! A game is a directory: `game.splash` + `manifest.toml`. The local model is
|
||||
//! a librarian, never a codegen: it maps an utterance onto load / restart /
|
||||
//! knob-set, and everything else goes to the cloud agent. Where no local model
|
||||
//! exists, fuzzy name matching covers load and restart, which is most of what
|
||||
//! anyone says out loud anyway.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// A knob the manifest declares as safely settable without codegen.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Knob {
|
||||
pub name: String,
|
||||
pub value: f64,
|
||||
pub min: f64,
|
||||
pub max: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Default)]
|
||||
pub struct Manifest {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub players: u32,
|
||||
pub knobs: Vec<Knob>,
|
||||
}
|
||||
|
||||
impl Manifest {
|
||||
/// Minimal TOML reader: `key = value`, `[knobs.<name>]` tables. The full
|
||||
/// format is not worth a dependency here, and a manifest that fails to
|
||||
/// parse must degrade to "a game with a name", never to an error dialog.
|
||||
pub fn parse(text: &str, fallback_name: &str) -> Self {
|
||||
let mut m = Manifest {
|
||||
name: fallback_name.to_string(),
|
||||
players: 1,
|
||||
..Default::default()
|
||||
};
|
||||
let mut knob: Option<Knob> = None;
|
||||
for raw in text.lines() {
|
||||
let line = raw.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if let Some(header) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
|
||||
if let Some(k) = knob.take() {
|
||||
m.knobs.push(k);
|
||||
}
|
||||
if let Some(name) = header.strip_prefix("knobs.") {
|
||||
knob = Some(Knob {
|
||||
name: name.trim().to_string(),
|
||||
value: 0.0,
|
||||
min: f64::NEG_INFINITY,
|
||||
max: f64::INFINITY,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let Some((key, value)) = line.split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
let key = key.trim();
|
||||
let value = value.trim().trim_matches('"');
|
||||
match (&mut knob, key) {
|
||||
(Some(k), "value") => k.value = value.parse().unwrap_or(0.0),
|
||||
(Some(k), "min") => k.min = value.parse().unwrap_or(f64::NEG_INFINITY),
|
||||
(Some(k), "max") => k.max = value.parse().unwrap_or(f64::INFINITY),
|
||||
(None, "name") => m.name = value.to_string(),
|
||||
(None, "description") => m.description = value.to_string(),
|
||||
(None, "players") => m.players = value.parse().unwrap_or(1),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(k) = knob.take() {
|
||||
m.knobs.push(k);
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
pub fn knob(&self, name: &str) -> Option<&Knob> {
|
||||
self.knobs.iter().find(|k| k.name.eq_ignore_ascii_case(name))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct GameEntry {
|
||||
pub dir: PathBuf,
|
||||
pub manifest: Manifest,
|
||||
}
|
||||
|
||||
impl GameEntry {
|
||||
pub fn splash_path(&self) -> PathBuf {
|
||||
self.dir.join("game.splash")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Library {
|
||||
pub games: Vec<GameEntry>,
|
||||
}
|
||||
|
||||
impl Library {
|
||||
/// Scan `root` for game directories. Missing root is an empty library, not
|
||||
/// an error — a fresh device has no games yet.
|
||||
pub fn scan(root: &Path) -> Self {
|
||||
let mut games = Vec::new();
|
||||
if let Ok(entries) = std::fs::read_dir(root) {
|
||||
for entry in entries.flatten() {
|
||||
let dir = entry.path();
|
||||
if !dir.join("game.splash").is_file() {
|
||||
continue;
|
||||
}
|
||||
let fallback = dir
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
let manifest = std::fs::read_to_string(dir.join("manifest.toml"))
|
||||
.map(|t| Manifest::parse(&t, &fallback))
|
||||
.unwrap_or(Manifest {
|
||||
name: fallback,
|
||||
players: 1,
|
||||
..Default::default()
|
||||
});
|
||||
games.push(GameEntry { dir, manifest });
|
||||
}
|
||||
}
|
||||
games.sort_by(|a, b| a.manifest.name.cmp(&b.manifest.name));
|
||||
Self { games }
|
||||
}
|
||||
|
||||
pub fn find(&self, name: &str) -> Option<&GameEntry> {
|
||||
self.games
|
||||
.iter()
|
||||
.find(|g| g.manifest.name.eq_ignore_ascii_case(name))
|
||||
}
|
||||
}
|
||||
|
||||
/// What the librarian decided to do with an utterance.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Action {
|
||||
Load(PathBuf),
|
||||
Restart,
|
||||
SetKnob { name: String, value: f64 },
|
||||
/// Anything creative — only the cloud agent writes code.
|
||||
AskAgent(String),
|
||||
}
|
||||
|
||||
/// The local model, when there is one. Returning None means "no opinion",
|
||||
/// which falls through to the deterministic matcher — so a flaky or absent
|
||||
/// local model can never block a request, only sharpen it.
|
||||
pub trait Librarian {
|
||||
fn classify(&mut self, utterance: &str, library: &Library) -> Option<Action>;
|
||||
}
|
||||
|
||||
/// No local model: fuzzy name matching for load/restart, everything else to
|
||||
/// the agent.
|
||||
pub struct FuzzyLibrarian;
|
||||
|
||||
impl Librarian for FuzzyLibrarian {
|
||||
fn classify(&mut self, _utterance: &str, _library: &Library) -> Option<Action> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Deterministic fallback, and the final arbiter for what a local model
|
||||
/// returns. Word-overlap scoring over name + description.
|
||||
pub fn classify_deterministic(
|
||||
utterance: &str,
|
||||
library: &Library,
|
||||
current: Option<&Manifest>,
|
||||
) -> Action {
|
||||
let lower = utterance.to_lowercase();
|
||||
let words: Vec<&str> = lower
|
||||
.split(|c: char| !c.is_alphanumeric())
|
||||
.filter(|w| w.len() > 2)
|
||||
.collect();
|
||||
|
||||
if words.iter().any(|w| matches!(*w, "restart" | "again" | "reset" | "replay")) {
|
||||
return Action::Restart;
|
||||
}
|
||||
|
||||
// A knob the CURRENT game declares: "make the cars faster" -> speed.
|
||||
if let Some(manifest) = current {
|
||||
for knob in &manifest.knobs {
|
||||
let kn = knob.name.to_lowercase();
|
||||
if !words.iter().any(|w| kn.contains(*w) || w.contains(&kn)) {
|
||||
continue;
|
||||
}
|
||||
let up = words.iter().any(|w| {
|
||||
matches!(*w, "faster" | "more" | "higher" | "bigger" | "increase" | "raise")
|
||||
});
|
||||
let down = words.iter().any(|w| {
|
||||
matches!(*w, "slower" | "less" | "lower" | "smaller" | "decrease" | "reduce")
|
||||
});
|
||||
if up || down {
|
||||
let step = if knob.value.abs() > f64::EPSILON {
|
||||
knob.value * 0.25
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let value = (knob.value + if up { step } else { -step })
|
||||
.clamp(knob.min, knob.max);
|
||||
return Action::SetKnob {
|
||||
name: knob.name.clone(),
|
||||
value,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// "play/load the dogfight game" — only match on an explicit play verb, so
|
||||
// "make a racing game" still reaches the agent rather than loading one.
|
||||
let wants_load = words
|
||||
.iter()
|
||||
.any(|w| matches!(*w, "play" | "load" | "open" | "start" | "switch"));
|
||||
if wants_load {
|
||||
let mut best: Option<(usize, &GameEntry)> = None;
|
||||
for game in &library.games {
|
||||
let hay = format!(
|
||||
"{} {}",
|
||||
game.manifest.name.to_lowercase(),
|
||||
game.manifest.description.to_lowercase()
|
||||
);
|
||||
let score = words.iter().filter(|w| hay.contains(**w)).count();
|
||||
if score > 0 && best.map(|(b, _)| score > b).unwrap_or(true) {
|
||||
best = Some((score, game));
|
||||
}
|
||||
}
|
||||
if let Some((_, game)) = best {
|
||||
return Action::Load(game.splash_path());
|
||||
}
|
||||
}
|
||||
|
||||
Action::AskAgent(utterance.to_string())
|
||||
}
|
||||
|
||||
/// Route an utterance: ask the local model if there is one, else (and on any
|
||||
/// "no opinion") fall back to the deterministic matcher.
|
||||
pub fn route(
|
||||
utterance: &str,
|
||||
library: &Library,
|
||||
current: Option<&Manifest>,
|
||||
librarian: Option<&mut dyn Librarian>,
|
||||
) -> Action {
|
||||
if let Some(l) = librarian {
|
||||
if let Some(action) = l.classify(utterance, library) {
|
||||
return action;
|
||||
}
|
||||
}
|
||||
classify_deterministic(utterance, library, current)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn library() -> Library {
|
||||
Library {
|
||||
games: vec![
|
||||
GameEntry {
|
||||
dir: PathBuf::from("/games/racing"),
|
||||
manifest: Manifest {
|
||||
name: "racing".into(),
|
||||
description: "drive cars around an oval track".into(),
|
||||
players: 4,
|
||||
knobs: vec![Knob {
|
||||
name: "speed".into(),
|
||||
value: 20.0,
|
||||
min: 5.0,
|
||||
max: 60.0,
|
||||
}],
|
||||
},
|
||||
},
|
||||
GameEntry {
|
||||
dir: PathBuf::from("/games/dogfight"),
|
||||
manifest: Manifest {
|
||||
name: "dogfight".into(),
|
||||
description: "fly planes and shoot each other".into(),
|
||||
players: 6,
|
||||
knobs: vec![],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_parses_fields_and_knobs() {
|
||||
let m = Manifest::parse(
|
||||
r#"
|
||||
name = "racing"
|
||||
description = "drive cars"
|
||||
players = 4
|
||||
[knobs.speed]
|
||||
value = 20.0
|
||||
min = 5.0
|
||||
max = 60.0
|
||||
[knobs.gravity]
|
||||
value = 18.0
|
||||
"#,
|
||||
"fallback",
|
||||
);
|
||||
assert_eq!(m.name, "racing");
|
||||
assert_eq!(m.players, 4);
|
||||
assert_eq!(m.knobs.len(), 2);
|
||||
assert_eq!(m.knob("speed").unwrap().max, 60.0);
|
||||
// An unparsable manifest still yields a usable game.
|
||||
let empty = Manifest::parse("!!! not toml", "my-game");
|
||||
assert_eq!(empty.name, "my-game");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_by_description_not_just_name() {
|
||||
let lib = library();
|
||||
assert_eq!(
|
||||
route("play the one with the planes", &lib, None, None),
|
||||
Action::Load(PathBuf::from("/games/dogfight/game.splash"))
|
||||
);
|
||||
assert_eq!(
|
||||
route("load racing", &lib, None, None),
|
||||
Action::Load(PathBuf::from("/games/racing/game.splash"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creative_requests_reach_the_agent() {
|
||||
let lib = library();
|
||||
// "make a racing game" must NOT load the existing racing game.
|
||||
let a = route("make a new racing game with boats", &lib, None, None);
|
||||
assert!(matches!(a, Action::AskAgent(_)), "{a:?}");
|
||||
let a = route("add a jump ramp", &lib, None, None);
|
||||
assert!(matches!(a, Action::AskAgent(_)), "{a:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn knob_writes_are_parameter_edits_and_clamp() {
|
||||
let lib = library();
|
||||
let current = lib.find("racing").unwrap().manifest.clone();
|
||||
assert_eq!(
|
||||
route("make the speed faster", &lib, Some(¤t), None),
|
||||
Action::SetKnob { name: "speed".into(), value: 25.0 }
|
||||
);
|
||||
let mut slow = current.clone();
|
||||
slow.knobs[0].value = 6.0;
|
||||
// Clamped to the manifest's declared floor, never below.
|
||||
assert_eq!(
|
||||
route("speed slower", &lib, Some(&slow), None),
|
||||
Action::SetKnob { name: "speed".into(), value: 5.0 }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restart_is_recognised_without_a_model() {
|
||||
let lib = library();
|
||||
assert_eq!(route("restart please", &lib, None, None), Action::Restart);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_local_model_can_override_but_no_opinion_falls_through() {
|
||||
struct Stub(Option<Action>);
|
||||
impl Librarian for Stub {
|
||||
fn classify(&mut self, _u: &str, _l: &Library) -> Option<Action> {
|
||||
self.0.clone()
|
||||
}
|
||||
}
|
||||
let lib = library();
|
||||
let mut stub = Stub(Some(Action::Restart));
|
||||
assert_eq!(
|
||||
route("anything at all", &lib, None, Some(&mut stub)),
|
||||
Action::Restart
|
||||
);
|
||||
// No opinion -> deterministic matcher still routes correctly.
|
||||
let mut quiet = Stub(None);
|
||||
let a = route("build me a maze", &lib, None, Some(&mut quiet));
|
||||
assert!(matches!(a, Action::AskAgent(_)), "{a:?}");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,575 +0,0 @@
|
|||
// Makepad Arcade — networked AI game sandbox. Plan: repo-root game.md.
|
||||
pub use makepad_widgets;
|
||||
|
||||
pub mod ai;
|
||||
pub mod arcade_view;
|
||||
pub mod audio;
|
||||
pub mod bigworld;
|
||||
pub mod authoring;
|
||||
pub mod browser;
|
||||
pub mod capability;
|
||||
pub mod chat;
|
||||
pub mod coedit;
|
||||
pub mod intent;
|
||||
pub mod library;
|
||||
pub mod pair_server;
|
||||
pub mod pairing;
|
||||
pub mod settings;
|
||||
pub mod synth;
|
||||
pub mod xr_input;
|
||||
|
||||
use crate::authoring::{Applied, Authoring};
|
||||
use crate::capability::{arcade_home, Capabilities};
|
||||
use crate::chat::{ChatData, ChatRole};
|
||||
// Named rather than glob-imported: `makepad_ai::*` and the widgets prelude
|
||||
// both re-export a `makepad_widgets`, and the collision is a warning.
|
||||
use makepad_ai::agent::{Agent, AgentEvent, PromptId, SessionId};
|
||||
use makepad_widgets::*;
|
||||
|
||||
app_main!(App);
|
||||
|
||||
script_mod! {
|
||||
use mod.prelude.widgets.*
|
||||
use mod.widgets.ArcadeView
|
||||
use mod.widgets.ArcadeSettings
|
||||
use mod.widgets.ArcadeBrowser
|
||||
use mod.widgets.ArcadeChat
|
||||
|
||||
startup() do #(App::script_component(vm)){
|
||||
ui: Root{
|
||||
main_window := Window{
|
||||
window.inner_size: vec2(1360, 860)
|
||||
window.title: "Makepad Arcade"
|
||||
// Push-to-talk on Escape is set from Rust in handle_startup:
|
||||
// without the `voice` feature the caption bar's VoiceWave is a
|
||||
// stub View, and naming its properties here is a script error.
|
||||
body +: {
|
||||
flow: Down
|
||||
show_bg: true
|
||||
draw_bg.color: #x0b0d14
|
||||
|
||||
split_view := Splitter {
|
||||
width: Fill
|
||||
height: Fill
|
||||
axis: SplitterAxis.Horizontal
|
||||
align: SplitterAlign.FromA(420.0)
|
||||
size: 8.0
|
||||
|
||||
a: View {
|
||||
width: Fill
|
||||
height: Fill
|
||||
flow: Down
|
||||
spacing: 8
|
||||
padding: Inset{left: 12 top: 10 right: 8 bottom: 10}
|
||||
|
||||
View {
|
||||
width: Fill
|
||||
height: Fit
|
||||
flow: Right
|
||||
spacing: 8
|
||||
align: Align{y: 0.5}
|
||||
|
||||
Label {
|
||||
text: "Arcade"
|
||||
draw_text.color: #xb9c4d6
|
||||
draw_text.text_style: theme.font_regular{font_size: 14}
|
||||
}
|
||||
View { width: Fill height: 1 }
|
||||
games_button := Button { text: "Games" }
|
||||
settings_button := Button { text: "Settings" }
|
||||
}
|
||||
|
||||
// Hidden until asked for: this is a game, not a
|
||||
// control panel.
|
||||
settings_panel := ArcadeSettings { visible: false }
|
||||
browser_panel := ArcadeBrowser { visible: false }
|
||||
|
||||
chat := ArcadeChat {}
|
||||
|
||||
View {
|
||||
width: Fill
|
||||
height: Fit
|
||||
flow: Right
|
||||
spacing: 6
|
||||
align: Align{y: 1.0}
|
||||
|
||||
// No `voice_wave` node here on purpose: the
|
||||
// Window already declares one in its caption
|
||||
// bar, and a second with the same id breaks
|
||||
// ids!(voice_wave) and spawns a second worker.
|
||||
input := TextInput {
|
||||
width: Fill
|
||||
height: 42
|
||||
empty_text: "Ask for a game..."
|
||||
}
|
||||
send_button := Button { text: "Go" }
|
||||
stop_button := Button { text: "Stop" visible: false }
|
||||
}
|
||||
|
||||
status_label := Label {
|
||||
width: Fill
|
||||
height: Fit
|
||||
text: "Starting up..."
|
||||
draw_text.color: #x8fa2b8
|
||||
draw_text.text_style: theme.font_regular{font_size: 11}
|
||||
}
|
||||
}
|
||||
|
||||
b: View {
|
||||
width: Fill
|
||||
height: Fill
|
||||
flow: Overlay
|
||||
arcade_view := ArcadeView{}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Frames of the "still working" indicator: an agent can spend many seconds
|
||||
/// inside one tool call without emitting a word.
|
||||
const SPINNER: [&str; 4] = ["• ", "•• ", "•••", " ••"];
|
||||
const SPINNER_PERIOD: f64 = 0.18;
|
||||
|
||||
#[derive(Script, ScriptHook)]
|
||||
pub struct App {
|
||||
#[live]
|
||||
ui: WidgetRef,
|
||||
#[rust]
|
||||
settings_open: bool,
|
||||
#[rust]
|
||||
browser_open: bool,
|
||||
#[rust]
|
||||
caps: Option<Capabilities>,
|
||||
#[rust]
|
||||
agent: Option<Box<dyn Agent>>,
|
||||
#[rust]
|
||||
session_id: Option<SessionId>,
|
||||
#[rust]
|
||||
current_prompt: Option<PromptId>,
|
||||
/// The intent log. Every edit — local agent or remote Claude — goes
|
||||
/// through it; there is deliberately no faster path for the local one.
|
||||
#[rust]
|
||||
authoring: Option<Authoring>,
|
||||
#[rust]
|
||||
speech: Option<makepad_converse::SpeechOutput>,
|
||||
#[rust]
|
||||
next_frame: NextFrame,
|
||||
#[rust]
|
||||
spinner_phase: usize,
|
||||
#[rust]
|
||||
spinner_at: f64,
|
||||
#[rust]
|
||||
focus_armed: bool,
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn set_status(&self, cx: &mut Cx, text: &str) {
|
||||
self.ui.label(cx, ids!(status_label)).set_text(cx, text);
|
||||
}
|
||||
|
||||
fn game_path(&self) -> std::path::PathBuf {
|
||||
arcade_home().join("current").join("game.splash")
|
||||
}
|
||||
|
||||
/// Send whatever is in the input box. The agent edits `game.splash`; the
|
||||
/// resulting file is submitted to the intent log when the turn lands.
|
||||
fn send_message(&mut self, cx: &mut Cx) {
|
||||
let input = self.ui.text_input(cx, ids!(input));
|
||||
let text = input.text();
|
||||
if text.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
ChatData::push(ChatRole::User, text.clone());
|
||||
input.set_text(cx, "");
|
||||
// Voice injects into whatever holds key focus, so the input must keep
|
||||
// it or a spoken sentence lands nowhere.
|
||||
input.set_key_focus(cx);
|
||||
|
||||
let (Some(agent), Some(session_id)) = (&mut self.agent, self.session_id) else {
|
||||
ChatData::push(
|
||||
ChatRole::System,
|
||||
"No AI is connected. Open Settings to choose a provider and add a key.",
|
||||
);
|
||||
self.ui.redraw(cx);
|
||||
return;
|
||||
};
|
||||
if self.current_prompt.is_some() {
|
||||
// The newest instruction wins; the partial reply stays in the log.
|
||||
if let Some(prompt) = self.current_prompt.take() {
|
||||
agent.cancel_prompt(cx, prompt);
|
||||
}
|
||||
}
|
||||
if let Some(authoring) = &mut self.authoring {
|
||||
authoring.begin_turn();
|
||||
}
|
||||
ChatData::begin_stream();
|
||||
ChatData::set_activity("Thinking");
|
||||
self.current_prompt = Some(agent.send_prompt(cx, session_id, &text));
|
||||
self.ui.widget(cx, ids!(stop_button)).set_visible(cx, true);
|
||||
|
||||
if let Some(speech) = self.speech.as_mut() {
|
||||
speech.unhush();
|
||||
}
|
||||
self.ui.redraw(cx);
|
||||
}
|
||||
|
||||
fn cancel_request(&mut self, cx: &mut Cx) {
|
||||
if let Some(speech) = self.speech.as_mut() {
|
||||
speech.stop();
|
||||
}
|
||||
let (Some(agent), Some(prompt)) = (&mut self.agent, self.current_prompt.take()) else {
|
||||
return;
|
||||
};
|
||||
agent.cancel_prompt(cx, prompt);
|
||||
ChatData::end_stream();
|
||||
self.ui.widget(cx, ids!(stop_button)).set_visible(cx, false);
|
||||
self.set_status(cx, "Stopped.");
|
||||
self.ui.redraw(cx);
|
||||
}
|
||||
|
||||
/// Submit the agent's edit to the intent log and reload if the head moved.
|
||||
fn land_edit(&mut self, cx: &mut Cx, intent: &str) {
|
||||
let Some(authoring) = &mut self.authoring else {
|
||||
return;
|
||||
};
|
||||
if let Applied::Reload(pending) = authoring.submit_from_disk(intent) {
|
||||
let path = authoring.path().to_path_buf();
|
||||
let generation = pending.generation;
|
||||
let view = self.ui.widget(cx, ids!(arcade_view));
|
||||
let error = view
|
||||
.borrow_mut::<crate::arcade_view::ArcadeView>()
|
||||
.and_then(|mut v| v.load_game(cx, &path));
|
||||
match error {
|
||||
Some(error) => {
|
||||
// The world keeps running last-good; the proposer hears
|
||||
// about it (and nobody else does).
|
||||
if let Some(a) = &mut self.authoring {
|
||||
a.note_eval_error(generation, error);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if let Some(a) = &mut self.authoring {
|
||||
a.note_eval_ok(generation);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.drain_authoring(cx);
|
||||
}
|
||||
|
||||
/// Route coedit responses addressed to this device's agent into the chat.
|
||||
fn drain_authoring(&mut self, cx: &mut Cx) {
|
||||
let Some(authoring) = &mut self.authoring else {
|
||||
return;
|
||||
};
|
||||
let lines: Vec<String> = authoring
|
||||
.drain_local()
|
||||
.iter()
|
||||
.filter_map(Authoring::describe)
|
||||
.collect();
|
||||
for line in lines {
|
||||
ChatData::push(ChatRole::System, line);
|
||||
}
|
||||
self.ui.redraw(cx);
|
||||
}
|
||||
|
||||
fn draw_activity(&mut self, cx: &mut Cx) {
|
||||
let activity = match chat::CHAT.read() {
|
||||
Ok(data) => data.activity.clone(),
|
||||
Err(_) => return,
|
||||
};
|
||||
if activity.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.set_status(cx, &format!("{} {activity}", SPINNER[self.spinner_phase]));
|
||||
cx.redraw_all();
|
||||
}
|
||||
|
||||
/// Turn a tool call into something a child can understand.
|
||||
fn describe_tool(tool_name: &str, subject: &str) -> Option<String> {
|
||||
let file = subject.rsplit('/').next().unwrap_or(subject);
|
||||
match tool_name {
|
||||
"Edit" | "Write" => Some("Changing the game".to_string()),
|
||||
"Read" => Some(format!("Looking at {file}")),
|
||||
"Glob" | "Grep" => Some("Looking around".to_string()),
|
||||
"Bash" => Some("Working on it".to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MatchEvent for App {
|
||||
fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) {
|
||||
if self.ui.button(cx, ids!(settings_button)).clicked(actions) {
|
||||
self.settings_open = !self.settings_open;
|
||||
self.ui
|
||||
.widget(cx, ids!(settings_panel))
|
||||
.set_visible(cx, self.settings_open);
|
||||
self.ui.redraw(cx);
|
||||
}
|
||||
if self.ui.button(cx, ids!(games_button)).clicked(actions) {
|
||||
self.browser_open = !self.browser_open;
|
||||
self.ui
|
||||
.widget(cx, ids!(browser_panel))
|
||||
.set_visible(cx, self.browser_open);
|
||||
self.ui.redraw(cx);
|
||||
}
|
||||
if self.ui.button(cx, ids!(send_button)).clicked(actions) {
|
||||
self.send_message(cx);
|
||||
}
|
||||
if self.ui.button(cx, ids!(stop_button)).clicked(actions) {
|
||||
self.cancel_request(cx);
|
||||
}
|
||||
if self.ui.text_input(cx, ids!(input)).returned(actions).is_some() {
|
||||
self.send_message(cx);
|
||||
}
|
||||
|
||||
// A transcript arrives as a synthetic TextInput event, which only a
|
||||
// TextInput holding key focus consumes. Take focus the moment the mic
|
||||
// opens, or the words are dispatched into the tree and dropped.
|
||||
#[cfg(feature = "voice")]
|
||||
{
|
||||
let voice_wave = self.ui.voice_wave(cx, ids!(voice_wave));
|
||||
for action in
|
||||
actions.filter_widget_actions_cast::<VoiceWaveAction>(voice_wave.widget_uid())
|
||||
{
|
||||
if let VoiceWaveAction::RecordVoice(true) = action {
|
||||
// Quiet the speaker while the mic is open, or Whisper
|
||||
// transcribes the AI's own voice back at it.
|
||||
if let Some(speech) = self.speech.as_mut() {
|
||||
speech.stop();
|
||||
}
|
||||
self.ui.text_input(cx, ids!(input)).set_key_focus(cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let browser = self.ui.widget(cx, ids!(browser_panel));
|
||||
let action = match browser.borrow_mut::<crate::browser::ArcadeBrowser>() {
|
||||
Some(mut b) => b.handle_actions(cx, actions),
|
||||
None => crate::browser::BrowserAction::None,
|
||||
};
|
||||
if let crate::browser::BrowserAction::Play(slug) = action {
|
||||
// Anything the browser lists was installed from a package, so it
|
||||
// runs sandboxed — a game from a stranger is untrusted code.
|
||||
let path = crate::browser::games_root()
|
||||
.join(&slug)
|
||||
.join(makepad_game_pkg::GAME_FILE);
|
||||
let view = self.ui.widget(cx, ids!(arcade_view));
|
||||
let err = view
|
||||
.borrow_mut::<crate::arcade_view::ArcadeView>()
|
||||
.and_then(|mut v| {
|
||||
v.load_game_with_trust(cx, &path, makepad_game_script::Trust::Downloaded)
|
||||
});
|
||||
if let Some(err) = err {
|
||||
log!("arcade: {slug} failed to load: {err}");
|
||||
}
|
||||
self.browser_open = false;
|
||||
self.ui
|
||||
.widget(cx, ids!(browser_panel))
|
||||
.set_visible(cx, false);
|
||||
self.ui.redraw(cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_startup(&mut self, cx: &mut Cx) {
|
||||
let caps = Capabilities::detect();
|
||||
log!("{}", caps.report());
|
||||
let tier = caps.tier();
|
||||
|
||||
// The mic only exists where the chain behind it does; the text box is
|
||||
// in every tier (game.md §"Per-platform capability fallback").
|
||||
if !tier.shows_mic() {
|
||||
self.ui
|
||||
.widget(cx, ids!(voice_wave))
|
||||
.set_visible(cx, false);
|
||||
} else {
|
||||
// Escape is the big friendly push-to-talk key. Only meaningful
|
||||
// with the `voice` feature, where VoiceWave is the real widget.
|
||||
#[cfg(feature = "voice")]
|
||||
{
|
||||
let mut wave = self.ui.widget(cx, ids!(voice_wave));
|
||||
script_apply_eval!(cx, wave, { ptt_use_escape: true });
|
||||
}
|
||||
}
|
||||
|
||||
// No explicit preference: prefer a CLI agent (no key needed), else
|
||||
// whichever direct API has a key. Settings can override per device.
|
||||
let backend = ai::choose_backend(None);
|
||||
match &backend {
|
||||
ai::BackendChoice::Ready(provider) => {
|
||||
if let Some(mut agent) = ai::build_agent(*provider, None) {
|
||||
let dir = self.game_path().parent().map(|p| p.to_path_buf());
|
||||
if let Some(dir) = &dir {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
}
|
||||
let config = ai::authoring_session(
|
||||
dir.map(|d| d.to_string_lossy().to_string()),
|
||||
&makepad_game_script::api_text(),
|
||||
None,
|
||||
);
|
||||
self.session_id = Some(agent.create_session(cx, config));
|
||||
self.agent = Some(agent);
|
||||
}
|
||||
}
|
||||
ai::BackendChoice::NeedsKey(p) => {
|
||||
ChatData::push(
|
||||
ChatRole::System,
|
||||
format!("{p:?} needs an API key — open Settings to add one, or pair from another device."),
|
||||
);
|
||||
}
|
||||
ai::BackendChoice::None => {
|
||||
ChatData::push(
|
||||
ChatRole::System,
|
||||
"No AI backend found. Open Settings to choose a provider.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The intent log starts from whatever game is on disk.
|
||||
let path = self.game_path();
|
||||
if let Some(dir) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
}
|
||||
self.authoring = Some(Authoring::new(&path));
|
||||
if path.exists() {
|
||||
let view = self.ui.widget(cx, ids!(arcade_view));
|
||||
let err = view
|
||||
.borrow_mut::<crate::arcade_view::ArcadeView>()
|
||||
.and_then(|mut v| v.load_game(cx, &path));
|
||||
if let Some(err) = err {
|
||||
log!("arcade: startup eval failed: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
// Speech: the worker fills `playback`, the audio callback drains it.
|
||||
// Absent voice pack -> text-only replies, which is a tier, not a fault.
|
||||
let speech = caps
|
||||
.tts_voice
|
||||
.as_ref()
|
||||
.map(|_| makepad_converse::SpeechOutput::new("bm_fable.mkvoice"));
|
||||
let playback = speech.as_ref().map(|s| s.playback());
|
||||
cx.audio_output(0, move |info, output| {
|
||||
output.zero();
|
||||
// Game SFX first, speech layered on top of it.
|
||||
crate::synth::mix_into(output, info.sample_rate);
|
||||
if let Some(playback) = &playback {
|
||||
if let Ok(mut playback) = playback.lock() {
|
||||
playback.mix_into(output, info.sample_rate);
|
||||
}
|
||||
}
|
||||
});
|
||||
self.speech = speech;
|
||||
|
||||
self.set_status(cx, &format!("Ready — {}", tier.label()));
|
||||
self.caps = Some(caps);
|
||||
self.focus_armed = true;
|
||||
self.next_frame = cx.new_next_frame();
|
||||
}
|
||||
|
||||
fn handle_audio_devices(&mut self, cx: &mut Cx, devices: &AudioDevicesEvent) {
|
||||
cx.use_audio_outputs(&devices.default_output());
|
||||
}
|
||||
}
|
||||
|
||||
impl AppMain for App {
|
||||
fn script_mod(vm: &mut ScriptVm) -> ScriptValue {
|
||||
crate::makepad_widgets::script_mod(vm);
|
||||
makepad_game_render::script_mod(vm);
|
||||
crate::arcade_view::script_mod(vm);
|
||||
crate::settings::script_mod(vm);
|
||||
crate::browser::script_mod(vm);
|
||||
crate::chat::script_mod(vm);
|
||||
self::script_mod(vm)
|
||||
}
|
||||
|
||||
fn handle_event(&mut self, cx: &mut Cx, event: &Event) {
|
||||
self.match_event(cx, event);
|
||||
self.ui.handle_event(cx, event, &mut Scope::empty());
|
||||
|
||||
if let Some(frame) = self.next_frame.is_event(event) {
|
||||
if self.focus_armed {
|
||||
self.focus_armed = false;
|
||||
self.ui.text_input(cx, ids!(input)).set_key_focus(cx);
|
||||
}
|
||||
// Drain the game's audio queue against the local listener. Doing
|
||||
// it here rather than in the view keeps the synth (a host concern)
|
||||
// out of the engine crates.
|
||||
let view = self.ui.widget(cx, ids!(arcade_view));
|
||||
let drained = view
|
||||
.borrow_mut::<crate::arcade_view::ArcadeView>()
|
||||
.map(|mut v| v.drain_audio());
|
||||
if let Some((requests, listener)) = drained {
|
||||
for name in audio::play_all(&requests, &listener) {
|
||||
// A silent typo costs an agent a whole test cycle.
|
||||
log!("arcade: unknown sound '{name}'");
|
||||
}
|
||||
}
|
||||
if chat::CHAT.read().map(|d| d.is_streaming).unwrap_or(false)
|
||||
&& frame.time - self.spinner_at >= SPINNER_PERIOD
|
||||
{
|
||||
self.spinner_at = frame.time;
|
||||
self.spinner_phase = (self.spinner_phase + 1) % SPINNER.len();
|
||||
self.draw_activity(cx);
|
||||
}
|
||||
self.next_frame = cx.new_next_frame();
|
||||
}
|
||||
|
||||
let Some(agent) = &mut self.agent else { return };
|
||||
for event in agent.handle_event(cx, event) {
|
||||
match event {
|
||||
AgentEvent::TextDelta { text, .. } => {
|
||||
ChatData::push_delta(&text);
|
||||
if let Some(speech) = self.speech.as_mut() {
|
||||
speech.feed(&text);
|
||||
}
|
||||
cx.redraw_all();
|
||||
}
|
||||
AgentEvent::ToolRequest {
|
||||
tool_name,
|
||||
tool_input,
|
||||
..
|
||||
} => {
|
||||
if let Some(activity) = Self::describe_tool(&tool_name, &tool_input) {
|
||||
ChatData::set_activity(&activity);
|
||||
}
|
||||
// An edit landed: propose it now so the world rebuilds
|
||||
// while the agent keeps working.
|
||||
if matches!(tool_name.as_str(), "Edit" | "Write") {
|
||||
self.land_edit(cx, "edit");
|
||||
}
|
||||
}
|
||||
AgentEvent::TurnComplete { .. } => {
|
||||
ChatData::end_stream();
|
||||
if let Some(speech) = self.speech.as_mut() {
|
||||
speech.flush();
|
||||
}
|
||||
self.current_prompt = None;
|
||||
self.ui.widget(cx, ids!(stop_button)).set_visible(cx, false);
|
||||
// Catch a final edit the tool stream did not announce.
|
||||
self.land_edit(cx, "turn");
|
||||
self.set_status(cx, "Ready.");
|
||||
self.ui.text_input(cx, ids!(input)).set_key_focus(cx);
|
||||
cx.redraw_all();
|
||||
}
|
||||
AgentEvent::SessionError { error, .. } => {
|
||||
ChatData::push(ChatRole::System, format!("Session error: {error}"));
|
||||
cx.redraw_all();
|
||||
}
|
||||
AgentEvent::PromptError { prompt_id, error } => {
|
||||
if self.current_prompt == Some(prompt_id) {
|
||||
ChatData::end_stream();
|
||||
self.current_prompt = None;
|
||||
self.ui.widget(cx, ids!(stop_button)).set_visible(cx, false);
|
||||
}
|
||||
ChatData::push(ChatRole::System, format!("Error: {error}"));
|
||||
cx.redraw_all();
|
||||
}
|
||||
AgentEvent::SessionReady { .. } => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,125 +0,0 @@
|
|||
//! The `/pair` HTTP endpoint: a keyboard-less device serves this on the LAN so
|
||||
//! a nearby computer can paste an API key into it.
|
||||
//!
|
||||
//! Uses platform/network's http_server. The device shows the URL and a 4-digit
|
||||
//! code; the page echoes the code back with the key. See pairing.rs for the
|
||||
//! (deliberately modest) security posture.
|
||||
|
||||
use crate::pairing::{PairError, Pairing, Provider};
|
||||
use makepad_widgets::makepad_platform::makepad_network::http_server::*;
|
||||
use makepad_widgets::makepad_platform::makepad_network::{NetworkConfig, NetworkRuntime};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::mpsc;
|
||||
|
||||
pub struct PairServer {
|
||||
pub pairing: Pairing,
|
||||
pub addr: SocketAddr,
|
||||
requests: mpsc::Receiver<HttpServerRequest>,
|
||||
_runtime: NetworkRuntime,
|
||||
}
|
||||
|
||||
/// What the last poll did, so the UI can say something true.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum PairEvent {
|
||||
Stored(Provider),
|
||||
Rejected(PairError),
|
||||
}
|
||||
|
||||
/// The port the device advertises: fixed, because the user has to type it.
|
||||
pub const DEFAULT_PAIR_PORT: u16 = 8790;
|
||||
|
||||
impl PairServer {
|
||||
pub fn start(provider: Provider, seed: u64, port: u16) -> Option<Self> {
|
||||
let runtime = NetworkRuntime::new(NetworkConfig::default());
|
||||
let (tx, rx) = mpsc::channel::<HttpServerRequest>();
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
||||
runtime.start_http_server(HttpServer {
|
||||
listen_address: addr,
|
||||
// A key is a few hundred bytes; anything larger is not a key.
|
||||
post_max_size: 8 * 1024,
|
||||
request: tx,
|
||||
});
|
||||
Some(Self {
|
||||
pairing: Pairing::new(provider, seed),
|
||||
addr,
|
||||
requests: rx,
|
||||
_runtime: runtime,
|
||||
})
|
||||
}
|
||||
|
||||
/// Non-blocking: drain whatever arrived since the last call.
|
||||
pub fn poll(&mut self) -> Vec<PairEvent> {
|
||||
let mut events = Vec::new();
|
||||
while let Ok(request) = self.requests.try_recv() {
|
||||
match request {
|
||||
HttpServerRequest::Get { headers, response_sender } => {
|
||||
let (status, body) = if headers.path == "/pair" {
|
||||
(200, self.pairing.page_html())
|
||||
} else {
|
||||
(404, "not found".to_string())
|
||||
};
|
||||
send(&response_sender, status, "text/html", body.into_bytes());
|
||||
}
|
||||
HttpServerRequest::Post { headers, body, response } => {
|
||||
if headers.path != "/pair" {
|
||||
send(&response, 404, "text/plain", b"not found".to_vec());
|
||||
continue;
|
||||
}
|
||||
let text = String::from_utf8_lossy(&body).to_string();
|
||||
match self.pairing.accept(&text) {
|
||||
Ok(key) => {
|
||||
// Store, then report WITHOUT the key in the message.
|
||||
match crate::pairing::store_key(self.pairing.provider, &key) {
|
||||
Ok(()) => {
|
||||
events.push(PairEvent::Stored(self.pairing.provider));
|
||||
send(
|
||||
&response,
|
||||
200,
|
||||
"text/html",
|
||||
b"<h2>Connected.</h2><p>You can close this page.</p>"
|
||||
.to_vec(),
|
||||
);
|
||||
}
|
||||
Err(_) => send(
|
||||
&response,
|
||||
500,
|
||||
"text/html",
|
||||
b"<h2>Could not store the key on the device.</h2>".to_vec(),
|
||||
),
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
events.push(PairEvent::Rejected(err));
|
||||
let msg: &[u8] = match err {
|
||||
PairError::WrongCode => {
|
||||
b"<h2>Wrong code.</h2><p>Check the code on the device.</p>"
|
||||
}
|
||||
PairError::Malformed => b"<h2>Missing code or key.</h2>",
|
||||
};
|
||||
send(&response, 400, "text/html", msg.to_vec());
|
||||
}
|
||||
}
|
||||
}
|
||||
// Pairing speaks only GET/POST; sockets and messages are
|
||||
// not part of this surface.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
events
|
||||
}
|
||||
}
|
||||
|
||||
fn send(
|
||||
sender: &mpsc::Sender<HttpServerResponse>,
|
||||
status: u16,
|
||||
content_type: &str,
|
||||
body: Vec<u8>,
|
||||
) {
|
||||
let _ = sender.send(HttpServerResponse {
|
||||
header: format!(
|
||||
"HTTP/1.1 {status} OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
body.len()
|
||||
),
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
|
@ -1,280 +0,0 @@
|
|||
//! BYO API keys, and the LAN paste flow for devices with no keyboard
|
||||
//! (game.md §"AI tiers": no QR, no third-party site — the target device serves
|
||||
//! the page itself and the key crosses only the local network).
|
||||
//!
|
||||
//! Security notes, deliberately modest and written down rather than implied:
|
||||
//! - The key is stored in a file under the app config dir at mode 0600. That
|
||||
//! is not a keystore; on Android/iOS this must move to the platform keystore
|
||||
//! before those ship.
|
||||
//! - The pairing page is plaintext HTTP on the LAN. Arcade already assumes a
|
||||
//! trusted LAN (multiplayer runs on it). The 4-digit code is there to stop
|
||||
//! pasting into the WRONG device in a room with several, not to stop an
|
||||
//! attacker who is already on the wire.
|
||||
//! - The key is never logged, never replicated to peers, and never written
|
||||
//! into a game package.
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Provider {
|
||||
ClaudeCode,
|
||||
Anthropic,
|
||||
OpenAi,
|
||||
Gemini,
|
||||
}
|
||||
|
||||
impl Provider {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Provider::ClaudeCode => "Claude Code (CLI)",
|
||||
Provider::Anthropic => "Anthropic API",
|
||||
Provider::OpenAi => "OpenAI API",
|
||||
Provider::Gemini => "Gemini API",
|
||||
}
|
||||
}
|
||||
|
||||
/// Only the direct-HTTP providers need a key; a CLI agent carries its own
|
||||
/// auth, which is why a PC with the CLI never has to pair at all.
|
||||
pub fn needs_key(self) -> bool {
|
||||
!matches!(self, Provider::ClaudeCode)
|
||||
}
|
||||
|
||||
pub fn slug(self) -> &'static str {
|
||||
match self {
|
||||
Provider::ClaudeCode => "claude_code",
|
||||
Provider::Anthropic => "anthropic",
|
||||
Provider::OpenAi => "openai",
|
||||
Provider::Gemini => "gemini",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_slug(s: &str) -> Option<Self> {
|
||||
Some(match s {
|
||||
"claude_code" => Provider::ClaudeCode,
|
||||
"anthropic" => Provider::Anthropic,
|
||||
"openai" => Provider::OpenAi,
|
||||
"gemini" => Provider::Gemini,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn config_dir() -> PathBuf {
|
||||
if let Ok(dir) = std::env::var("ARCADE_CONFIG_DIR") {
|
||||
return PathBuf::from(dir);
|
||||
}
|
||||
let home = std::env::var("HOME").unwrap_or_default();
|
||||
PathBuf::from(home).join(".config").join("makepad-arcade")
|
||||
}
|
||||
|
||||
fn key_path(provider: Provider) -> PathBuf {
|
||||
config_dir().join(format!("{}.key", provider.slug()))
|
||||
}
|
||||
|
||||
/// Persist a key 0600. Returns an error rather than logging the key on any
|
||||
/// failure path — an error message that quotes the secret is a leak.
|
||||
pub fn store_key(provider: Provider, key: &str) -> std::io::Result<()> {
|
||||
let dir = config_dir();
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let path = key_path(provider);
|
||||
let mut file = std::fs::File::create(&path)?;
|
||||
file.write_all(key.trim().as_bytes())?;
|
||||
file.flush()?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load_key(provider: Provider) -> Option<String> {
|
||||
// An env var wins, so CI and headless runs never touch the config dir.
|
||||
if let Ok(k) = std::env::var(match provider {
|
||||
Provider::Anthropic => "ANTHROPIC_API_KEY",
|
||||
Provider::OpenAi => "OPENAI_API_KEY",
|
||||
Provider::Gemini => "GOOGLE_API_KEY",
|
||||
Provider::ClaudeCode => "ARCADE_UNUSED_KEY",
|
||||
}) {
|
||||
if !k.is_empty() {
|
||||
return Some(k);
|
||||
}
|
||||
}
|
||||
std::fs::read_to_string(key_path(provider))
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
pub fn has_key(provider: Provider) -> bool {
|
||||
!provider.needs_key() || load_key(provider).is_some()
|
||||
}
|
||||
|
||||
/// A pairing session: the device shows `code`, the browser must echo it.
|
||||
pub struct Pairing {
|
||||
pub code: String,
|
||||
pub provider: Provider,
|
||||
}
|
||||
|
||||
impl Pairing {
|
||||
/// The code only has to be unguessable-enough for a room, and it must not
|
||||
/// depend on a RNG crate — tick-derived entropy is plenty here.
|
||||
pub fn new(provider: Provider, seed: u64) -> Self {
|
||||
let n = (seed.wrapping_mul(2654435761) >> 16) % 10_000;
|
||||
Self {
|
||||
code: format!("{n:04}"),
|
||||
provider,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn page_html(&self) -> String {
|
||||
format!(
|
||||
"<!doctype html><meta name=viewport content=\"width=device-width\">\
|
||||
<title>Pair Arcade</title>\
|
||||
<style>body{{font:16px system-ui;margin:40px auto;max-width:30rem}}\
|
||||
input{{font:inherit;width:100%;padding:.6rem;margin:.4rem 0}}\
|
||||
button{{font:inherit;padding:.6rem 1.2rem}}</style>\
|
||||
<h2>Connect an API key</h2>\
|
||||
<p>Provider: <b>{}</b></p>\
|
||||
<p>Type the 4-digit code shown on the device, then paste the key.</p>\
|
||||
<form method=POST action=/pair>\
|
||||
<input name=code placeholder=\"4-digit code\" inputmode=numeric autocomplete=off>\
|
||||
<input name=key placeholder=\"paste API key\" autocomplete=off>\
|
||||
<button type=submit>Connect</button></form>",
|
||||
self.provider.label()
|
||||
)
|
||||
}
|
||||
|
||||
/// Parse `code=..&key=..` (form or query). Returns the key only when the
|
||||
/// code matches, so a stray POST to the wrong device stores nothing.
|
||||
pub fn accept(&self, body: &str) -> Result<String, PairError> {
|
||||
let mut code = None;
|
||||
let mut key = None;
|
||||
for pair in body.split('&') {
|
||||
let Some((k, v)) = pair.split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
let v = url_decode(v);
|
||||
match k {
|
||||
"code" => code = Some(v),
|
||||
"key" => key = Some(v),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let (Some(code), Some(key)) = (code, key) else {
|
||||
return Err(PairError::Malformed);
|
||||
};
|
||||
if code.trim() != self.code {
|
||||
return Err(PairError::WrongCode);
|
||||
}
|
||||
let key = key.trim().to_string();
|
||||
if key.is_empty() {
|
||||
return Err(PairError::Malformed);
|
||||
}
|
||||
Ok(key)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum PairError {
|
||||
WrongCode,
|
||||
Malformed,
|
||||
}
|
||||
|
||||
fn url_decode(s: &str) -> String {
|
||||
let bytes = s.as_bytes();
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
match bytes[i] {
|
||||
b'+' => {
|
||||
out.push(' ');
|
||||
i += 1;
|
||||
}
|
||||
b'%' if i + 2 < bytes.len() => {
|
||||
let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or("");
|
||||
match u8::from_str_radix(hex, 16) {
|
||||
Ok(b) => {
|
||||
out.push(b as char);
|
||||
i += 3;
|
||||
}
|
||||
Err(_) => {
|
||||
out.push('%');
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
b => {
|
||||
out.push(b as char);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `ARCADE_CONFIG_DIR` is process-global, so every test that touches the
|
||||
/// key store must hold this or they clobber each other in parallel.
|
||||
pub(crate) static KEY_STORE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
#[test]
|
||||
fn wrong_code_is_refused_and_stores_nothing() {
|
||||
let p = Pairing::new(Provider::Anthropic, 12345);
|
||||
assert_eq!(p.accept("code=0000&key=sk-test"), Err(PairError::WrongCode));
|
||||
assert_eq!(p.accept("key=sk-test"), Err(PairError::Malformed));
|
||||
assert_eq!(
|
||||
p.accept(&format!("code={}&key=", p.code)),
|
||||
Err(PairError::Malformed)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_code_yields_the_key_url_decoded() {
|
||||
let p = Pairing::new(Provider::Anthropic, 999);
|
||||
let body = format!("code={}&key=sk-ant%2Dabc+def", p.code);
|
||||
assert_eq!(p.accept(&body).unwrap(), "sk-ant-abc def");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_is_four_digits() {
|
||||
for seed in [0u64, 1, 7, 12345, u64::MAX] {
|
||||
let p = Pairing::new(Provider::OpenAi, seed);
|
||||
assert_eq!(p.code.len(), 4, "{}", p.code);
|
||||
assert!(p.code.chars().all(|c| c.is_ascii_digit()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_providers_need_no_key() {
|
||||
assert!(!Provider::ClaudeCode.needs_key());
|
||||
assert!(has_key(Provider::ClaudeCode));
|
||||
assert!(Provider::Anthropic.needs_key());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_round_trip_at_0600_and_never_appear_in_the_page() {
|
||||
let _guard = KEY_STORE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let dir = std::env::temp_dir().join(format!("arcade-key-test-{}", std::process::id()));
|
||||
std::env::set_var("ARCADE_CONFIG_DIR", &dir);
|
||||
store_key(Provider::OpenAi, "sk-secret-value").unwrap();
|
||||
assert_eq!(load_key(Provider::OpenAi).as_deref(), Some("sk-secret-value"));
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = std::fs::metadata(dir.join("openai.key"))
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode();
|
||||
assert_eq!(mode & 0o777, 0o600);
|
||||
}
|
||||
let page = Pairing::new(Provider::OpenAi, 1).page_html();
|
||||
assert!(!page.contains("sk-secret-value"));
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
std::env::remove_var("ARCADE_CONFIG_DIR");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,291 +0,0 @@
|
|||
//! The settings panel: pick a provider and model, connect a key, and see
|
||||
//! which capability tier this device actually got (game.md §"AI tiers").
|
||||
//!
|
||||
//! M4 wired the provider/model/key selection as API only; this is the surface
|
||||
//! for it. Key entry is masked and, on a device with no comfortable keyboard,
|
||||
//! the "Pair from another device" button starts the `/pair` endpoint so a
|
||||
//! nearby computer can paste the key in instead.
|
||||
|
||||
use crate::capability::{Capabilities, Tier};
|
||||
use crate::pair_server::{PairEvent, PairServer, DEFAULT_PAIR_PORT};
|
||||
use crate::pairing::{self, Provider};
|
||||
use makepad_widgets::*;
|
||||
|
||||
script_mod! {
|
||||
use mod.prelude.widgets_internal.*
|
||||
use mod.widgets.*
|
||||
|
||||
mod.widgets.ArcadeSettingsBase = #(ArcadeSettings::register_widget(vm))
|
||||
mod.widgets.ArcadeSettings = set_type_default() do mod.widgets.ArcadeSettingsBase{
|
||||
width: Fill
|
||||
height: Fit
|
||||
flow: Down
|
||||
spacing: 10
|
||||
padding: theme.space_2
|
||||
|
||||
View {
|
||||
width: Fill
|
||||
height: Fit
|
||||
flow: Right
|
||||
spacing: 8
|
||||
align: Align{y: 0.5}
|
||||
|
||||
Label {
|
||||
text: "AI backend"
|
||||
draw_text.text_style: theme.font_regular{font_size: 13}
|
||||
}
|
||||
View { width: Fill height: 1 }
|
||||
provider_dropdown := DropDown {
|
||||
width: 130
|
||||
labels: ["..."]
|
||||
draw_text.text_style.font_size: 11
|
||||
}
|
||||
model_dropdown := DropDown {
|
||||
width: 150
|
||||
labels: ["..."]
|
||||
draw_text.text_style.font_size: 11
|
||||
}
|
||||
}
|
||||
|
||||
View {
|
||||
width: Fill
|
||||
height: Fit
|
||||
flow: Right
|
||||
spacing: 8
|
||||
align: Align{y: 0.5}
|
||||
|
||||
key_input := TextInput {
|
||||
width: Fill
|
||||
height: 38
|
||||
is_password: true
|
||||
empty_text: "paste API key"
|
||||
}
|
||||
save_key_button := Button { text: "Save" }
|
||||
pair_button := Button { text: "Pair from another device" }
|
||||
}
|
||||
|
||||
tier_label := Label {
|
||||
text: "detecting..."
|
||||
draw_text.text_style: theme.font_regular{font_size: 11}
|
||||
}
|
||||
pair_label := Label {
|
||||
text: ""
|
||||
draw_text.text_style: theme.font_regular{font_size: 11}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Providers offered in the picker, in menu order.
|
||||
const PROVIDERS: &[Provider] = &[
|
||||
Provider::ClaudeCode,
|
||||
Provider::Anthropic,
|
||||
Provider::OpenAi,
|
||||
Provider::Gemini,
|
||||
];
|
||||
|
||||
/// Model choices per provider. First entry is the default.
|
||||
fn models_for(provider: Provider) -> &'static [&'static str] {
|
||||
match provider {
|
||||
Provider::ClaudeCode => &["claude-fable-5", "claude-opus-5", "claude-sonnet-5"],
|
||||
Provider::Anthropic => &["claude-fable-5", "claude-sonnet-5", "claude-haiku-4-5-20251001"],
|
||||
Provider::OpenAi => &["gpt-5", "gpt-5-mini"],
|
||||
Provider::Gemini => &["gemini-3-pro", "gemini-3-flash"],
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Script, ScriptHook, Widget)]
|
||||
pub struct ArcadeSettings {
|
||||
#[source]
|
||||
source: ScriptObjectRef,
|
||||
#[deref]
|
||||
view: View,
|
||||
#[rust]
|
||||
initialized: bool,
|
||||
#[rust]
|
||||
provider_index: usize,
|
||||
#[rust]
|
||||
model_index: usize,
|
||||
#[rust]
|
||||
caps: Option<Capabilities>,
|
||||
/// Live only while pairing; dropped when a key lands, which stops serving.
|
||||
#[rust]
|
||||
pair_server: Option<PairServer>,
|
||||
#[rust]
|
||||
next_frame: NextFrame,
|
||||
}
|
||||
|
||||
impl ArcadeSettings {
|
||||
fn provider(&self) -> Provider {
|
||||
PROVIDERS[self.provider_index.min(PROVIDERS.len() - 1)]
|
||||
}
|
||||
|
||||
fn tier(&mut self) -> Tier {
|
||||
let caps = self.caps.get_or_insert_with(Capabilities::detect);
|
||||
caps.tier()
|
||||
}
|
||||
|
||||
fn refresh_labels(&mut self, cx: &mut Cx) {
|
||||
let provider = self.provider();
|
||||
let models: Vec<String> = models_for(provider).iter().map(|m| m.to_string()).collect();
|
||||
self.drop_down(cx, ids!(model_dropdown)).set_labels(cx, models);
|
||||
self.drop_down(cx, ids!(model_dropdown))
|
||||
.set_selected_item(cx, self.model_index.min(models_for(provider).len() - 1));
|
||||
|
||||
let tier = self.tier();
|
||||
let key_state = if !provider.needs_key() {
|
||||
"no key needed".to_string()
|
||||
} else if pairing::has_key(provider) {
|
||||
"key connected".to_string()
|
||||
} else {
|
||||
"no key — join/play only".to_string()
|
||||
};
|
||||
let text = format!("{} · {}", tier.label(), key_state);
|
||||
self.label(cx, ids!(tier_label)).set_text(cx, &text);
|
||||
}
|
||||
|
||||
fn start_pairing(&mut self, cx: &mut Cx) {
|
||||
let provider = self.provider();
|
||||
// Tick-derived seed: the code only has to be unguessable within a room.
|
||||
let seed = (cx.seconds_since_app_start() * 1_000_000.0) as u64;
|
||||
match PairServer::start(provider, seed, DEFAULT_PAIR_PORT) {
|
||||
Some(server) => {
|
||||
let text = format!(
|
||||
"Open http://{}:{}/pair on a computer, then enter code {}",
|
||||
local_ip_hint(),
|
||||
server.addr.port(),
|
||||
server.pairing.code
|
||||
);
|
||||
self.label(cx, ids!(pair_label)).set_text(cx, &text);
|
||||
self.pair_server = Some(server);
|
||||
// Poll the endpoint until a key lands.
|
||||
self.next_frame = cx.new_next_frame();
|
||||
}
|
||||
None => {
|
||||
self.label(cx, ids!(pair_label))
|
||||
.set_text(cx, "could not start the pairing server");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_pairing(&mut self, cx: &mut Cx) {
|
||||
let Some(server) = self.pair_server.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let events = server.poll();
|
||||
let mut done = false;
|
||||
for event in events {
|
||||
match event {
|
||||
PairEvent::Stored(provider) => {
|
||||
self.label(cx, ids!(pair_label))
|
||||
.set_text(cx, &format!("{} key connected", provider.label()));
|
||||
done = true;
|
||||
}
|
||||
PairEvent::Rejected(err) => {
|
||||
self.label(cx, ids!(pair_label))
|
||||
.set_text(cx, &format!("rejected: {err:?}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if done {
|
||||
// Stop serving the moment we have what we came for.
|
||||
self.pair_server = None;
|
||||
self.refresh_labels(cx);
|
||||
} else {
|
||||
self.next_frame = cx.new_next_frame();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort LAN address for the instructions. Not authoritative — the user
|
||||
/// can read it off their router if this guesses wrong.
|
||||
fn local_ip_hint() -> String {
|
||||
use std::net::UdpSocket;
|
||||
// Connecting a UDP socket picks the interface without sending anything.
|
||||
UdpSocket::bind("0.0.0.0:0")
|
||||
.and_then(|s| {
|
||||
s.connect("8.8.8.8:80")?;
|
||||
s.local_addr()
|
||||
})
|
||||
.map(|a| a.ip().to_string())
|
||||
.unwrap_or_else(|_| "<this device>".to_string())
|
||||
}
|
||||
|
||||
impl Widget for ArcadeSettings {
|
||||
fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep {
|
||||
if !self.initialized {
|
||||
self.initialized = true;
|
||||
let labels: Vec<String> = PROVIDERS.iter().map(|p| p.label().to_string()).collect();
|
||||
self.drop_down(cx.cx, ids!(provider_dropdown))
|
||||
.set_labels(cx.cx, labels);
|
||||
self.refresh_labels(cx.cx);
|
||||
}
|
||||
self.view.draw_walk(cx, scope, walk)
|
||||
}
|
||||
|
||||
fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
|
||||
if self.next_frame.is_event(event).is_some() {
|
||||
self.poll_pairing(cx);
|
||||
}
|
||||
self.widget_match_event(cx, event, scope);
|
||||
self.view.handle_event(cx, event, scope);
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetMatchEvent for ArcadeSettings {
|
||||
fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions, _scope: &mut Scope) {
|
||||
if let Some(index) = self.drop_down(cx, ids!(provider_dropdown)).changed(actions) {
|
||||
self.provider_index = index;
|
||||
self.model_index = 0;
|
||||
self.refresh_labels(cx);
|
||||
}
|
||||
if let Some(index) = self.drop_down(cx, ids!(model_dropdown)).changed(actions) {
|
||||
self.model_index = index;
|
||||
}
|
||||
if self.button(cx, ids!(save_key_button)).clicked(actions) {
|
||||
let key = self.text_input(cx, ids!(key_input)).text();
|
||||
let provider = self.provider();
|
||||
let message = if key.trim().is_empty() {
|
||||
"nothing to save".to_string()
|
||||
} else {
|
||||
match pairing::store_key(provider, key.trim()) {
|
||||
Ok(()) => {
|
||||
// Never leave the key sitting in a widget.
|
||||
self.text_input(cx, ids!(key_input)).set_text(cx, "");
|
||||
format!("{} key stored", provider.label())
|
||||
}
|
||||
Err(err) => format!("could not store key: {err}"),
|
||||
}
|
||||
};
|
||||
self.label(cx, ids!(pair_label)).set_text(cx, &message);
|
||||
self.refresh_labels(cx);
|
||||
}
|
||||
if self.button(cx, ids!(pair_button)).clicked(actions) {
|
||||
self.start_pairing(cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_provider_offers_at_least_one_model() {
|
||||
for provider in PROVIDERS {
|
||||
assert!(
|
||||
!models_for(*provider).is_empty(),
|
||||
"{} has no models",
|
||||
provider.label()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_labels_are_distinct() {
|
||||
let mut seen = Vec::new();
|
||||
for provider in PROVIDERS {
|
||||
assert!(!seen.contains(&provider.label()), "duplicate provider label");
|
||||
seen.push(provider.label());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,543 +0,0 @@
|
|||
//! Game sound effects: a tiny polyphonic synthesizer.
|
||||
//!
|
||||
//! Games ship no audio assets — the art style is procedural, so the sound is
|
||||
//! too: a named bank of kid-game staples plus raw `beep`/`jingle`/`tone`
|
||||
//! primitives, mixed additively into the app's audio output callback.
|
||||
//!
|
||||
//! Ported from `examples/gamemaker/src/synth.rs`, which this deliberately does
|
||||
//! not share yet: the shared home for it is `libs/game/audio`, and creating
|
||||
//! that crate means editing gamemaker (tape-parity-critical) — out of this
|
||||
//! task's scope. The one behavioural addition here is **stereo**: a voice
|
||||
//! carries a pan, so `game.sfx_at` can actually be heard to one side. Merging
|
||||
//! the two copies is a mechanical follow-up, gated by the tape.
|
||||
|
||||
use makepad_widgets::makepad_platform::audio::AudioBuffer;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// Percussive envelope attack, long enough to avoid clicks.
|
||||
const ATTACK_SECS: f32 = 0.004;
|
||||
/// Voice cap: oldest voice is dropped, a stuck script can't build a wall of sound.
|
||||
const MAX_VOICES: usize = 24;
|
||||
/// Sustained voice cap — engine hums, wind, sirens. Small on purpose.
|
||||
const MAX_TONES: usize = 6;
|
||||
/// Parameter smoothing rate (≈30ms to target) so per-tick retuning from
|
||||
/// `game.tone_set` glides instead of zipper-stepping.
|
||||
const TONE_SMOOTH_RATE: f32 = 33.0;
|
||||
const TONE_ATTACK_RATE: f32 = 60.0;
|
||||
const TONE_RELEASE_RATE: f32 = 18.0;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||
pub enum Wave {
|
||||
Sine,
|
||||
Square,
|
||||
Saw,
|
||||
Triangle,
|
||||
Noise,
|
||||
}
|
||||
|
||||
impl Wave {
|
||||
pub fn parse(name: &str) -> Wave {
|
||||
match name {
|
||||
"sine" => Wave::Sine,
|
||||
"saw" => Wave::Saw,
|
||||
"triangle" | "tri" => Wave::Triangle,
|
||||
"noise" => Wave::Noise,
|
||||
_ => Wave::Square,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-channel gains for a pan in -1..1. Centre keeps full volume in both
|
||||
/// channels (rather than equal-power, which would make every 2D sound
|
||||
/// quieter than it is today just for having gained a pan field).
|
||||
fn pan_gains(pan: f32) -> (f32, f32) {
|
||||
let pan = pan.clamp(-1.0, 1.0);
|
||||
(1.0 - pan.max(0.0), 1.0 + pan.min(0.0))
|
||||
}
|
||||
|
||||
struct Voice {
|
||||
wave: Wave,
|
||||
freq_from: f32,
|
||||
freq_to: f32,
|
||||
len: f32,
|
||||
gain: f32,
|
||||
/// -1 left .. 0 centre .. +1 right. Positional sounds set this; the 2D
|
||||
/// verbs leave it at 0 and behave exactly as before.
|
||||
pan: f32,
|
||||
/// Seconds until the voice starts — how jingles sequence notes.
|
||||
delay: f32,
|
||||
t: f32,
|
||||
phase: f32,
|
||||
noise: u32,
|
||||
}
|
||||
|
||||
/// A looping tone: no envelope end, retunable without retriggering. This is
|
||||
/// the "car engine note" primitive one-shot beeps can't fake (60 envelope
|
||||
/// restarts a second).
|
||||
struct Tone {
|
||||
id: u64,
|
||||
wave: Wave,
|
||||
freq: f32,
|
||||
freq_target: f32,
|
||||
gain: f32,
|
||||
gain_target: f32,
|
||||
/// Attack/release level; a released tone fades out and is dropped.
|
||||
level: f32,
|
||||
releasing: bool,
|
||||
phase: f32,
|
||||
noise: u32,
|
||||
}
|
||||
|
||||
pub struct Synth {
|
||||
voices: Vec<Voice>,
|
||||
tones: Vec<Tone>,
|
||||
}
|
||||
|
||||
/// Shared with the audio callback. The script side only ever pushes voices;
|
||||
/// the audio thread only ever advances and drops them.
|
||||
static SYNTH: Mutex<Synth> = Mutex::new(Synth {
|
||||
voices: Vec::new(),
|
||||
tones: Vec::new(),
|
||||
});
|
||||
|
||||
/// Start a sustained tone under a host-minted id (`Ctx::next_tone`), so the
|
||||
/// script gets its handle back synchronously from a drained queue.
|
||||
pub fn tone(id: u64, freq: f32, wave: Wave, gain: f32) {
|
||||
let Ok(mut synth) = SYNTH.lock() else { return };
|
||||
if synth.tones.len() >= MAX_TONES {
|
||||
synth.tones.remove(0);
|
||||
}
|
||||
synth.tones.push(Tone {
|
||||
id,
|
||||
wave,
|
||||
freq: freq.clamp(20.0, 8000.0),
|
||||
freq_target: freq.clamp(20.0, 8000.0),
|
||||
gain: 0.0,
|
||||
gain_target: gain.clamp(0.0, 1.0),
|
||||
level: 0.0,
|
||||
releasing: false,
|
||||
phase: 0.0,
|
||||
noise: 0x51ed_2705,
|
||||
});
|
||||
}
|
||||
|
||||
/// Retune a running tone — smoothed, never retriggered.
|
||||
pub fn tone_set(id: u64, freq: Option<f32>, gain: Option<f32>) {
|
||||
let Ok(mut synth) = SYNTH.lock() else { return };
|
||||
if let Some(tone) = synth.tones.iter_mut().find(|t| t.id == id) {
|
||||
if let Some(freq) = freq {
|
||||
tone.freq_target = freq.clamp(20.0, 8000.0);
|
||||
}
|
||||
if let Some(gain) = gain {
|
||||
tone.gain_target = gain.clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tone_stop(id: u64) {
|
||||
let Ok(mut synth) = SYNTH.lock() else { return };
|
||||
if let Some(tone) = synth.tones.iter_mut().find(|t| t.id == id) {
|
||||
tone.releasing = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// A rebuilt world must never inherit a stuck engine hum: the script queues
|
||||
/// this on every eval/reset.
|
||||
pub fn stop_all_tones() {
|
||||
let Ok(mut synth) = SYNTH.lock() else { return };
|
||||
for tone in synth.tones.iter_mut() {
|
||||
tone.releasing = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// One tone, optionally gliding from `freq` to `to` over its length.
|
||||
pub fn beep(freq: f32, to: f32, secs: f32, wave: Wave, gain: f32, delay: f32) {
|
||||
beep_panned(freq, to, secs, wave, gain, delay, 0.0);
|
||||
}
|
||||
|
||||
pub fn beep_panned(
|
||||
freq: f32,
|
||||
to: f32,
|
||||
secs: f32,
|
||||
wave: Wave,
|
||||
gain: f32,
|
||||
delay: f32,
|
||||
pan: f32,
|
||||
) {
|
||||
let Ok(mut synth) = SYNTH.lock() else { return };
|
||||
if synth.voices.len() >= MAX_VOICES {
|
||||
synth.voices.remove(0);
|
||||
}
|
||||
synth.voices.push(Voice {
|
||||
wave,
|
||||
freq_from: freq.clamp(20.0, 8000.0),
|
||||
freq_to: to.clamp(20.0, 8000.0),
|
||||
len: secs.clamp(0.01, 3.0),
|
||||
gain: gain.clamp(0.0, 1.0),
|
||||
pan: pan.clamp(-1.0, 1.0),
|
||||
delay: delay.max(0.0),
|
||||
t: 0.0,
|
||||
phase: 0.0,
|
||||
noise: 0x2f6e2b1,
|
||||
});
|
||||
}
|
||||
|
||||
/// Note names, e.g. "C4 E4 G4 C5" (sharps as "F#5"). Unknown tokens are rests,
|
||||
/// so a slightly-wrong jingle still plays instead of erroring at a kid.
|
||||
pub fn jingle(notes: &str, note_secs: f32, wave: Wave, gain: f32) {
|
||||
let step = note_secs.clamp(0.03, 1.0);
|
||||
for (index, token) in notes.split_whitespace().enumerate() {
|
||||
if let Some(freq) = note_freq(token) {
|
||||
beep(freq, freq, step * 0.9, wave, gain, index as f32 * step);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn note_freq(token: &str) -> Option<f32> {
|
||||
let bytes = token.as_bytes();
|
||||
let semitone = match bytes.first()?.to_ascii_uppercase() {
|
||||
b'C' => 0,
|
||||
b'D' => 2,
|
||||
b'E' => 4,
|
||||
b'F' => 5,
|
||||
b'G' => 7,
|
||||
b'A' => 9,
|
||||
b'B' => 11,
|
||||
_ => return None,
|
||||
};
|
||||
let mut index = 1;
|
||||
let mut sharp = 0;
|
||||
if bytes.get(index) == Some(&b'#') {
|
||||
sharp = 1;
|
||||
index += 1;
|
||||
}
|
||||
let octave: i32 = token.get(index..)?.parse().ok()?;
|
||||
let midi = (octave + 1) * 12 + semitone + sharp;
|
||||
Some(440.0 * 2f32.powf((midi as f32 - 69.0) / 12.0))
|
||||
}
|
||||
|
||||
/// The kid-game staple bank, at a position. `gain_scale` is the distance
|
||||
/// attenuation and `pan` the direction; a 2D `sfx` passes (1.0, 0.0) and gets
|
||||
/// exactly the historical sound.
|
||||
pub fn play_named_at(name: &str, pitch: f32, gain_scale: f32, pan: f32) -> bool {
|
||||
let p = pitch.clamp(0.25, 4.0);
|
||||
let g = gain_scale.clamp(0.0, 1.0);
|
||||
// Local helpers so the bank recipes stay readable and identical to the
|
||||
// gamemaker originals apart from the scale/pan pass-through.
|
||||
let b = |freq: f32, to: f32, secs: f32, wave: Wave, gain: f32, delay: f32| {
|
||||
beep_panned(freq, to, secs, wave, gain * g, delay, pan)
|
||||
};
|
||||
let j = |notes: &str, secs: f32, wave: Wave, gain: f32| {
|
||||
let step = secs.clamp(0.03, 1.0);
|
||||
for (index, token) in notes.split_whitespace().enumerate() {
|
||||
if let Some(freq) = note_freq(token) {
|
||||
beep_panned(
|
||||
freq,
|
||||
freq,
|
||||
step * 0.9,
|
||||
wave,
|
||||
gain * g,
|
||||
index as f32 * step,
|
||||
pan,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
match name {
|
||||
"jump" => b(260.0 * p, 540.0 * p, 0.12, Wave::Square, 0.22, 0.0),
|
||||
"shoot" => b(880.0 * p, 180.0 * p, 0.09, Wave::Square, 0.20, 0.0),
|
||||
"zap" => {
|
||||
b(1200.0 * p, 90.0 * p, 0.18, Wave::Saw, 0.22, 0.0);
|
||||
b(600.0, 600.0, 0.10, Wave::Noise, 0.12, 0.0);
|
||||
}
|
||||
"grab" => b(320.0 * p, 180.0 * p, 0.12, Wave::Sine, 0.25, 0.0),
|
||||
"angry" => b(150.0 * p, 90.0 * p, 0.25, Wave::Square, 0.22, 0.0),
|
||||
"calm" => b(390.0 * p, 520.0 * p, 0.20, Wave::Sine, 0.20, 0.0),
|
||||
"rescue" => j("E5 G5", 0.09, Wave::Triangle, 0.22),
|
||||
"shove" => b(200.0, 200.0, 0.06, Wave::Noise, 0.30, 0.0),
|
||||
// Firework: a noise burst sweeping down into a long tail. Noise
|
||||
// because a shell is broadband — a tone reads as a laser, not a bang.
|
||||
"board" => b(220.0 * p, 330.0 * p, 0.11, Wave::Sine, 0.22, 0.0),
|
||||
"coin" => j("B5 E6", 0.07, Wave::Triangle, 0.20),
|
||||
"hurt" => b(300.0 * p, 120.0 * p, 0.15, Wave::Saw, 0.22, 0.0),
|
||||
"win" => j("C5 E5 G5 C6", 0.10, Wave::Triangle, 0.22),
|
||||
"lose" => j("E4 C4 A3", 0.14, Wave::Square, 0.20),
|
||||
"squeak" => b(900.0 * p, 1400.0 * p, 0.08, Wave::Sine, 0.18, 0.0),
|
||||
"roar" => {
|
||||
b(220.0 * p, 60.0 * p, 0.5, Wave::Saw, 0.28, 0.0);
|
||||
b(300.0, 300.0, 0.35, Wave::Noise, 0.14, 0.0);
|
||||
}
|
||||
"bark" => {
|
||||
b(520.0 * p, 520.0 * p, 0.06, Wave::Square, 0.30, 0.0);
|
||||
b(340.0 * p, 340.0 * p, 0.06, Wave::Square, 0.30, 0.06);
|
||||
}
|
||||
"moo" => b(200.0 * p, 150.0 * p, 0.35, Wave::Square, 0.18, 0.0),
|
||||
"clank" => {
|
||||
b(980.0 * p, 980.0 * p, 0.07, Wave::Square, 0.30, 0.0);
|
||||
b(300.0 * p, 300.0 * p, 0.07, Wave::Square, 0.30, 0.07);
|
||||
}
|
||||
"whip" => b(420.0 * p, 1500.0 * p, 0.09, Wave::Square, 0.25, 0.0),
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// 2D form: full volume, centred.
|
||||
pub fn play_named(name: &str, pitch: f32) -> bool {
|
||||
play_named_at(name, pitch, 1.0, 0.0)
|
||||
}
|
||||
|
||||
fn wave_sample(wave: Wave, phase: f32, noise: &mut u32) -> f32 {
|
||||
match wave {
|
||||
Wave::Sine => (phase * std::f32::consts::TAU).sin(),
|
||||
Wave::Square => {
|
||||
if phase < 0.5 {
|
||||
1.0
|
||||
} else {
|
||||
-1.0
|
||||
}
|
||||
}
|
||||
Wave::Saw => 2.0 * phase - 1.0,
|
||||
Wave::Triangle => 1.0 - 4.0 * (phase - 0.5).abs(),
|
||||
Wave::Noise => {
|
||||
*noise ^= *noise << 13;
|
||||
*noise ^= *noise >> 17;
|
||||
*noise ^= *noise << 5;
|
||||
(*noise as f32 / u32::MAX as f32) * 2.0 - 1.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mix all live voices additively into `output`. Runs on the audio thread —
|
||||
/// no allocation, one brief lock. Callers zero or fill the buffer first.
|
||||
pub fn mix_into(output: &mut AudioBuffer, sample_rate: f64) {
|
||||
let Ok(mut synth) = SYNTH.lock() else { return };
|
||||
if synth.voices.is_empty() && synth.tones.is_empty() {
|
||||
return;
|
||||
}
|
||||
let dt = 1.0 / sample_rate as f32;
|
||||
let frames = output.frame_count();
|
||||
let channels = output.channel_count();
|
||||
for frame in 0..frames {
|
||||
// Sustained tones are non-positional (an engine hum belongs to the
|
||||
// whole scene), so they land in the centre sum.
|
||||
let mut centre = 0.0f32;
|
||||
let mut left = 0.0f32;
|
||||
let mut right = 0.0f32;
|
||||
for tone in synth.tones.iter_mut() {
|
||||
tone.freq += (tone.freq_target - tone.freq) * (TONE_SMOOTH_RATE * dt).min(1.0);
|
||||
tone.gain += (tone.gain_target - tone.gain) * (TONE_SMOOTH_RATE * dt).min(1.0);
|
||||
if tone.releasing {
|
||||
tone.level -= TONE_RELEASE_RATE * dt;
|
||||
} else {
|
||||
tone.level = (tone.level + TONE_ATTACK_RATE * dt).min(1.0);
|
||||
}
|
||||
if tone.level <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
tone.phase = (tone.phase + tone.freq * dt).fract();
|
||||
let raw = wave_sample(tone.wave, tone.phase, &mut tone.noise);
|
||||
centre += raw * tone.level * tone.gain;
|
||||
}
|
||||
for voice in synth.voices.iter_mut() {
|
||||
if voice.delay > 0.0 {
|
||||
voice.delay -= dt;
|
||||
continue;
|
||||
}
|
||||
if voice.t >= voice.len {
|
||||
continue;
|
||||
}
|
||||
let u = voice.t / voice.len;
|
||||
let freq = voice.freq_from + (voice.freq_to - voice.freq_from) * u;
|
||||
voice.phase = (voice.phase + freq * dt).fract();
|
||||
let raw = wave_sample(voice.wave, voice.phase, &mut voice.noise);
|
||||
let attack = (voice.t / ATTACK_SECS).min(1.0);
|
||||
let envelope = attack * (1.0 - u) * (1.0 - u);
|
||||
let sample = raw * envelope * voice.gain;
|
||||
if voice.pan == 0.0 {
|
||||
centre += sample;
|
||||
} else {
|
||||
let (gl, gr) = pan_gains(voice.pan);
|
||||
left += sample * gl;
|
||||
right += sample * gr;
|
||||
}
|
||||
voice.t += dt;
|
||||
}
|
||||
if centre != 0.0 || left != 0.0 || right != 0.0 {
|
||||
for channel in 0..channels {
|
||||
// Channel 0 is left, 1 is right; anything beyond a stereo pair
|
||||
// (or a mono device) gets the unpanned sum so nothing is lost.
|
||||
let sample = match channel {
|
||||
0 if channels >= 2 => centre + left,
|
||||
1 if channels >= 2 => centre + right,
|
||||
_ => centre + left + right,
|
||||
};
|
||||
output.channel_mut(channel)[frame] += sample.clamp(-0.9, 0.9);
|
||||
}
|
||||
}
|
||||
}
|
||||
synth.voices.retain(|v| v.delay > 0.0 || v.t < v.len);
|
||||
synth.tones.retain(|t| !(t.releasing && t.level <= 0.0));
|
||||
}
|
||||
|
||||
/// Drop every live voice and tone. Used when a world is torn down so a new
|
||||
/// game never inherits the previous one's noise.
|
||||
pub fn reset() {
|
||||
let Ok(mut synth) = SYNTH.lock() else { return };
|
||||
synth.voices.clear();
|
||||
synth.tones.clear();
|
||||
}
|
||||
|
||||
/// Live voice + tone count, for tests and diagnostics.
|
||||
pub fn live_counts() -> (usize, usize) {
|
||||
match SYNTH.lock() {
|
||||
Ok(synth) => (synth.voices.len(), synth.tones.len()),
|
||||
Err(_) => (0, 0),
|
||||
}
|
||||
}
|
||||
|
||||
/// The synth is a process-global, so tests that assert on voice counts must
|
||||
/// not interleave — including tests in the modules that drive it.
|
||||
#[cfg(test)]
|
||||
pub static SYNTH_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use makepad_widgets::makepad_platform::audio::AudioBuffer;
|
||||
|
||||
fn buffer() -> AudioBuffer {
|
||||
let mut b = AudioBuffer::new_with_size(256, 2);
|
||||
b.zero();
|
||||
b
|
||||
}
|
||||
|
||||
fn peak(buf: &AudioBuffer, channel: usize) -> f32 {
|
||||
buf.channel(channel)
|
||||
.iter()
|
||||
.fold(0.0f32, |acc, s| acc.max(s.abs()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_named_sfx_produces_audible_samples() {
|
||||
let _guard = super::SYNTH_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
reset();
|
||||
assert!(play_named("jump", 1.0), "jump is in the bank");
|
||||
let mut buf = buffer();
|
||||
mix_into(&mut buf, 44100.0);
|
||||
assert!(peak(&buf, 0) > 0.0, "a played sfx must be audible");
|
||||
reset();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_sfx_name_is_reported_not_played() {
|
||||
let _guard = super::SYNTH_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
reset();
|
||||
assert!(!play_named("not-a-sound", 1.0));
|
||||
assert_eq!(live_counts().0, 0, "an unknown name must queue nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pan_moves_the_sound_between_the_channels() {
|
||||
let _guard = super::SYNTH_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
// Hard right: the right channel is louder than the left.
|
||||
reset();
|
||||
beep_panned(440.0, 440.0, 0.2, Wave::Square, 0.5, 0.0, 1.0);
|
||||
let mut buf = buffer();
|
||||
mix_into(&mut buf, 44100.0);
|
||||
let (l, r) = (peak(&buf, 0), peak(&buf, 1));
|
||||
assert!(r > l, "panned right: left {l} right {r}");
|
||||
assert_eq!(l, 0.0, "hard right must be silent on the left");
|
||||
|
||||
// Hard left is the mirror image.
|
||||
reset();
|
||||
beep_panned(440.0, 440.0, 0.2, Wave::Square, 0.5, 0.0, -1.0);
|
||||
let mut buf = buffer();
|
||||
mix_into(&mut buf, 44100.0);
|
||||
let (l, r) = (peak(&buf, 0), peak(&buf, 1));
|
||||
assert!(l > r, "panned left: left {l} right {r}");
|
||||
assert_eq!(r, 0.0);
|
||||
reset();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_centred_sound_is_equal_and_keeps_full_volume() {
|
||||
let _guard = super::SYNTH_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
reset();
|
||||
beep(440.0, 440.0, 0.2, Wave::Square, 0.5, 0.0);
|
||||
let mut centred = buffer();
|
||||
mix_into(&mut centred, 44100.0);
|
||||
let (l, r) = (peak(¢red, 0), peak(¢red, 1));
|
||||
assert_eq!(l, r, "centre must be identical in both channels");
|
||||
assert!(l > 0.0);
|
||||
reset();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distance_attenuation_scales_the_bank() {
|
||||
let _guard = super::SYNTH_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
reset();
|
||||
play_named_at("jump", 1.0, 1.0, 0.0);
|
||||
let mut loud = buffer();
|
||||
mix_into(&mut loud, 44100.0);
|
||||
let near = peak(&loud, 0);
|
||||
|
||||
reset();
|
||||
play_named_at("jump", 1.0, 0.2, 0.0);
|
||||
let mut quiet = buffer();
|
||||
mix_into(&mut quiet, 44100.0);
|
||||
let far = peak(&quiet, 0);
|
||||
|
||||
assert!(far < near, "distant {far} must be quieter than near {near}");
|
||||
assert!(far > 0.0, "still audible inside range");
|
||||
reset();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tones_sustain_until_stopped_and_reset_clears_them() {
|
||||
let _guard = super::SYNTH_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
reset();
|
||||
tone(7, 220.0, Wave::Saw, 0.4);
|
||||
let mut buf = buffer();
|
||||
mix_into(&mut buf, 44100.0);
|
||||
assert_eq!(live_counts().1, 1, "a tone sustains across buffers");
|
||||
assert!(peak(&buf, 0) > 0.0);
|
||||
|
||||
tone_stop(7);
|
||||
for _ in 0..40 {
|
||||
let mut buf = buffer();
|
||||
mix_into(&mut buf, 44100.0);
|
||||
}
|
||||
assert_eq!(live_counts().1, 0, "a stopped tone releases and is dropped");
|
||||
|
||||
tone(8, 220.0, Wave::Saw, 0.4);
|
||||
stop_all_tones();
|
||||
for _ in 0..40 {
|
||||
let mut buf = buffer();
|
||||
mix_into(&mut buf, 44100.0);
|
||||
}
|
||||
assert_eq!(live_counts().1, 0, "stop_all_tones releases everything");
|
||||
reset();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_voice_cap_holds_under_a_runaway_script() {
|
||||
let _guard = super::SYNTH_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
reset();
|
||||
for _ in 0..200 {
|
||||
beep(440.0, 440.0, 1.0, Wave::Sine, 0.2, 0.0);
|
||||
}
|
||||
assert_eq!(live_counts().0, MAX_VOICES, "oldest voices are dropped");
|
||||
reset();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_names_parse_to_concert_pitch() {
|
||||
// A4 is the tuning reference; C5 is three semitones above A4's octave.
|
||||
let a4 = note_freq("A4").unwrap();
|
||||
assert!((a4 - 440.0).abs() < 0.01, "A4 = {a4}");
|
||||
let a5 = note_freq("A5").unwrap();
|
||||
assert!((a5 - 880.0).abs() < 0.02, "A5 = {a5}");
|
||||
assert!(note_freq("H9").is_none(), "unknown tokens are rests");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,310 +0,0 @@
|
|||
//! XR controllers and hands → the same per-player input every other device
|
||||
//! sends (game.md §"XR input → player input").
|
||||
//!
|
||||
//! An XR player is not a special case in the simulation: the headset fills a
|
||||
//! [`PadState`] and a camera yaw, exactly as a gamepad or a phone would, and
|
||||
//! the packet that goes on the wire is indistinguishable. That is what lets a
|
||||
//! Quest in the corner race against a laptop without the sim knowing which is
|
||||
//! which.
|
||||
//!
|
||||
//! Handedness follows the room, not the game: the left stick walks, the right
|
||||
//! stick turns, and either hand's trigger shoots — so a left-handed player is
|
||||
//! never worse off.
|
||||
|
||||
use makepad_widgets::makepad_platform::event::xr::{XrController, XrHand, XrState};
|
||||
use makepad_widgets::*;
|
||||
|
||||
/// Sticks below this read as centred — Quest sticks rest a hair off zero and
|
||||
/// would otherwise walk the player into a wall while nobody touches them.
|
||||
const STICK_DEAD_ZONE: f32 = 0.15;
|
||||
|
||||
/// Trigger/grip pull that counts as a press.
|
||||
const ANALOG_PRESS: f32 = 0.5;
|
||||
|
||||
/// One frame of XR intent, ready to merge into a player's input.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct XrIntent {
|
||||
/// Left stick, camera-relative walk axes (x = strafe, z = forward).
|
||||
pub axis_x: f64,
|
||||
pub axis_z: f64,
|
||||
/// Right stick x, applied to the player's camera yaw (snap or smooth is
|
||||
/// the shell's choice; this is the raw request).
|
||||
pub turn: f32,
|
||||
pub jump: bool,
|
||||
pub shoot: bool,
|
||||
pub grab: bool,
|
||||
pub reset: bool,
|
||||
/// Head yaw in tracking space: what "forward" means for this player this
|
||||
/// tick. Travels in their input packet, so movement resolves against
|
||||
/// their own view without replicating a camera.
|
||||
pub head_yaw: f32,
|
||||
}
|
||||
|
||||
fn dead_zoned(v: f32) -> f64 {
|
||||
if v.abs() < STICK_DEAD_ZONE {
|
||||
0.0
|
||||
} else {
|
||||
// Rescale past the dead zone so the first live degree is gentle
|
||||
// rather than a jump to 0.15.
|
||||
let sign = if v < 0.0 { -1.0 } else { 1.0 };
|
||||
let scaled = (v.abs() - STICK_DEAD_ZONE) / (1.0 - STICK_DEAD_ZONE);
|
||||
(sign * scaled.min(1.0)) as f64
|
||||
}
|
||||
}
|
||||
|
||||
/// Yaw of a pose's forward vector, projected onto the floor plane.
|
||||
///
|
||||
/// A head tipped to look at your feet still means "forward is that way", so
|
||||
/// the y component is dropped rather than normalised into the answer.
|
||||
pub fn flat_yaw(orientation: Quat) -> f32 {
|
||||
let f = orientation.rotate_vec3(&vec3f(0.0, 0.0, -1.0));
|
||||
if f.x.abs() < 1.0e-6 && f.z.abs() < 1.0e-6 {
|
||||
// Looking straight up or down: no usable heading, keep the old one.
|
||||
return 0.0;
|
||||
}
|
||||
// Matches the engine's camera convention: forward = (sin y, -cos y).
|
||||
f.x.atan2(-f.z)
|
||||
}
|
||||
|
||||
fn controller_active(c: &XrController) -> bool {
|
||||
c.buttons & XrController::ACTIVE != 0
|
||||
}
|
||||
|
||||
/// Map a headset state to game intent. Controllers win when held; hands
|
||||
/// take over when they are the only thing tracked, so putting a controller
|
||||
/// down mid-game does not freeze the player.
|
||||
pub fn intent_from_xr(state: &XrState) -> XrIntent {
|
||||
let mut intent = XrIntent {
|
||||
head_yaw: flat_yaw(state.head_pose.orientation),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let left = &state.left_controller;
|
||||
let right = &state.right_controller;
|
||||
let any_controller = controller_active(left) || controller_active(right);
|
||||
|
||||
if any_controller {
|
||||
if controller_active(left) {
|
||||
intent.axis_x = dead_zoned(left.stick.x);
|
||||
// Stick forward is +y in XR, and forward is -z in the game.
|
||||
intent.axis_z = -dead_zoned(left.stick.y);
|
||||
intent.grab = left.grip >= ANALOG_PRESS;
|
||||
intent.shoot |= left.trigger >= ANALOG_PRESS;
|
||||
intent.jump |= left.buttons & XrController::CLICK_X != 0;
|
||||
intent.reset |= left.buttons & XrController::CLICK_Y != 0;
|
||||
}
|
||||
if controller_active(right) {
|
||||
intent.turn = if right.stick.x.abs() < STICK_DEAD_ZONE {
|
||||
0.0
|
||||
} else {
|
||||
right.stick.x
|
||||
};
|
||||
intent.grab |= right.grip >= ANALOG_PRESS;
|
||||
intent.shoot |= right.trigger >= ANALOG_PRESS;
|
||||
intent.jump |= right.buttons & XrController::CLICK_A != 0;
|
||||
intent.reset |= right.buttons & XrController::CLICK_B != 0;
|
||||
}
|
||||
return intent;
|
||||
}
|
||||
|
||||
// Hands-only: pinch is the whole vocabulary. Index pinch is the trigger
|
||||
// (shoot), middle pinch is grab — cheap, reliable, and no gesture the
|
||||
// player has to learn beyond "pinch to act".
|
||||
let hand_shoot = |h: &XrHand| h.pinch_index();
|
||||
let hand_grab = |h: &XrHand| h.pinch_middle();
|
||||
intent.shoot = hand_shoot(&state.left_hand) || hand_shoot(&state.right_hand);
|
||||
intent.grab = hand_grab(&state.left_hand) || hand_grab(&state.right_hand);
|
||||
intent
|
||||
}
|
||||
|
||||
/// Fold XR intent into the pad half of a player's input. Digital keys are
|
||||
/// untouched, so a headset and a keyboard can drive the same player.
|
||||
pub fn apply_intent_to_pad(intent: &XrIntent, pad: &mut PadState) {
|
||||
let was_jump = pad.jump;
|
||||
let was_shoot = pad.shoot;
|
||||
let was_grab = pad.grab;
|
||||
let was_reset = pad.reset;
|
||||
|
||||
pad.axis_x = intent.axis_x;
|
||||
pad.axis_z = intent.axis_z;
|
||||
pad.jump = intent.jump;
|
||||
pad.shoot = intent.shoot;
|
||||
pad.grab = intent.grab;
|
||||
pad.reset = intent.reset;
|
||||
|
||||
// Edges, so a held trigger fires once — same contract as the gamepad.
|
||||
pad.jump_pressed = intent.jump && !was_jump;
|
||||
pad.shoot_pressed = intent.shoot && !was_shoot;
|
||||
pad.grab_pressed = intent.grab && !was_grab;
|
||||
pad.reset_pressed = intent.reset && !was_reset;
|
||||
}
|
||||
|
||||
pub use makepad_game_sim::PadState;
|
||||
|
||||
/// The stage this device starts in, from `ARCADE_XR`.
|
||||
///
|
||||
/// `mr` is the Quest default in game.md — passthrough on, world shrunk onto
|
||||
/// the floor. `vr` is full-scale for headsets with no passthrough. Anything
|
||||
/// else (including unset) stays flat, which is what desktop and mobile want.
|
||||
pub fn stage_from_env() -> Stage {
|
||||
match std::env::var("ARCADE_XR").as_deref() {
|
||||
Ok("mr") | Ok("MR") => {
|
||||
let scale = std::env::var("ARCADE_XR_SCALE")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<f32>().ok())
|
||||
.filter(|s| *s > 0.0)
|
||||
.unwrap_or(DIORAMA_SCALE);
|
||||
// Planted on the floor, 1.5m in front of where the player starts.
|
||||
Stage::mr_diorama(vec3f(0.0, 0.0, -1.5), 0.0, scale)
|
||||
}
|
||||
Ok("vr") | Ok("VR") => Stage::vr_full_scale(),
|
||||
_ => Stage::flat(),
|
||||
}
|
||||
}
|
||||
|
||||
use makepad_game_render::stage::{Stage, DIORAMA_SCALE};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn active(mut c: XrController) -> XrController {
|
||||
c.buttons |= XrController::ACTIVE;
|
||||
c
|
||||
}
|
||||
|
||||
fn state_with(left: XrController, right: XrController) -> XrState {
|
||||
XrState {
|
||||
left_controller: left,
|
||||
right_controller: right,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resting_sticks_do_not_walk_the_player() {
|
||||
let left = active(XrController {
|
||||
stick: vec2f(0.08, -0.10),
|
||||
..Default::default()
|
||||
});
|
||||
let intent = intent_from_xr(&state_with(left, XrController::default()));
|
||||
assert_eq!(intent.axis_x, 0.0);
|
||||
assert_eq!(intent.axis_z, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn left_stick_walks_with_game_forward_sign() {
|
||||
let left = active(XrController {
|
||||
// Full forward push on the stick.
|
||||
stick: vec2f(0.0, 1.0),
|
||||
..Default::default()
|
||||
});
|
||||
let intent = intent_from_xr(&state_with(left, XrController::default()));
|
||||
// Forward in the game is -z.
|
||||
assert!(intent.axis_z < -0.9, "{intent:?}");
|
||||
assert_eq!(intent.axis_x, 0.0);
|
||||
|
||||
let left = active(XrController {
|
||||
stick: vec2f(1.0, 0.0),
|
||||
..Default::default()
|
||||
});
|
||||
let intent = intent_from_xr(&state_with(left, XrController::default()));
|
||||
assert!(intent.axis_x > 0.9, "{intent:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn either_trigger_shoots_and_grips_grab() {
|
||||
let left = active(XrController {
|
||||
trigger: 1.0,
|
||||
..Default::default()
|
||||
});
|
||||
assert!(intent_from_xr(&state_with(left, XrController::default())).shoot);
|
||||
|
||||
let right = active(XrController {
|
||||
trigger: 1.0,
|
||||
grip: 1.0,
|
||||
..Default::default()
|
||||
});
|
||||
let intent = intent_from_xr(&state_with(XrController::default(), right));
|
||||
assert!(intent.shoot && intent.grab);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn face_buttons_jump_and_reset() {
|
||||
let right = active(XrController {
|
||||
buttons: XrController::CLICK_A,
|
||||
..Default::default()
|
||||
});
|
||||
assert!(intent_from_xr(&state_with(XrController::default(), right)).jump);
|
||||
|
||||
let right = active(XrController {
|
||||
buttons: XrController::CLICK_B,
|
||||
..Default::default()
|
||||
});
|
||||
assert!(intent_from_xr(&state_with(XrController::default(), right)).reset);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hands_drive_when_no_controller_is_tracked() {
|
||||
let mut state = XrState::default();
|
||||
// Nothing is ACTIVE, so the hand path owns input. Pinch lives in the
|
||||
// flags bitfield — the runtime's own detection — not the raw
|
||||
// strength array.
|
||||
state.right_hand.flags |= XrHand::PINCH_INDEX;
|
||||
let intent = intent_from_xr(&state);
|
||||
assert!(intent.shoot, "index pinch should shoot: {intent:?}");
|
||||
assert!(!intent.grab);
|
||||
|
||||
state.right_hand.flags &= !XrHand::PINCH_INDEX;
|
||||
state.left_hand.flags |= XrHand::PINCH_MIDDLE;
|
||||
assert!(intent_from_xr(&state).grab);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tracked_controller_beats_a_pinching_hand() {
|
||||
// Hands stay tracked while holding controllers; a curled finger on
|
||||
// the grip must not read as a pinch and fire the gun.
|
||||
let mut state = state_with(
|
||||
active(XrController::default()),
|
||||
active(XrController::default()),
|
||||
);
|
||||
state.right_hand.flags |= XrHand::PINCH_INDEX;
|
||||
assert!(!intent_from_xr(&state).shoot);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn head_yaw_matches_the_engine_camera_convention() {
|
||||
// Facing -z (the default look direction) is yaw 0.
|
||||
let state = XrState::default();
|
||||
assert!(intent_from_xr(&state).head_yaw.abs() < 1.0e-5);
|
||||
|
||||
// Quarter turn to face +x.
|
||||
let mut state = XrState::default();
|
||||
state.head_pose.orientation =
|
||||
Quat::from_axis_angle(vec3f(0.0, 1.0, 0.0), std::f32::consts::FRAC_PI_2);
|
||||
let yaw = intent_from_xr(&state).head_yaw;
|
||||
assert!(
|
||||
(yaw.abs() - std::f32::consts::FRAC_PI_2).abs() < 1.0e-4,
|
||||
"{yaw}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pad_edges_fire_once_per_press() {
|
||||
let mut pad = PadState::default();
|
||||
let held = XrIntent {
|
||||
shoot: true,
|
||||
..Default::default()
|
||||
};
|
||||
apply_intent_to_pad(&held, &mut pad);
|
||||
assert!(pad.shoot && pad.shoot_pressed);
|
||||
// Still held next tick: no new edge.
|
||||
apply_intent_to_pad(&held, &mut pad);
|
||||
assert!(pad.shoot && !pad.shoot_pressed);
|
||||
// Released, then pressed again.
|
||||
apply_intent_to_pad(&XrIntent::default(), &mut pad);
|
||||
assert!(!pad.shoot && !pad.shoot_pressed);
|
||||
apply_intent_to_pad(&held, &mut pad);
|
||||
assert!(pad.shoot_pressed);
|
||||
}
|
||||
}
|
||||
|
|
@ -361,13 +361,10 @@ fn worker_main(
|
|||
let mut tool_calls: Vec<(String, String)> = Vec::new();
|
||||
loop {
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
// Close the dangling assistant turn so the KV stays valid,
|
||||
// and drop any tool calls the cancelled turn produced — an
|
||||
// interrupted command must not keep acting.
|
||||
// Close the dangling assistant turn so the KV stays valid.
|
||||
if let Some(im_end) = im_end {
|
||||
let _ = session.append_token(im_end);
|
||||
}
|
||||
tool_calls.clear();
|
||||
break;
|
||||
}
|
||||
if generated >= MAX_NEW_TOKENS
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ script_mod! {
|
|||
startup() do #(App::script_component(vm)){
|
||||
ui: Root{
|
||||
main_window := Window{
|
||||
window.inner_size: vec2(3400, 2050)
|
||||
window.inner_size: vec2(1280, 840)
|
||||
pass.clear_color: vec4(0.08, 0.10, 0.12, 1.0)
|
||||
body +: {
|
||||
View{
|
||||
|
|
@ -111,15 +111,12 @@ script_mod! {
|
|||
map := MapView{
|
||||
width: Fill
|
||||
height: Fill
|
||||
// GPU-opt benchmark scene: AMS side view at the
|
||||
// lowest zoom that shows 3D geometry.
|
||||
center_lon: 4.8952
|
||||
center_lat: 52.3702
|
||||
zoom: 15.6
|
||||
tilt: 60.0
|
||||
zoom: 13.0
|
||||
min_zoom: 3.0
|
||||
mbtiles_path: "local/maps/world.mkmap"
|
||||
detail_mbtiles_path: "local/maps/world.mkmap"
|
||||
mbtiles_path: "local/maps/europe-shortbread.mbtiles"
|
||||
detail_mbtiles_path: "local/maps/europe-osm-detail.mbtiles"
|
||||
bridge_dz_mbtiles_path: "local/maps/nl-bridge-dz.mbtiles"
|
||||
buildings_3d: true
|
||||
}
|
||||
|
|
@ -793,30 +790,6 @@ pub struct App {
|
|||
/// Last nav banner instruction spoken, so each maneuver is announced once.
|
||||
#[rust]
|
||||
last_spoken_banner: String,
|
||||
/// In-flight dispatcher prompt, for user-override cancellation.
|
||||
#[rust]
|
||||
current_prompt: Option<PromptId>,
|
||||
/// Tool calls executed for the current prompt (loop budget).
|
||||
#[rust]
|
||||
tool_rounds: usize,
|
||||
/// Previous (name, args) this turn — breaks identical-call loops.
|
||||
#[rust]
|
||||
last_tool_call: Option<(String, String)>,
|
||||
}
|
||||
|
||||
/// Pull "ctx USED/MAX" out of the local timing status line.
|
||||
fn parse_ctx_usage(timing: &str) -> Option<(usize, usize)> {
|
||||
let at = timing.rfind("ctx ")?;
|
||||
let rest = &timing[at + 4..];
|
||||
let (used, max) = rest.trim().split_once('/')?;
|
||||
Some((
|
||||
used.trim().parse().ok()?,
|
||||
max.trim()
|
||||
.split(|c: char| !c.is_ascii_digit())
|
||||
.next()?
|
||||
.parse()
|
||||
.ok()?,
|
||||
))
|
||||
}
|
||||
|
||||
fn read_secret(name: &str) -> Option<String> {
|
||||
|
|
@ -873,12 +846,6 @@ impl App {
|
|||
/// dispatcher instead. Claude, when a key exists, otherwise serves as
|
||||
/// the cloud_ask escalation tool.
|
||||
fn init_agent(&mut self, cx: &mut Cx) {
|
||||
// AI on by default again (perf campaign over); MAKEPAD_NO_AI=1
|
||||
// keeps the GPU clear of the 9B/4B/whisper chain when profiling
|
||||
// the map renderer.
|
||||
if std::env::var_os("MAKEPAD_NO_AI").is_some() {
|
||||
return;
|
||||
}
|
||||
let api_key = read_secret("ANTHROPIC_API_KEY");
|
||||
let cloud_dispatch = std::env::var("MAKEPAD_ROUTE_CLOUD").is_ok() && api_key.is_some();
|
||||
|
||||
|
|
@ -1046,11 +1013,7 @@ impl App {
|
|||
self.push_line(cx, "⚠ no agent available");
|
||||
return;
|
||||
}
|
||||
// A new turn obsoletes whatever the voice was still saying — and a
|
||||
// typed command while the dispatcher runs is always an override.
|
||||
if self.busy {
|
||||
self.interrupt_turn(cx);
|
||||
}
|
||||
// A new turn obsoletes whatever the voice was still saying.
|
||||
if let Some(speech) = &mut self.speech {
|
||||
speech.stop();
|
||||
}
|
||||
|
|
@ -1072,64 +1035,11 @@ impl App {
|
|||
self.trip.digest()
|
||||
);
|
||||
let (agent, session) = (self.agent.as_mut().unwrap(), self.session.unwrap());
|
||||
let prompt_id = agent.send_prompt(cx, session, &prompt);
|
||||
self.current_prompt = Some(prompt_id);
|
||||
self.tool_rounds = 0;
|
||||
self.last_tool_call = None;
|
||||
agent.send_prompt(cx, session, &prompt);
|
||||
self.busy = true;
|
||||
self.set_status(cx, "thinking…");
|
||||
}
|
||||
|
||||
/// User override: cancel the in-flight dispatcher turn NOW. The worker
|
||||
/// stops per-token, closes the assistant turn and drops its tool calls.
|
||||
fn interrupt_turn(&mut self, cx: &mut Cx) {
|
||||
if !self.busy {
|
||||
return;
|
||||
}
|
||||
if let (Some(agent), Some(prompt_id)) = (self.agent.as_mut(), self.current_prompt) {
|
||||
agent.cancel_prompt(cx, prompt_id);
|
||||
}
|
||||
self.busy = false;
|
||||
self.voice_queue.clear();
|
||||
if let Some(speech) = &mut self.speech {
|
||||
speech.stop();
|
||||
}
|
||||
self.push_entry(cx, EntryKind::Info, "⏹ interrupted");
|
||||
self.set_status(cx, "ready");
|
||||
}
|
||||
|
||||
/// Does this utterance override the current activity? Only the LEADING
|
||||
/// words count — "add a charging stop" must not match on "stop".
|
||||
fn is_override_command(text: &str) -> bool {
|
||||
let lower = text.to_lowercase();
|
||||
lower
|
||||
.split(|c: char| !c.is_alphanumeric())
|
||||
.filter(|w| !w.is_empty())
|
||||
.take(2)
|
||||
.any(|w| {
|
||||
matches!(
|
||||
w,
|
||||
"stop" | "cancel" | "nevermind" | "never" | "forget" | "wait" | "actually"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// "stop" / "nevermind" with no follow-up command: just halt.
|
||||
fn is_pure_stop(text: &str) -> bool {
|
||||
let words: Vec<&str> = text
|
||||
.split(|c: char| !c.is_alphanumeric())
|
||||
.filter(|w| !w.is_empty())
|
||||
.collect();
|
||||
words.len() <= 3
|
||||
&& words.iter().all(|w| {
|
||||
matches!(
|
||||
w.to_lowercase().as_str(),
|
||||
"ok" | "okay" | "no" | "stop" | "cancel" | "nevermind" | "never" | "mind"
|
||||
| "it" | "that" | "forget" | "computer"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn on_agent_event(&mut self, cx: &mut Cx, event: AgentEvent) {
|
||||
match event {
|
||||
AgentEvent::SessionReady { .. } => {
|
||||
|
|
@ -1170,35 +1080,17 @@ impl App {
|
|||
speech.flush();
|
||||
}
|
||||
self.busy = false;
|
||||
self.current_prompt = None;
|
||||
let timing = self
|
||||
.local_timing
|
||||
.as_ref()
|
||||
.and_then(|t| t.lock().ok().map(|s| s.clone()))
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| "ready".to_string());
|
||||
// The session is append-only: once the context is nearly
|
||||
// full it cannot recover — restart with a fresh session
|
||||
// (mmap makes the reload cheap; chat history stays in the
|
||||
// transcript, the model just loses conversational memory).
|
||||
if let Some((used, max)) = parse_ctx_usage(&timing) {
|
||||
if used * 10 > max * 9 {
|
||||
self.push_entry(
|
||||
cx,
|
||||
EntryKind::Info,
|
||||
&format!("⚠ context {used}/{max} — restarting local session"),
|
||||
);
|
||||
self.agent = None;
|
||||
self.session = None;
|
||||
self.init_agent(cx);
|
||||
}
|
||||
}
|
||||
self.set_status(cx, &timing);
|
||||
}
|
||||
AgentEvent::PromptError { error, .. } => {
|
||||
self.commit_pending(cx);
|
||||
self.busy = false;
|
||||
self.current_prompt = None;
|
||||
self.push_line(cx, &format!("⚠ {error}"));
|
||||
self.set_status(cx, "error — try again");
|
||||
}
|
||||
|
|
@ -1504,21 +1396,9 @@ impl App {
|
|||
GateResult::Send { raw, instruction } => {
|
||||
let _ = &raw;
|
||||
self.push_entry(cx, EntryKind::Info, "→ directed");
|
||||
if self.busy && Self::is_override_command(&instruction) {
|
||||
// "ok nevermind, stop, go do X now" — cancel the turn;
|
||||
// send the rest unless it was a bare stop.
|
||||
self.interrupt_turn(cx);
|
||||
if !Self::is_pure_stop(&instruction) {
|
||||
self.send_user_prompt(cx, &instruction);
|
||||
}
|
||||
} else if self.busy {
|
||||
if self.busy {
|
||||
self.push_entry(cx, EntryKind::Info, "(queued until current turn ends)");
|
||||
self.voice_queue.push(instruction);
|
||||
} else if Self::is_pure_stop(&instruction) {
|
||||
// Nothing running — just quiet the voice.
|
||||
if let Some(speech) = &mut self.speech {
|
||||
speech.stop();
|
||||
}
|
||||
} else {
|
||||
self.send_user_prompt(cx, &instruction);
|
||||
}
|
||||
|
|
@ -1561,28 +1441,6 @@ impl App {
|
|||
fn run_tool(&mut self, cx: &mut Cx, tool_use_id: &str, name: &str, input: &str) {
|
||||
let compact: String = input.chars().take(120).collect();
|
||||
self.push_entry(cx, EntryKind::Tool, &format!("⚙ {name} {compact}"));
|
||||
// Loop breakers: greedy decoding can wedge the dispatcher into
|
||||
// re-issuing the same call forever (seen: identical geo_search
|
||||
// spam). Repeats and over-budget turns get a corrective tool
|
||||
// result instead of execution.
|
||||
self.tool_rounds += 1;
|
||||
let this_call = (name.to_string(), input.to_string());
|
||||
let repeated = self.last_tool_call.as_ref() == Some(&this_call);
|
||||
self.last_tool_call = Some(this_call);
|
||||
if repeated || self.tool_rounds > 10 {
|
||||
let nudge = if repeated {
|
||||
"Error: identical tool call repeated — you already have this result. \
|
||||
Do NOT call this tool again; answer the user now with what you know."
|
||||
} else {
|
||||
"Error: tool budget for this request is exhausted. \
|
||||
Stop calling tools and answer the user now with what you have."
|
||||
};
|
||||
self.push_entry(cx, EntryKind::Info, "⛔ tool loop broken");
|
||||
if let (Some(agent), Some(session)) = (self.agent.as_mut(), self.session) {
|
||||
agent.send_tool_result(cx, session, tool_use_id, nudge, true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// images_search runs async over cx.http_request; the tool result is
|
||||
// sent when the thumbnails land.
|
||||
if name == "images_search" {
|
||||
|
|
|
|||
|
|
@ -21,15 +21,6 @@ pub fn defs() -> Vec<ToolDefinition> {
|
|||
|
||||
pub fn search(ctx: &mut ToolCtx, args: &JsonValue) -> Result<String, String> {
|
||||
let query = arg_str(args, "query").ok_or("missing query")?.to_string();
|
||||
// A degenerate/repetitious query (greedy-decoding loops produce them)
|
||||
// makes the fuzzy index scan crawl. Refuse with guidance instead.
|
||||
if query.len() > 64 || query.split_whitespace().count() > 8 {
|
||||
return Err(format!(
|
||||
"query too long ({} chars) — search for ONE short place name or category, \
|
||||
e.g. 'Oudegracht 399 Utrecht' or 'hotel museumkwartier'",
|
||||
query.len()
|
||||
));
|
||||
}
|
||||
let limit = arg_usize(args, "limit").unwrap_or(8).clamp(1, 20);
|
||||
let (center_lon, center_lat) = ctx.map_center();
|
||||
let near = LonLat {
|
||||
|
|
|
|||
256
blur.md
Normal file
256
blur.md
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
# Numbered Overlays And Multi-Level Gauss Blur
|
||||
|
||||
## Goal
|
||||
|
||||
Replace the current single overlay channel with numbered overlay levels, then add two Gauss capture levels:
|
||||
|
||||
- Gauss level 0: captures the base window scene before any overlay.
|
||||
- Gauss level 1: captures the base scene plus overlay level 0.
|
||||
|
||||
This lets glass on overlay level 0 blur/lens the app background, while glass on overlay level 1 can blur/lens the already-composited level-0 UI. It also keeps the rule clear: a glass surface samples only completed lower levels, never itself or peers in the same level.
|
||||
|
||||
## Current State
|
||||
|
||||
The current overlay path is singular:
|
||||
|
||||
- `draw/src/cx_2d.rs` stores one `overlay_id`, one `overlay_pass_id`, and `overlay_draw_depth`.
|
||||
- `draw/src/overlay.rs` owns one overlay root `DrawList`.
|
||||
- `draw/src/draw_list_2d.rs::begin_overlay_reuse()` appends a widget draw list to that one overlay root.
|
||||
- `widgets/src/window.rs` owns one `overlay: Overlay`.
|
||||
- `widgets/src/gauss_view.rs::request_window_gauss()` only succeeds while `cx.is_drawing_overlay()` is true.
|
||||
- `Window::begin()` chooses either the normal pass or the `gauss_scene` capture pass, then points the one overlay root at the final window pass.
|
||||
|
||||
That means all overlay widgets are in one ordering bucket. A glass popup can sample the base app scene, but another glass layer above it cannot sample the popup as part of its blurred backing. Overlapping glass in the same overlay level also cannot produce nested blur because all glass is sampling the same lower snapshot.
|
||||
|
||||
## Proposed Model
|
||||
|
||||
Introduce ordered overlay levels:
|
||||
|
||||
```rust
|
||||
pub type OverlayLevel = u8;
|
||||
|
||||
pub const OVERLAY_LEVEL_BASE: OverlayLevel = 0;
|
||||
pub const OVERLAY_LEVEL_FLOATING: OverlayLevel = 1;
|
||||
pub const OVERLAY_LEVEL_TOP: OverlayLevel = 2;
|
||||
```
|
||||
|
||||
Level order is strict:
|
||||
|
||||
1. Main scene
|
||||
2. Overlay level 0
|
||||
3. Overlay level 1
|
||||
4. Overlay level 2 and higher
|
||||
|
||||
Within a level, existing append order and `begin_overlay_last` behavior still apply. Across levels, numeric level decides ordering.
|
||||
|
||||
## Public API Shape
|
||||
|
||||
Keep existing calls as level-0 compatibility:
|
||||
|
||||
```rust
|
||||
draw_list.begin_overlay_reuse(cx); // means level 0
|
||||
draw_list.begin_overlay_last(cx); // means level 0, last within level 0
|
||||
```
|
||||
|
||||
Add explicit level APIs:
|
||||
|
||||
```rust
|
||||
draw_list.begin_overlay_level_reuse(cx, level);
|
||||
draw_list.begin_overlay_level_last(cx, level);
|
||||
```
|
||||
|
||||
Widgets that own overlay draw lists should gain a live property:
|
||||
|
||||
```rust
|
||||
#[live(0)]
|
||||
overlay_level: u8,
|
||||
```
|
||||
|
||||
Then `Tooltip`, `PopupNotification`, `PopupMenu`, `Modal`, dock ghost overlays, and the new glass layer can choose where they belong without custom render code.
|
||||
|
||||
## Render Context Changes
|
||||
|
||||
Replace the single overlay target fields in `Cx2d` with a small overlay target table plus current overlay draw state:
|
||||
|
||||
```rust
|
||||
pub struct OverlayTarget {
|
||||
pub draw_list_id: DrawListId,
|
||||
pub pass_id: Option<DrawPassId>,
|
||||
}
|
||||
|
||||
pub struct Cx2d<'a, 'b> {
|
||||
overlay_targets: SmallVec<[Option<OverlayTarget>; 4]>,
|
||||
current_overlay_level: Option<OverlayLevel>,
|
||||
overlay_draw_depth: usize,
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
Required helpers:
|
||||
|
||||
```rust
|
||||
pub fn is_drawing_overlay(&self) -> bool;
|
||||
pub fn current_overlay_level(&self) -> Option<OverlayLevel>;
|
||||
pub fn overlay_target(&self, level: OverlayLevel) -> Option<&OverlayTarget>;
|
||||
```
|
||||
|
||||
`DrawList2d::begin_overlay_level_*` should:
|
||||
|
||||
- Look up the target for the requested level.
|
||||
- Set this draw list's pass to the target pass, falling back to the current pass if no explicit pass exists.
|
||||
- Store this draw list under that level's overlay root.
|
||||
- Set `current_overlay_level` while drawing.
|
||||
- Increment/decrement `overlay_draw_depth` as today.
|
||||
|
||||
## Overlay Stack
|
||||
|
||||
Replace or wrap `Overlay` with an `OverlayStack` owned by `Window`:
|
||||
|
||||
```rust
|
||||
pub struct OverlayStack {
|
||||
levels: Vec<Overlay>,
|
||||
}
|
||||
```
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Allocate one root overlay draw list per level.
|
||||
- During window begin, publish all active overlay targets into `Cx2d`.
|
||||
- During window end, append each overlay root in ascending level order.
|
||||
- Flush stale sublists per level, using the same redraw-id cleanup as today's single `Overlay::end()`.
|
||||
|
||||
Backward compatibility can be kept by making `Overlay` a thin level-0 wrapper or by preserving `Overlay::begin()` as `OverlayStack::begin_level(0)`.
|
||||
|
||||
## Two Gauss Capture Levels
|
||||
|
||||
Extend the per-window Gauss state from one request/snapshot to two indexed capture states:
|
||||
|
||||
```rust
|
||||
pub const WINDOW_GAUSS_LEVELS: usize = 2;
|
||||
|
||||
struct GaussCaptureState {
|
||||
requested_last_frame: bool,
|
||||
requested_this_frame: bool,
|
||||
capture_active: bool,
|
||||
snapshot: Option<GaussBlurSnapshot>,
|
||||
}
|
||||
|
||||
struct GaussWindowEntry {
|
||||
generation: u64,
|
||||
levels: [GaussCaptureState; WINDOW_GAUSS_LEVELS],
|
||||
}
|
||||
```
|
||||
|
||||
Add APIs:
|
||||
|
||||
```rust
|
||||
pub fn request_window_gauss_level(cx: &mut Cx2d, level: usize) -> Option<GaussBlurSnapshot>;
|
||||
pub fn request_window_gauss(cx: &mut Cx2d) -> Option<GaussBlurSnapshot>;
|
||||
```
|
||||
|
||||
Default behavior:
|
||||
|
||||
- If drawing overlay level 0, `request_window_gauss()` requests Gauss level 0.
|
||||
- If drawing overlay level 1 or higher, `request_window_gauss()` requests Gauss level 1.
|
||||
- Explicit `request_window_gauss_level()` exists for advanced widgets and tests.
|
||||
|
||||
This mapping keeps existing `GaussRoundedView` source-compatible while allowing higher overlays to sample a later scene.
|
||||
|
||||
## Window Render Order
|
||||
|
||||
When no Gauss is requested, rendering can remain almost identical:
|
||||
|
||||
1. Draw main scene to the window pass.
|
||||
2. Draw overlay roots in numeric order.
|
||||
|
||||
When only Gauss level 0 is requested:
|
||||
|
||||
1. Draw main scene into `gauss_stack[0].scene`.
|
||||
2. Build blur chain 0.
|
||||
3. Draw scene 0 texture to the window pass.
|
||||
4. Draw overlay level 0+ to the window pass. Level-0 glass samples snapshot 0.
|
||||
|
||||
When Gauss level 1 is requested:
|
||||
|
||||
1. Draw main scene into `gauss_stack[0].scene`.
|
||||
2. Build blur chain 0.
|
||||
3. Draw/copy scene 0 into `gauss_stack[1].scene`.
|
||||
4. Draw overlay level 0 into `gauss_stack[1].scene`. Level-0 glass samples snapshot 0.
|
||||
5. Build blur chain 1 from `gauss_stack[1].scene`.
|
||||
6. Draw scene 1 texture to the window pass.
|
||||
7. Draw overlay level 1+ to the window pass. Level-1 glass samples snapshot 1.
|
||||
|
||||
This avoids feedback loops:
|
||||
|
||||
- Level 0 never samples itself.
|
||||
- Level 1 samples base plus completed level 0.
|
||||
- Same-level glass overlap is still not nested blur, by design.
|
||||
|
||||
## Gauss Stack Ownership
|
||||
|
||||
`Window` currently owns one `GaussStack`. Change that to two stacks:
|
||||
|
||||
```rust
|
||||
gauss_stacks: [GaussStack; WINDOW_GAUSS_LEVELS],
|
||||
```
|
||||
|
||||
To reduce duplicated code:
|
||||
|
||||
- Keep `GaussStack` as the render-texture/mip-chain owner.
|
||||
- Add a helper that renders one stack from an input draw phase.
|
||||
- Reuse existing `DrawGaussDownsample`, `DrawGaussUpsample`, and `DrawGaussScene`.
|
||||
|
||||
The second stack needs a way to start with the previous level's scene texture. A simple first implementation can draw `gauss_stack[0].scene_texture` into `gauss_stack[1].scene` with `DrawGaussScene`, then draw overlay level 0 over it.
|
||||
|
||||
## Widget Migration
|
||||
|
||||
Add `overlay_level` to:
|
||||
|
||||
- `PopupNotification`
|
||||
- `Tooltip`
|
||||
- `PopupMenu`
|
||||
- `Modal`
|
||||
- Dock drag ghost overlay
|
||||
- `mod.widgets.glass.Layer`
|
||||
|
||||
Defaults:
|
||||
|
||||
- Existing behavior stays level 0.
|
||||
- Menus/tooltips that must float over glass app chrome can opt into level 1.
|
||||
- Debug/drag/cursor-like overlays can use level 2 or `begin_overlay_level_last()`.
|
||||
|
||||
## Compatibility Rules
|
||||
|
||||
- Existing apps keep working because `begin_overlay_reuse()` maps to level 0.
|
||||
- Existing `GaussRoundedView` keeps working because `request_window_gauss()` maps from current overlay level.
|
||||
- Non-overlay Gauss requests should still return `None`.
|
||||
- Existing single-level popup demos should look unchanged.
|
||||
|
||||
## Tests And Validation
|
||||
|
||||
Add focused tests or demos:
|
||||
|
||||
1. Base scene only: no overlay, no Gauss request, same as current render.
|
||||
2. Level-0 glass over detailed background: samples Gauss level 0.
|
||||
3. Level-1 glass popup over level-0 glass nav: samples Gauss level 1 and visibly blurs/lenses the nav.
|
||||
4. Same-level overlapping glass: does not recursively blur peers.
|
||||
5. `begin_overlay_last()` remains last only within its level.
|
||||
6. Stale overlay draw lists are removed independently per level.
|
||||
|
||||
Runtime validation should use Studio release runs and screenshots, because this is a UI/render-stack change.
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
1. Add numbered overlay plumbing to `Cx2d`, `DrawList2d`, and `OverlayStack`, keeping old APIs as level-0 wrappers.
|
||||
2. Migrate `Window` from one `Overlay` to `OverlayStack` with one level enabled first.
|
||||
3. Add `overlay_level` fields to overlay widgets and verify old demos are unchanged.
|
||||
4. Refactor Gauss window global state to indexed capture states.
|
||||
5. Add two `GaussStack`s to `Window` and implement level-1 capture/composite.
|
||||
6. Update `request_window_gauss()` to choose a snapshot from the current overlay level.
|
||||
7. Build a small demo with level-0 app chrome and level-1 popup glass to prove nested blur.
|
||||
|
||||
## Open Decisions
|
||||
|
||||
- Maximum overlay levels: fixed small array versus dynamic vector. A fixed small array is simpler and likely enough initially.
|
||||
- Whether level 2 should get a third Gauss capture later. For now it should render above level 1 while sampling level-1's snapshot if it uses glass.
|
||||
- Whether pass compositing should draw full scene textures at each capture level or draw only overlay deltas. Full scene textures are simpler and safer first; delta compositing can be optimized later.
|
||||
161
bridge.md
Normal file
161
bridge.md
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
# bridge.md — road elevation & grade separation for the 3D car-follow view
|
||||
|
||||
How we get overpasses, bridges, tunnels and stacked interchanges to render with
|
||||
real depth in the perspective/ortho car-follow GPS view. Companion to `map.md`
|
||||
(renderer), `gps.md` (interaction), `datasources.md` §2 (elevation sources; this
|
||||
doc expands its "Road elevation / grade separation" subsection). Researched and
|
||||
endpoint-checked 2026-07-29.
|
||||
|
||||
## 1. The data reality: OSM will not store road z
|
||||
|
||||
- **OSM has no plans to add elevation to road geometry — it's a settled
|
||||
position, not an oversight.** The data model is strictly 2D lat/lon. `ele=*`
|
||||
exists but is for summits/POIs; the wiki is explicit that OSM is not an
|
||||
elevation database. No active proposal changes this.
|
||||
- **Overture landed in the same place.** Their transportation `level` property
|
||||
is relative stacking order only, documented as "an approximation for
|
||||
rendering, not a precise indication of elevation". Geometry is 2D.
|
||||
- The freshest ecosystem effort (OSM2World's Prototype-Fund project, June 2026 —
|
||||
see §3) plans *around* the missing z, not toward adding it. At most it may
|
||||
propose new helper *tags*.
|
||||
|
||||
Conclusion: waiting for upstream z is not a strategy. Everyone derives it;
|
||||
so will we — at tile/nav build time, baked into our own tiles.
|
||||
|
||||
## 2. What road geometry does carry (all already in our PBF)
|
||||
|
||||
| Tag | Meaning | Use for us |
|
||||
|---|---|---|
|
||||
| `layer=*` | relative stacking, −5..5, per way | the load-bearing input: crossing order |
|
||||
| `bridge=*` / `tunnel=*` | way is elevated / buried; ways are split at the structure ends | where decks start/end; abutment anchors |
|
||||
| `incline=*` | signed grade on a way (%, or up/down) | constraint on ramps where mapped |
|
||||
| `maxheight=*` | legal clearance *under* a structure | lower bound on deck z at that crossing |
|
||||
| `height=*`, `ele=*` on bridge ways | rare, occasionally present | direct evidence when it exists |
|
||||
| `bridge:structure`, `man_made=bridge` areas | structure type / deck outline polygon | rendering style; polygon for AHN sampling (§5) |
|
||||
|
||||
`layer` is relative and only meaningful *at crossings* — two `layer=1` bridges
|
||||
in different towns share nothing. Treat it as a partial order between ways whose
|
||||
geometries intersect, never as an absolute height.
|
||||
|
||||
## 3. Prior art
|
||||
|
||||
- **OSM2World** is the reference implementation of derive-at-build: an elevation
|
||||
connector graph is initialized at terrain height, then a constraint pass
|
||||
enforces equal z at shared nodes, minimum vertical clearance where a higher
|
||||
`layer` crosses a lower one, tunnels below terrain, and grade
|
||||
smoothing/clamping (heat-equation smoothing was merged as PR #176).
|
||||
- **OSM2World "OSM-3D-Terrain"** (Prototype Fund, roadmap 2026-06-03) is
|
||||
productizing exactly this into 3D tiles with LOD — and explicitly flags that
|
||||
elevation adjustments must not create discontinuities at tile boundaries.
|
||||
Same problem class as ours; worth tracking their output formats.
|
||||
- **MapLibre / the open vector-map world**: nobody renders true grade-separated
|
||||
interchanges yet. Terrain drape squashes bridges onto the valley floor;
|
||||
Terrain3D work in MapLibre Native doesn't address elevated roads. Doing this
|
||||
properly is a visible differentiator for our renderer.
|
||||
- Commercial nav stacks survey slope/z per link themselves — same conclusion
|
||||
from the other direction: the community map gives topology and stacking,
|
||||
measured/derived z comes from elsewhere.
|
||||
|
||||
## 4. The derivation recipe (tile/nav build, Europe-wide)
|
||||
|
||||
Runs offline in `tools/map_tiles` / `nav-build`; the renderer only ever sees
|
||||
baked results.
|
||||
|
||||
1. **Init**: every road/rail vertex gets z = DEM height at (x,y)
|
||||
(Mapterhorn PMTiles / AHN, per `datasources.md` §2).
|
||||
2. **Never sample the DEM mid-bridge or mid-tunnel.** Bridge/tunnel ways are
|
||||
split at the structure boundary, so the endpoints are the abutments:
|
||||
interpolate z between abutments (linear; spline if the deck is long).
|
||||
Sampling under the deck is the classic artifact — the bridge sagging into
|
||||
the river valley — and it equally poisons the per-edge climb/descent numbers
|
||||
baked into `region.graph` for EV routing (datasources.md, EV thread).
|
||||
3. **Constraint pass** over crossing pairs and shared nodes:
|
||||
- shared node ⇒ equal z (ramps connect continuously);
|
||||
- `layer(a) > layer(b)` at a geometric crossing ⇒
|
||||
`z_a ≥ z_b + clearance`; default clearance ≈ 5.5 m per layer step
|
||||
(≥ 4.7 m legal clearance + ~0.8 m deck structure), overridden upward by
|
||||
`maxheight` where tagged;
|
||||
- `tunnel` ⇒ z ≤ terrain − cover (default ~2 m below surface at portals,
|
||||
deeper mid-tunnel is fine);
|
||||
- respect `incline=*` where mapped.
|
||||
4. **Smooth + clamp**: grade smoothing along each way chain (moving
|
||||
average or heat-equation style), then clamp per class — motorway ≤ ~6%,
|
||||
links ≤ ~10%, service/other ≤ ~15%. Approach ramps to a lifted deck extend
|
||||
into the adjacent non-bridge ways; ease in/out (cosine) so decks don't pop.
|
||||
5. **Tile-boundary continuity**: solve on a buffered neighborhood (same trick
|
||||
as our existing tile pipeline uses for geometry clipping) so a vertex near
|
||||
the edge gets the same z in both tiles.
|
||||
6. **Bake**: per-vertex **Δz above DEM** (not absolute z), quantized (dm is
|
||||
plenty), on road/rail geometry in our tiles. Δz-above-DEM keeps the field
|
||||
independent of DEM version/resolution mismatches at render time and is 0 for
|
||||
~99% of vertices ⇒ compresses to almost nothing.
|
||||
|
||||
## 5. NL upgrade: measured deck heights instead of solved ones
|
||||
|
||||
Where surveyed data exists, replace the constraint solution:
|
||||
|
||||
- **RWS DTB** (Digitaal Topografisch Bestand, open on PDOK): cm-grade surveyed
|
||||
x/y/z on lines/points/areas covering exactly the RWS-managed motorway network
|
||||
— i.e. precisely where the multi-level interchanges are. Z is in the PDOK
|
||||
atom shapefiles and (since recently) the WFS as well. Join to OSM ways by
|
||||
proximity+bearing, sample z along the DTB lines.
|
||||
- **AHN** (open LiDAR): the **DSM keeps bridge decks, the DTM removes them** —
|
||||
so DSM − DTM over a bridge polygon (`man_made=bridge` from OSM, or BGT
|
||||
"overbruggingsdeel") ≈ deck height directly. The LAZ point clouds classify
|
||||
structures explicitly if we want it from the source.
|
||||
- **Kadaster 3D Basisvoorziening** (open, 3dfier-generated): ready-made 3D
|
||||
terrain + LoD1 bridge decks as meshes — the ingest-meshes-instead-of-solving
|
||||
option, and a validation set for our solver either way.
|
||||
|
||||
Europe-wide the solver result stands; NL gets true-to-survey interchanges.
|
||||
Validation loop: run the solver in NL, diff against DTB/AHN, tune clearance and
|
||||
clamp constants until the residuals are boring, then trust it elsewhere.
|
||||
|
||||
## 6. Renderer integration
|
||||
|
||||
- Tiles carry Δz per road vertex; the renderer adds it on top of the same
|
||||
terrain drape it already uses (see map terrain/3D displacement architecture
|
||||
notes). Decks lift, tunnels sink, everything else stays glued to the drape.
|
||||
- 2D/top-down mode keeps using `layer` draw order exactly as today — Δz only
|
||||
engages in tilted/3D camera modes, so this is purely additive.
|
||||
- Depth: elevated decks get real depth-tested geometry; the existing
|
||||
bridge-casing styling (open item in the carto-quality list) becomes the deck
|
||||
side/edge treatment in 3D.
|
||||
- What sells the effect: a drop shadow / AO blob under decks onto whatever is
|
||||
below, and portal treatment at tunnel mouths (clip or fade the tunnel line
|
||||
under terrain, keep a dimmed dashed hint in nav mode when the route goes
|
||||
through it).
|
||||
- Car-follow camera: route position includes Δz, so the chase camera rides over
|
||||
the flyover instead of clipping through it; on stacked interchanges the
|
||||
route's own level is unambiguous because it comes from the graph edge, not
|
||||
from GPS altitude (which is too noisy to pick a deck).
|
||||
|
||||
## 7. Build order
|
||||
|
||||
- **M0 — fake it visually**: no DEM needed. Δz = `layer` × 5.5 m on
|
||||
bridge/tunnel ways with cosine ease-in/out extending into approach ways.
|
||||
Gets stacked interchanges reading correctly in one step; wrong absolute
|
||||
heights, right relative ones.
|
||||
- **M1 — solve it**: recipe of §4 in the tile build, bake Δz-above-DEM,
|
||||
renderer consumes it. Requires the DEM ingest that hillshade (path B) needs
|
||||
anyway.
|
||||
- **M2 — measure it (NL)**: DTB/AHN override per §5 + solver validation.
|
||||
- **M3 — polish**: deck shadows/AO, tunnel portals, casing-as-deck-edge,
|
||||
`region.graph` climb/descent switched to the same z source.
|
||||
|
||||
## Sources (checked 2026-07-29)
|
||||
|
||||
- OSM wiki: [Altitude](https://wiki.openstreetmap.org/wiki/Altitude),
|
||||
[Key:ele](https://wiki.openstreetmap.org/wiki/Key:ele),
|
||||
[Key:layer](https://wiki.openstreetmap.org/wiki/Key:layer),
|
||||
[Key:maxheight](https://wiki.openstreetmap.org/wiki/Key:maxheight)
|
||||
- OSM2World: [OSM-3D-Terrain Prototype-Fund roadmap (2026-06-03)](https://osm2world.org/blog/2026/06/03/ptf-roadmap-2026-osm-3d-terrain/),
|
||||
[elevation smoothing PR #176](https://github.com/tordanik/OSM2World/pull/176)
|
||||
- Overture: [shape & connectivity](https://docs.overturemaps.org/schema/concepts/by-theme/transportation/shape-connectivity/),
|
||||
[segments & connectors](https://docs.overturemaps.org/guides/transportation/segments-and-connectors/)
|
||||
- NL: [RWS DTB on PDOK](https://www.pdok.nl/introductie/-/article/digitaal-topografisch-bestand-dtb-),
|
||||
[DTB open data (RWS)](https://rijkswaterstaat.nl/zakelijk/open-data/digitaal-topografisch-bestand),
|
||||
[AHN DTM](https://data.overheid.nl/dataset/47567-actueel-hoogtebestand-nederland--ahn--dtm),
|
||||
[AHN DSM (AHN4)](https://data.overheid.nl/dataset/36461-actueel-hoogtebestand-nederland-dsm--ahn4-),
|
||||
[Kadaster 3D Basisvoorziening](https://www.kadaster.nl/zakelijk/producten/geo-informatie/3d-producten/3d-basisvoorziening)
|
||||
- Renderer gap: [MapLibre Terrain3D roadmap](https://maplibre.org/roadmap/maplibre-native/terrain3d/)
|
||||
433
datasources.md
Normal file
433
datasources.md
Normal file
|
|
@ -0,0 +1,433 @@
|
|||
# Open data sources for map overlays
|
||||
|
||||
Survey of open datasources we can overlay on the Europe map, researched and
|
||||
endpoint-verified 2026-07-27 (curl-probed where noted). Netherlands-first, Europe-wide
|
||||
where possible. Companion to `gps.md` (interaction layer) and `map.md` (renderer).
|
||||
|
||||
> **Implementation**: `libs/geodata` (CLI: `geodata`) is the bulk-download +
|
||||
> layer-database builder born from this survey — see `libs/geodata/README.md`
|
||||
> for the per-layer database schemas and which layers are built vs planned.
|
||||
|
||||
License verdicts used below:
|
||||
- **OPEN** — CC0 / public domain / no conditions. Bundle, redistribute, ship.
|
||||
- **ATTR** — CC-BY-style. Fine, needs an attribution screen.
|
||||
- **NC** — non-commercial only. Fine for the hobby build, blocks a commercial app.
|
||||
- **RESTRICTED** — permission required / unusable. Listed to warn.
|
||||
|
||||
## How overlays plug into our stack
|
||||
|
||||
Five integration paths, roughly in order of how much new machinery each needs:
|
||||
|
||||
- **A. Tile-time attribute join** (import pipeline, `tools/map_tiles`): join external
|
||||
attributes onto OSM features while building our own tiles. Zero new renderer work —
|
||||
the style layer just gets new tags to color by.
|
||||
- **B. Raster overlay layer** (new renderer feature): decode raster tiles (PNG/WebP/COG)
|
||||
onto textures, alpha-blend above the base map, optional time dimension for animation
|
||||
(radar frames). One feature, many layers: radar, hillshade, aerial, satellite, model
|
||||
fields. The tile scheduling/fade machinery in `view.rs` generalizes to this.
|
||||
- **C. Live point/vector overlays** (existing `overlay.rs` machinery from the nav work):
|
||||
markers, polylines, colored road segments fed by a background poller. Transit
|
||||
vehicles, planes, ships, chargers, traffic coloring.
|
||||
- **D. Routing-graph enrichment** (`libs/map_nav` + import): per-edge attributes
|
||||
(elevation profile, live travel times, bridge state) that change route costs, not
|
||||
pixels.
|
||||
- **E. 3D import** (box3d/xr track): real building meshes and terrain meshes.
|
||||
|
||||
---
|
||||
|
||||
## 1. Weather
|
||||
|
||||
### OPERA / EUMETNET European rain radar — the headline find
|
||||
The pan-European radar composite became genuinely open via the EU high-value-datasets
|
||||
push. The **EUMETNET Open Radar Data (ORD) API** on MeteoGate serves OPERA composites:
|
||||
max reflectivity at **1 km / 5 min** and surface rain rate + 1-h accumulation at
|
||||
2 km / 15 min, all of Europe, as **ODIM HDF5 and cloud-optimized GeoTIFF**, rolling
|
||||
24 h + archive to 2012. Anonymous tier verified live
|
||||
(`https://api.meteogate.eu/eu-eumetnet-weather-radar/collections` returns 200); free
|
||||
API key raises limits; MQTT push available. **ATTR (CC BY 4.0).**
|
||||
Integration: B — COG → reproject to mercator → colormap → animated raster frames.
|
||||
|
||||
### KNMI Data Platform (NL detail + nowcast)
|
||||
5-min NL reflectivity composite (`radar_reflectivity_composites/2.0`) plus
|
||||
`radar_forecast/1.0` — a **+2 h nowcast in 5-min steps**, i.e. the "rain in the next
|
||||
two hours" animation comes precomputed. HARMONIE-AROME forecast grids (GRIB1) too.
|
||||
REST file API + MQTT push; free key (shared anonymous key exists). **ATTR (CC BY 4.0).**
|
||||
Format: KNMI HDF5, polar-stereographic — needs decode + reprojection (or accept the
|
||||
2 km OPERA rain-rate product for NL too and skip HDF5 entirely at first).
|
||||
|
||||
### Wind fields — the animated particle/arrow layer (verified 2026-07-28)
|
||||
The windy.com / earth.nullschool effect needs a gridded **10 m u/v field** (not
|
||||
speed/direction points): u/v becomes a small RG texture per forecast step, a shader
|
||||
advects fading particles through it, steps crossfade for the time animation — the
|
||||
same technique as Beccario's earth and Mapbox's WebGL wind demo; their
|
||||
grib2json/leaflet-velocity JSON is literally this grid, we just decode GRIB2/NetCDF
|
||||
ourselves. All sources below carry 10 m u/v (+ gusts); curl-probed 2026-07-28:
|
||||
|
||||
- **NOAA GFS via NOMADS** — global 0.25°, 4 runs/day, hourly steps to 120 h. The
|
||||
filter CGI subsets **by variable and bbox server-side**: 10 m U+V over a NL box
|
||||
returned a **939-byte** GRIB2
|
||||
(`filter_gfs_0p25.pl?...&var_UGRD=on&var_VGRD=on&lev_10_m_above_ground=on&subregion=&...`).
|
||||
**OPEN (US public domain — the only no-attribution source).** The bootstrap
|
||||
source for the shader work: bytes per frame, no key, and it's what
|
||||
windy/nullschool themselves run on.
|
||||
- **ECMWF open data (IFS)** — global 0.25°, GRIB2, 4 runs/day, 3-h steps to 144 h
|
||||
(6-h to 240 h), no registration. Each file has an `.index` sidecar with byte
|
||||
offsets per param → one HTTP range request pulls a global 10u/10v field (~870 KB).
|
||||
Better European skill than GFS. `data.ecmwf.int` verified fine; the AWS mirror
|
||||
(`ecmwf-forecasts`, eu-central-1) throttles hard (SlowDown after a handful of
|
||||
rapid hits). **ATTR (CC BY 4.0).**
|
||||
- **KNMI UWC-West HARMONIE — `uwcw-ha-det-nl-s1`** — NL at **2 km**, hourly runs,
|
||||
60 h horizon, one NetCDF per parameter: `wind-speed-components-hagl` (u/v at 10 m
|
||||
+ boundary-layer levels, ~57 MB/run) or speed/direction/gust-only files
|
||||
(~27 MB each). Verified landing hourly. Free registered key; the shared anonymous
|
||||
key is 50 req/min shared and rate-limited repeatedly while probing — register.
|
||||
**ATTR (CC BY 4.0).** The NL-detail layer once the particle pass exists. Its
|
||||
predecessors (`harmonie_arome_cy43_p1` = 850 MB GRIB1 tars/run,
|
||||
`uwcw_extra_lv_ha43_nl_2km`) are deprecated; the Europe-wide 0.05° DINI domain
|
||||
sits in `harmonie_arome_cy43_p3` (GRIB1 tars).
|
||||
- **DWD ICON-D2 / ICON-EU** — D2: 2.2 km covering Germany + all of Benelux, hourly
|
||||
`u_10m`/`v_10m`, **regular-lat-lon variants available** (~1 MB bz2 per step),
|
||||
plain HTTPS directory, no key
|
||||
(`opendata.dwd.de/weather/nwp/icon-d2/grib/<run>/u_10m/`). ICON-EU at 6.5 km
|
||||
likewise. **ATTR (GeoNutzV).** The middle tier between ECMWF and KNMI 2 km.
|
||||
- **Live station wind (observed arrows)**: KNMI
|
||||
**`10-minute-in-situ-meteorological-observations`** — ~156 KB NetCDF every
|
||||
10 min, all NL automatic stations incl. wind poles, verified fresh at probe time.
|
||||
**ATTR.** Path C arrow markers + a "now" calibration for the model field.
|
||||
(Replaces `Actuele10mindataKNMIstations`, which stopped updating 2025-09-29.)
|
||||
- Shortcut worth knowing: **Open-Meteo's AWS open-data bucket** republishes
|
||||
ECMWF/ICON/HARMONIE grids pre-merged in their custom `.om` chunk format —
|
||||
convenient, but the raw GRIB2/NetCDF above is plain enough to skip the extra
|
||||
format dependency.
|
||||
|
||||
Integration: path B's raster/time machinery + one new GPU particle pass (u/v → RG
|
||||
texture per step, advect + fade, interpolate between steps); arrows are the cheap
|
||||
first render of the same field. The identical u/v grid later feeds the EV headwind
|
||||
term (EV thread, point 4).
|
||||
|
||||
### Forecast field overlays (cloud, snow, precip)
|
||||
- **ECMWF open data** — IFS/AIFS at 0.25°, GRIB2, 4 runs/day, no registration,
|
||||
mirrors on AWS S3. **ATTR (CC BY 4.0).** Cloud cover, snow depth, and the other
|
||||
synoptic fields (wind: see above).
|
||||
- **DWD ICON-EU** — 6.5 km Europe grid, GRIB2, plain HTTPS directory, no signup
|
||||
(`opendata.dwd.de`). **ATTR (GeoNutzV).** Sharper than ECMWF over central Europe;
|
||||
ICON-D2 (2.2 km) covers eastern NL fringe.
|
||||
- **Open-Meteo** — JSON point/route forecasts; accepts coordinate lists →
|
||||
**forecast along a route in one call** (EV: wind + temperature per leg). Data is
|
||||
CC-BY; hosted API free for **non-commercial** (~10k calls/day); AGPL self-host
|
||||
option. **ATTR data / NC hosted API.**
|
||||
- **MET Norway api.met.no** — CC-BY point forecasts, no key (mandatory User-Agent);
|
||||
solid backup.
|
||||
|
||||
### Satellite cloud layer
|
||||
**EUMETSAT EUMETView** — WMS/WMTS at `view.eumetsat.int/geoserver`, no registration:
|
||||
Meteosat MTG imagery, full disc every 10 min, rapid-scan Europe 5 min. **ATTR.**
|
||||
Integration: B, trivially (it's already a tile service; cache politely).
|
||||
|
||||
### Flagged unsuitable
|
||||
- **RainViewer** — free tier now personal/educational only, nowcast+satellite removed
|
||||
from the public endpoint, max zoom 7. Prototyping only. **NC.**
|
||||
- **Buienradar** — imagery not georeferenced; apps require written permission.
|
||||
**RESTRICTED** (and redundant: KNMI is its upstream).
|
||||
- **Blitzortung lightning** — network participants only, no commercial use.
|
||||
**RESTRICTED.** No open EU lightning alternative exists.
|
||||
|
||||
---
|
||||
|
||||
## 2. Buildings, elevation, imagery
|
||||
|
||||
### Building age (the NL building-age map) — nearly free for us
|
||||
Dutch OSM buildings originate from the community BAG import and already carry
|
||||
**`ref:bag`** (BAG pand id) and **`start_date`** (= bouwjaar) — the construction year
|
||||
is **already in the Europe PBF on disk**. v1 building-age coloring is therefore pure
|
||||
path A: keep `start_date` on buildings at tile build, add an age→color ramp to the
|
||||
style. For authoritative/fresh data later: **BAG via PDOK** — `bag-light.gpkg`
|
||||
(7.8 GB GeoPackage, monthly, **OPEN — CC0**), exact join on `ref:bag` =
|
||||
`identificatie`. Prior art: Waag's "All 9.8M buildings" map (2013).
|
||||
|
||||
### Building heights + real 3D (NL)
|
||||
**3D BAG** (TU Delft, release 2025.09, ~10.8M buildings): per-building height stats
|
||||
(ground/roof percentiles, ridge) derived from LiDAR — join by the same BAG id at tile
|
||||
build (path A) and extrude in the renderer; plus LoD2.2 roof-shape meshes as
|
||||
**3D Tiles 1.1 / CityJSON / OBJ** per-tile downloads for the real-3D showcase
|
||||
(path E). **ATTR (CC BY 4.0).** OSM's own `building:levels` covers only ~6% globally
|
||||
and worse in NL — 3D BAG is the answer here.
|
||||
Europe-wide fallback: **EUBUCCO v0.2** (322M buildings, height ~43%, age ~16%,
|
||||
**ODbL share-alike**) or JRC **DBSM R2025** (harmonized EU stock, ODbL).
|
||||
|
||||
### Elevation (the EV-routing input)
|
||||
- **AHN** (NL LiDAR): 0.5 m DTM/DSM GeoTIFF/COG via PDOK atom/WCS, AHN4 complete,
|
||||
AHN5 rolling in. **OPEN.** Best-in-class; also validates 3D BAG heights.
|
||||
- **Copernicus DEM GLO-30** (30 m, Europe/global): anonymous COGs on AWS
|
||||
(`s3://copernicus-dem-30m/`). **ATTR** (credit notice). The pan-European base under
|
||||
AHN. (EU-DEM is discontinued; the 10 m EEA product is access-restricted — skip.)
|
||||
- **Ready-made terrain tiles**: **Mapterhorn** — terrarium-encoded terrain tiles for
|
||||
Europe built from Copernicus + national LiDAR, distributed as a single **PMTiles**
|
||||
file (`download.mapterhorn.com/planet.pmtiles`), open data, offline-friendly —
|
||||
the fastest route to hillshade + 3D terrain. AWS/Mapzen terrain tiles still exist
|
||||
(**ATTR**) but carry stale EU-DEM-era data for Europe.
|
||||
|
||||
Uses: hillshade raster overlay (B), 3D terrain meshes (E), and **per-edge climb/descent
|
||||
baked into `region.graph`** (D) — see the EV section.
|
||||
|
||||
### Road elevation / grade separation (overpasses in the 3D car-follow view)
|
||||
Researched 2026-07-29: **OSM will not store road z, and there is no plan to.** The
|
||||
data model is 2D lat/lon; `ele=*` is for summits/POIs ("OSM is not an elevation
|
||||
database") and no active proposal changes that. Overture's transportation schema
|
||||
keeps the same design — `level` is relative stacking only, explicitly "not a
|
||||
precise indication of elevation". What road geometry does carry (all already in
|
||||
our PBF): `layer=*` (relative −5..5), `bridge`/`tunnel`, `incline=*`, `maxheight`
|
||||
(clearance *under*), occasionally `height` on bridge ways.
|
||||
|
||||
The ecosystem plan-of-record is **derive z at build time: DEM + OSM semantics +
|
||||
constraint solving**. OSM2World is the reference implementation (init every road
|
||||
vertex at terrain height → equal-z at shared nodes → minimum vertical clearance at
|
||||
bridge-over-road crossings → tunnels below terrain → grade smoothing + per-class
|
||||
incline clamps), and its Prototype-Fund **"OSM-3D-Terrain"** project (roadmap
|
||||
June 2026) is productizing exactly this into 3D tiles with LOD and tile-boundary
|
||||
continuity — worth watching. No open vector-map renderer draws true
|
||||
grade-separated interchanges yet (MapLibre's terrain drape squashes bridges flat),
|
||||
so doing it properly is a differentiator.
|
||||
|
||||
Our recipe (tile/nav build):
|
||||
1. Init road vertices at DEM height (Mapterhorn/AHN, above).
|
||||
2. Constraint pass; **never sample the DEM mid-bridge/-tunnel** — interpolate
|
||||
between abutments. (The classic artifact — elevation profile dipping into the
|
||||
valley under the viaduct — would also poison the EV climb numbers in the EV
|
||||
thread below.)
|
||||
3. Bake per-vertex Δz-above-DEM into our tiles; the renderer lifts decks and sinks
|
||||
tunnels from the same terrain drape.
|
||||
4. NL upgrade — measured deck heights instead of solved ones:
|
||||
- **RWS DTB** (PDOK, open): cm-grade surveyed x/y/z lines/points/areas covering
|
||||
exactly the RWS motorway network incl. interchanges; z now in the WFS too.
|
||||
- **AHN**: DSM keeps bridge decks, DTM removes them → DSM−DTM over bridge
|
||||
polygons ≈ deck height; the LAZ point clouds classify structures directly.
|
||||
- **Kadaster 3D Basisvoorziening** (open, 3dfier-generated): ready-made 3D
|
||||
terrain + LoD1 bridge decks, if ingesting meshes beats solving.
|
||||
|
||||
### Aerial & satellite imagery
|
||||
- **PDOK Luchtfoto** (NL): 25 cm nationwide 2×/year + 8 cm HR flights, WMTS +
|
||||
**bulk GeoTIFF download** via the Beeldmateriaal dataroom. **ATTR (CC BY 4.0)** —
|
||||
offline self-tiling is explicitly legal. NL "satellite view" solved.
|
||||
- **Sentinel-2**: EOX cloudless mosaics are **NC** for 2018–2024 (only the 2016 layer
|
||||
is CC-BY) — trap. The open path is compositing our own cloudless mosaic from raw
|
||||
Sentinel-2 L2A COGs on the Copernicus Data Space (**OPEN/ATTR**, one-off batch job).
|
||||
- Germany: NRW 10 cm orthophotos are license-zero (no attribution!); Belgium via
|
||||
geo.be WMTS (ATTR). Per-country patchwork.
|
||||
- **GHSL** (JRC): built-up surface 10 m, building height 100 m, population rasters.
|
||||
**ATTR.** Coarse Europe-wide fallbacks and a population-density shading layer.
|
||||
|
||||
---
|
||||
|
||||
## 3. Mobility & live transport
|
||||
|
||||
### OVapi / NDOV — all Dutch public transport, live, CC0
|
||||
The backbone NL feed, verified live (sub-minute freshness): static GTFS (~223 MB,
|
||||
daily) + GTFS-RT protobufs — `vehiclePositions.pb` (~218 KB!), `tripUpdates.pb`,
|
||||
`trainUpdates.pb`, `alerts.pb` at `gtfs.ovapi.nl` / `gtfs.openov.nl`. **No key, no
|
||||
registration, OPEN (CC0).** Every bus/tram/metro/train in the country as a live layer
|
||||
for one small protobuf poll every ~30 s (path C: colored vehicle markers; tap for
|
||||
line/delay). Volunteer-run — identify with a User-Agent, poll politely; NDOV loket
|
||||
(free registration, ZeroMQ push) is the harder-core fallback. The NS API is redundant
|
||||
for positions (proprietary terms; OVapi carries all rail already).
|
||||
|
||||
### NDW — national road traffic, minute cadence, no key
|
||||
`https://opendata.ndw.nu/` — plain HTTPS directory of gzipped DATEX II XML refreshed
|
||||
every minute, **OPEN**, verified live: point speeds/intensities from 24k+ sites
|
||||
(`trafficspeed.xml.gz`), segment travel times, situation feed (jams, closures,
|
||||
wrong-way drivers, **live bridge openings** — six bridges stood open at check time),
|
||||
matrix-sign states, roadworks/events planning, temporary speed limits, emission
|
||||
zones, truck parking, and a **national EV-charger dataset in OCPI JSON**. Effort sits
|
||||
in DATEX II parsing + matching their location references onto our road segments; the
|
||||
payoff is double: traffic coloring overlay (C) *and* live routing weights + bridge
|
||||
penalties (D). Historical archive via Dexter.
|
||||
|
||||
### Europe-wide transit
|
||||
No single EU GTFS-RT exists. Practical ladder: NL = OVapi; DE = **gtfs.de** mirrors
|
||||
(CC-BY, no login, incl. a free Germany-wide GTFS-RT feed, verified); rest via the
|
||||
**Transitous** project (1,800+ cleaned feeds, re-published openly; hosted MOTIS API is
|
||||
non-commercial fair-use — self-host MOTIS with their feed list if it pinches) and the
|
||||
`european-transport-feeds` / Mobility Database catalogs.
|
||||
|
||||
### Live vehicles for fun (path C, cheap to add)
|
||||
- **OpenSky** — live aircraft state vectors, bbox REST query, OAuth2; registered free
|
||||
tier ≈ one NL-bbox poll/22 s. **NC free tier.** Feeding an ADS-B receiver doubles
|
||||
the quota.
|
||||
- **aisstream.io** — free WebSocket AIS ship positions (good near NL coast/ports);
|
||||
thin license text — fine for hobby, clarify before commercial. Official NL inland
|
||||
AIS is deliberately closed — don't plan on it.
|
||||
- **OV-fiets GBFS** — `gbfs.openov.nl/ovfiets/gbfs.json`, live rental-bike
|
||||
availability per station, 60 s TTL, **OPEN**. Perfect companion to transit routing.
|
||||
- **Autobahn API** (DE) — no-key REST JSON: roadworks, closures, webcams, chargers
|
||||
per motorway. **OPEN.** Easiest German traffic entry point.
|
||||
|
||||
### EV charging
|
||||
**NDW's OCPI feed** for NL (open, national, refreshed ~daily) + **OpenChargeMap** for
|
||||
the rest of Europe (CC-BY, free key, rate-limited). Neither gives live occupancy for
|
||||
free. OSM's `amenity=charging_station` lags reality in NL — use it only as fallback.
|
||||
|
||||
### Cycling
|
||||
NL junction networks (knooppunten) are fully and actively mapped **in OSM** — already
|
||||
in our data; render as an overlay style. The official Fietsplatform database is
|
||||
**RESTRICTED** (purpose-limited license) — avoid.
|
||||
|
||||
### Water
|
||||
**Rijkswaterstaat WaterWebservices** (new endpoint
|
||||
`ddapi20-waterwebservices.rijkswaterstaat.nl` — old one is being switched off in
|
||||
2026): water levels every ~10 min, JSON, no registration, **OPEN**. Plus
|
||||
vaarweginformatie.nl fairway notices for lock/bridge operating times.
|
||||
|
||||
---
|
||||
|
||||
## 4. Environment & statistics
|
||||
|
||||
### Air quality
|
||||
- **Luchtmeetnet** (RIVM, official NL): ~100 stations, hourly NO2/PM/O3 + LKI index +
|
||||
interpolated concentrations at arbitrary lat/lon. JSON API
|
||||
(`api.luchtmeetnet.nl/open_api`), no auth, 100 req/5 min; bulk CSV at
|
||||
`data.rivm.nl/data/luchtmeetnet/`. **ATTR (CC BY 4.0).** Primary NL live layer.
|
||||
- **Sensor.Community**: crowdsourced PM, densest network in DE/NL; bbox-filterable
|
||||
JSON (`data.sensor.community`), daily CSV archive. **OPEN (ODbL-family, attr).**
|
||||
Noisy per-sensor — hex-bin into a heatmap. RIVM **Samen Meten** (SensorThings API)
|
||||
offers RIVM-calibrated versions of the same citizen sensors.
|
||||
- **EEA** (Europe): the old file dumps are discontinued; current Download Service
|
||||
serves Parquet timeseries + modelled rasters (**ATTR**). Europe-wide
|
||||
historical/modelled; use Luchtmeetnet for NL live.
|
||||
|
||||
### Noise
|
||||
**RIVM "Geluid in Nederland"** is the winner: whole-NL 10 m Lden/Lnight rasters per
|
||||
source (highways, municipal roads, rail, aviation, industry, wind turbines) as
|
||||
**CC0 GeoTIFF downloads** (`data.rivm.nl/data/alo/...`) + WMS. Bake our own raster
|
||||
tiles (path B). Official END round-4 contour *polygons* (also CC0) on PDOK/haleconnect
|
||||
if we want crisp vectors; EEA publishes the Europe-wide equivalents (**ATTR**, country
|
||||
completeness varies).
|
||||
|
||||
### Land cover
|
||||
**CORINE CLC2018** (100 m, Europe) — still the latest until CLC2024 lands mid-2026;
|
||||
GeoTIFF via land.copernicus.eu (EU-Login) or a no-login EEA WMS mirror. **ATTR.**
|
||||
**CLC+ Backbone 10 m** (2023 edition) is the modern high-res version — gorgeous
|
||||
green/urban/water backdrop at z13+. Both EPSG:3035 → one ingest-time reprojection.
|
||||
|
||||
### Protected nature
|
||||
**PDOK Natura 2000**: direct 10.7 MB GeoPackage, **CC0**, verified — ship it offline
|
||||
as vector-tile polygons (path A). Also CC0 on PDOK: Wetlands/Ramsar (3.8 MB),
|
||||
Nationale Parken (3.25 MB), Natuurnetwerk Nederland. Europe: EEA Natura 2000 bundle
|
||||
(2.3 GB SHP/GPKG, **ATTR**). **WDPA/Protected Planet is RESTRICTED** (redistribution
|
||||
ban incl. offline maps) — not needed, the open sets cover Europe.
|
||||
|
||||
### Flood risk (very Dutch, very good)
|
||||
- **LIWO** (Rijkswaterstaat): public GeoServer, no login — max water depth per
|
||||
probability class, with **WCS** access to pull actual depth grids for offline
|
||||
baking. De-facto open, attribute RWS.
|
||||
- **Klimaateffectatlas WMS**: the same national data with a clean **CC BY 4.0**
|
||||
citation surface; three probability classes as UI granularity.
|
||||
- **PDOK ROR** flood risk/hazard zone polygons: **CC0**, tiny — the cheap
|
||||
"am I in a flood zone?" layer.
|
||||
- **JRC Europe river flood hazard** v3.1.1: depth GeoTIFFs per return period,
|
||||
~90 m, WGS84, anonymous FTP-style download (RP100 = 323 MB), **ATTR**. The
|
||||
Europe-wide layer.
|
||||
|
||||
### Demographics
|
||||
- **CBS Wijk- en Buurtkaart** (2025 on PDOK): neighborhood polygons with population,
|
||||
age, income, density, housing — the canonical NL choropleth. GPKG ~261 MB.
|
||||
**ATTR (cite CBS + Kadaster).**
|
||||
- **CBS 100 m / 500 m grids** (Vierkantstatistieken, direct zips from
|
||||
`download.cbs.nl/vierkant/`): per-cell population, age/sex, housing, WOZ value,
|
||||
energy consumption — **the best 3D-extrusion layer we could ship** (100 m at high
|
||||
zoom, 500 m as LOD). **ATTR.** Cells under 5 residents are suppressed.
|
||||
- **Eurostat GISCO Census 2021 grid V3** (May 2026): 1 km grid, 30 countries,
|
||||
13 variables, GeoParquet/GPKG/GeoTIFF. **ATTR.** Pairs with the CBS grid inside NL.
|
||||
- **CBS StatLine OData**: join any statistic onto wijk/buurt codes at ingest, no key.
|
||||
- **GHSL** population/built-up rasters (see §2) as the global fallback.
|
||||
|
||||
### Historic maps
|
||||
No official open Topotijdreis WMTS exists. The clean route: **RCE Bonnebladen ~1900**
|
||||
via RCE's geo services (**ATTR**, pre-1930 imagery PD by age) — a "year slider"
|
||||
overlay anchored on ~1900 vs today. The per-year Esri NL "tijdreis" tile services
|
||||
work but are legally gray (unofficial). Pan-European: Arcanum is commercial, David
|
||||
Rumsey is **NC** — skip both.
|
||||
|
||||
### Wikipedia / Wikidata ("what's around me")
|
||||
Wikipedia geosearch API (no key, radius ≤10 km, descriptive User-Agent), Wikidata
|
||||
SPARQL for bulk coordinates (**coordinates CC0**), Commons geosearch for geotagged
|
||||
photos. Best value-per-effort in this whole document: article-card popups on map
|
||||
points (path C), cacheable offline. NL Wikipedia + Rijksmonumenten together make a
|
||||
genuinely good heritage layer.
|
||||
|
||||
### Fun extras (all verified, all NL unless noted)
|
||||
- **Rijksmonumenten** (~63k national monuments): RCE WFS/WMS, nightly refresh,
|
||||
**ATTR**.
|
||||
- **Kadastrale kaart (BRK)**: PDOK serves **native MVT vector tiles** — drop-in
|
||||
cadastral parcel lines at high zoom, **ATTR**. (The BAG OGC API also serves native
|
||||
MVT with bouwjaar — an alternative to our own join for quick experiments.)
|
||||
- **Groningen earthquakes**: KNMI FDSN event API, **ATTR** — bubble map.
|
||||
- **Trees**: Amsterdam serves ~300k trees with species/year as **native MVT**
|
||||
(`api.data.amsterdam.nl/v1/mvt/bomen`); Rotterdam etc. on data.overheid.nl.
|
||||
- **Solar potential per roof**: Zonatlas-derived national dataset, Public Domain
|
||||
Mark, but dated — verify before building on it.
|
||||
- **Energy labels** (EP-Online/RVO): monthly dump, free key, joins on BAG ids —
|
||||
**no open license: usable, don't redistribute.** A–G choropleth per building.
|
||||
- **AEDs, windmills, etc.**: no open national registers — OSM/Overpass has them.
|
||||
|
||||
---
|
||||
|
||||
## The EV thread (elevation + chargers + weather, combined)
|
||||
|
||||
The pieces above compose into proper EV routing (extends `gps.md` M3):
|
||||
|
||||
1. **Energy-aware graph**: at `nav-build` time, sample AHN (NL) / GLO-30 (Europe)
|
||||
along every edge → per-edge climb/descent meters packed next to length/speed in
|
||||
`region.graph`. Cost function becomes physics: rolling + aero (speed²) + mass ×
|
||||
climb − regen × descent. Flat NL barely notices; the first Ardennen/Alpen trip does.
|
||||
2. **Range visualization**: energy-cost Dijkstra from current position + state of
|
||||
charge → **reachable-area polygon** shaded on the map ("can I make it without
|
||||
charging?"), recomputed as you drive.
|
||||
3. **Charger-aware planning**: NDW OCPI + OCM chargers as graph POIs; multi-stop
|
||||
route planning picks charging stops by detour cost + connector/power filtering.
|
||||
4. **Weather correction**: Open-Meteo along-route wind + temperature → headwind term
|
||||
in the aero cost and a cold-battery efficiency factor. This is what makes highway
|
||||
range predictions honest.
|
||||
|
||||
## Licensing traps (one-screen summary)
|
||||
|
||||
| Source | Trap |
|
||||
|---|---|
|
||||
| RainViewer | personal/educational only now; free tier gutted |
|
||||
| Buienradar | apps need written permission — use KNMI (its upstream) |
|
||||
| Blitzortung | participants only, no commercial |
|
||||
| EOX Sentinel-2 cloudless | 2018+ mosaics are CC-BY-**NC**; only 2016 is CC-BY |
|
||||
| Copernicus EEA-10 DEM | access-restricted — use GLO-30 |
|
||||
| EUBUCCO / DBSM | ODbL share-alike — mind derived-database obligations |
|
||||
| Fietsplatform routes | purpose-limited license — OSM has the networks anyway |
|
||||
| NS API | proprietary terms and redundant — OVapi is CC0 |
|
||||
| Transitous hosted API | non-commercial fair-use — self-host MOTIS instead |
|
||||
| OpenSky / aisstream / Open-Meteo hosted | free tiers are non-commercial |
|
||||
| WDPA / Protected Planet | redistribution ban (incl. offline maps) — use Natura 2000/CDDA |
|
||||
| David Rumsey / Arcanum historic maps | NC / commercial — use RCE Bonnebladen |
|
||||
| EP-Online energy labels | no open license — use, don't redistribute |
|
||||
| Esri NL "tijdreis" historic tiles | no stated terms — legally gray, unofficial |
|
||||
| Everything NL-official (BAG, AHN, luchtfoto, NDW, OVapi, RWS, RIVM noise, PDOK nature/flood) | no traps — CC0/CC-BY, bundle away |
|
||||
|
||||
## Suggested build order
|
||||
|
||||
1. **Building age (path A)** — `start_date` is already in our PBF; a tag to keep +
|
||||
a color ramp. One day of work for the most beautiful quick win; the Waag map from
|
||||
OSM data we already have.
|
||||
2. **Raster overlay layer in the renderer (path B)** — the enabling feature. First
|
||||
consumer: hillshade from Mapterhorn PMTiles (static, offline, no cadence
|
||||
pressure). Second: rain radar animation (OPERA COG → colormapped frames + the
|
||||
KNMI 2 h nowcast for NL) — the "amazing" one. Third: the wind particle layer
|
||||
(§1 wind fields) — reuses the same time-dimension machinery plus one GPU
|
||||
particle pass; bootstrap on GFS bbox subsets (bytes per frame, no key).
|
||||
3. **Live transit + traffic (path C)** — OVapi vehicle positions as moving markers;
|
||||
NDW travel-time coloring on trunk roads. Both are single-file pollers feeding the
|
||||
existing overlay machinery.
|
||||
4. **Elevation into the graph (path D)** — EV/bike energy-aware routing + range
|
||||
polygon; quiet infrastructure, big payoff once routes leave the polder.
|
||||
5. **3D BAG + AHN terrain (path E)** — the showcase: LoD2.2 roofs + real terrain in
|
||||
the xr/3D track.
|
||||
|
||||
Once paths A–C exist, §4 provides cheap follow-on layers that reuse them: Natura 2000
|
||||
polygons (10.7 MB CC0 GPKG → path A), RIVM noise + JRC flood rasters (CC0/CC-BY
|
||||
GeoTIFFs → path B), Wikipedia/Rijksmonumenten points (path C), and the CBS 100 m grid
|
||||
as the first 3D-extrusion statistics layer (path E).
|
||||
|
|
@ -14,9 +14,6 @@ pub struct Cx2d<'a, 'b> {
|
|||
pub(crate) overlay_id: Option<DrawListId>,
|
||||
pub(crate) overlay_pass_id: Option<DrawPassId>,
|
||||
pub(crate) overlay_draw_depth: usize,
|
||||
/// Increments once per overlay sub-list begun this frame; stamped onto the
|
||||
/// sub-list so the Overlay can composite in draw order.
|
||||
pub(crate) overlay_seq: u64,
|
||||
|
||||
//pub (crate) overlay_sweep_lock: Option<Rc<RefCell<Area>>>,
|
||||
pub(crate) turtles: Vec<Turtle>,
|
||||
|
|
@ -49,7 +46,6 @@ impl<'a, 'b> Cx2d<'a, 'b> {
|
|||
overlay_id: None,
|
||||
overlay_pass_id: None,
|
||||
overlay_draw_depth: 0,
|
||||
overlay_seq: 0,
|
||||
cx,
|
||||
turtle_clips: Vec::with_capacity(1024),
|
||||
finished_rows: Vec::with_capacity(1024),
|
||||
|
|
|
|||
|
|
@ -248,14 +248,6 @@ impl DrawList2d {
|
|||
cx.draw_lists[overlay_id].store_sub_list(redraw_id, self.draw_list.id());
|
||||
}
|
||||
|
||||
// Stamp the draw ORDER. `store_sub_list` above only decides which slot
|
||||
// this list occupies (first free, kept forever) — it says nothing about
|
||||
// paint order, which is what a caller drawing one glass surface over
|
||||
// another actually means. `Overlay::end` sorts by this.
|
||||
cx.overlay_seq += 1;
|
||||
let seq = cx.overlay_seq;
|
||||
cx.draw_lists[self.draw_list.id()].overlay_order = seq;
|
||||
|
||||
if !self.overlay_active {
|
||||
self.overlay_active = true;
|
||||
cx.overlay_draw_depth += 1;
|
||||
|
|
|
|||
|
|
@ -51,44 +51,6 @@ pub struct VectorVertex {
|
|||
pub zbias: f32,
|
||||
}
|
||||
|
||||
/// Packed VectorVertex: 12 f32 slots carrying the 19 logical fields —
|
||||
/// f16 pairs / unorm8x4 bitcast into single slots, unpacked in the vertex
|
||||
/// shader (unpack2f16/unpack4u8). Halves vertex fetch bandwidth; the
|
||||
/// precision-critical slots (positions, stroke_mult sentinels, param4
|
||||
/// icon-floor composite, param5 depth ladder, zbias 1e-6 steps) stay f32.
|
||||
#[derive(Clone, Script, ScriptHook)]
|
||||
pub struct VectorVertexPacked {
|
||||
#[live]
|
||||
pub x: f32,
|
||||
#[live]
|
||||
pub y: f32,
|
||||
/// f16(u) | f16(v)
|
||||
#[live]
|
||||
pub uv: f32,
|
||||
/// unorm8 r|g|b|a
|
||||
#[live]
|
||||
pub color: f32,
|
||||
#[live]
|
||||
pub stroke_mult: f32,
|
||||
#[live]
|
||||
pub stroke_dist: f32,
|
||||
/// f16(param0) | f16(shape_id)
|
||||
#[live]
|
||||
pub p0s: f32,
|
||||
/// f16(param1) | f16(param2)
|
||||
#[live]
|
||||
pub p12: f32,
|
||||
/// f16(param3) | f16(clip_radius, clamped)
|
||||
#[live]
|
||||
pub p3c: f32,
|
||||
#[live]
|
||||
pub param4: f32,
|
||||
#[live]
|
||||
pub param5: f32,
|
||||
#[live]
|
||||
pub zbias: f32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Script, ScriptHook)]
|
||||
pub struct PbrVertex {
|
||||
#[live]
|
||||
|
|
@ -101,37 +63,6 @@ pub struct PbrVertex {
|
|||
pub tangent: Vec4f, // tangent xyz + handedness
|
||||
}
|
||||
|
||||
/// Packed mesh vertex: 6 f32 slots instead of PbrVertex's 16, for streams
|
||||
/// where fetch bandwidth matters more than the last bit of precision —
|
||||
/// CPU-skinned characters (re-uploaded every frame), terrain, shadow meshes.
|
||||
///
|
||||
/// Same idea as [`VectorVertexPacked`]: position stays f32 because it is
|
||||
/// precision-critical, everything else is an f16 pair or a unorm8 quad
|
||||
/// bitcast into one slot and unpacked in the vertex shader
|
||||
/// (`unpack2f16` / `unpack4u8`). Normals are octahedral-encoded, which is
|
||||
/// what lets a 3-component unit vector fit in two f16 lanes.
|
||||
#[derive(Clone, Script, ScriptHook)]
|
||||
pub struct GameMeshVertex {
|
||||
// Flat f32 fields, not a Vec3f: std140 pads a vec3 to 16 bytes, which
|
||||
// would not match the Rust repr(C) size. VectorVertexPacked does the
|
||||
// same for the same reason.
|
||||
#[live]
|
||||
pub px: f32,
|
||||
#[live]
|
||||
pub py: f32,
|
||||
#[live]
|
||||
pub pz: f32,
|
||||
/// f16(oct.x) | f16(oct.y) — octahedral unit normal.
|
||||
#[live]
|
||||
pub nrm: f32,
|
||||
/// f16(u) | f16(v)
|
||||
#[live]
|
||||
pub uv: f32,
|
||||
/// unorm8 r|g|b|a
|
||||
#[live]
|
||||
pub color: f32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Script, ScriptHook)]
|
||||
pub struct CubeVertex {
|
||||
#[live]
|
||||
|
|
@ -188,17 +119,10 @@ pub fn script_mod(vm: &mut ScriptVm) -> ScriptValue {
|
|||
set_script_value_to_pod!(vm, geom.VectorVertex);
|
||||
let vgen = shared(vm, id!(VectorGeom), GeometryGen::from_triangle_2d);
|
||||
set_script_value!(vm, geom.VectorGeom = vgen);
|
||||
set_script_value_to_pod!(vm, geom.VectorVertexPacked);
|
||||
let vpgen = shared(vm, id!(VectorGeomPacked), GeometryGen::from_triangle_2d_packed);
|
||||
set_script_value!(vm, geom.VectorGeomPacked = vpgen);
|
||||
// PBR geometry: vertex type + placeholder geom (overridden at draw time)
|
||||
set_script_value_to_pod!(vm, geom.PbrVertex);
|
||||
let pgen = shared(vm, id!(PbrGeom), GeometryGen::from_triangle_pbr);
|
||||
set_script_value!(vm, geom.PbrGeom = pgen);
|
||||
// Packed game mesh geometry: 6-slot vertex for bandwidth-bound streams.
|
||||
set_script_value_to_pod!(vm, geom.GameMeshVertex);
|
||||
let gmgen = shared(vm, id!(GameMeshGeom), GeometryGen::from_triangle_game_mesh);
|
||||
set_script_value!(vm, geom.GameMeshGeom = gmgen);
|
||||
// Cube geometry: unit cube in the old geom_pos/geom_normal/geom_uv layout.
|
||||
set_script_value_to_pod!(vm, geom.CubeVertex);
|
||||
let cgen = shared(vm, id!(CubeGeom), || {
|
||||
|
|
@ -241,22 +165,6 @@ impl GeometryGen {
|
|||
}
|
||||
|
||||
/// Placeholder single-triangle geometry for vector drawing (overridden at draw time)
|
||||
pub fn from_triangle_2d_packed() -> GeometryGen {
|
||||
let mut g = Self::default();
|
||||
for _ in 0..3 {
|
||||
g.vertices.extend_from_slice(&crate::vector::pack_vector_record(&[
|
||||
0.0, 0.0, 0.5, 1.0,
|
||||
1.0, 1.0, 1.0, 1.0,
|
||||
1e6, 0.0, 0.0,
|
||||
0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
]));
|
||||
}
|
||||
g.indices.extend_from_slice(&[0, 1, 2]);
|
||||
g
|
||||
}
|
||||
|
||||
pub fn from_triangle_2d() -> GeometryGen {
|
||||
let mut g = Self::default();
|
||||
// 3 vertices with full VectorVertex stride (23 floats each)
|
||||
|
|
@ -297,18 +205,6 @@ impl GeometryGen {
|
|||
g
|
||||
}
|
||||
|
||||
/// Placeholder single triangle in the packed GameMeshVertex stride.
|
||||
pub fn from_triangle_game_mesh() -> GeometryGen {
|
||||
let mut g = Self::default();
|
||||
for _ in 0..3 {
|
||||
// pos, nrm(oct 0,0 = +y), uv, color(white)
|
||||
g.vertices
|
||||
.extend_from_slice(&[0.0, 0.0, 0.0, 0.0, 0.0, f32::from_bits(u32::MAX)]);
|
||||
}
|
||||
g.indices.extend_from_slice(&[0, 1, 2]);
|
||||
g
|
||||
}
|
||||
|
||||
/// Placeholder single-triangle geometry for minimal faceted-vertex drawing.
|
||||
pub fn from_triangle_ico() -> GeometryGen {
|
||||
let mut g = Self::default();
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@ pub use crate::{
|
|||
nav::{NavItem, NavOrder, NavRole, NavScrollIndex, NavStop},
|
||||
overlay::Overlay,
|
||||
scene_3d::{SceneDrawCallAnchor, SceneScope3D, SceneState3D},
|
||||
vector::{pack_pair_f16, pack_unorm8x4},
|
||||
scene_sun::{
|
||||
SceneSun, ShinyConfig, MAT_CANOPY, MAT_GREEN, MAT_NONE, MAT_ROOF, MAT_ROUTE_GLOW,
|
||||
MAT_SHADOW, MAT_WALL, MAT_WATER,
|
||||
|
|
|
|||
|
|
@ -31,7 +31,6 @@ impl Overlay {
|
|||
// mark our overlay_id on cx
|
||||
cx.overlay_id = Some(self.draw_list.id());
|
||||
cx.overlay_pass_id = None;
|
||||
cx.overlay_seq = 0;
|
||||
// cx.overlay_sweep_lock = Some(self.sweep_lock.clone());
|
||||
}
|
||||
|
||||
|
|
@ -39,7 +38,6 @@ impl Overlay {
|
|||
// mark our overlay_id on cx
|
||||
cx.overlay_id = Some(self.draw_list.id());
|
||||
cx.overlay_pass_id = Some(pass_id);
|
||||
cx.overlay_seq = 0;
|
||||
// cx.overlay_sweep_lock = Some(self.sweep_lock.clone());
|
||||
}
|
||||
|
||||
|
|
@ -82,32 +80,5 @@ impl Overlay {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Composite in DRAW order, not slot order.
|
||||
//
|
||||
// A glass surface keeps whichever overlay slot it first claimed
|
||||
// (`store_sub_list` takes the first free one and never moves it), so
|
||||
// slot order is really creation order — a home widget rebuilt after a
|
||||
// layout change, or a panel opened later, lands wherever there happens
|
||||
// to be a hole. That is why glass appeared on top of things drawn after
|
||||
// it. Each sub-list was stamped with its draw position in
|
||||
// `begin_overlay_inner`; ordering by that stamp makes glass obey the
|
||||
// same rule as everything else: later drawn, later painted.
|
||||
let list_id = self.draw_list.id();
|
||||
let len = cx.draw_lists[list_id].draw_items.len();
|
||||
let mut keys: Vec<u64> = Vec::with_capacity(len);
|
||||
for i in 0..len {
|
||||
let sub = cx.draw_lists[list_id].draw_items[i].sub_list();
|
||||
let order = sub
|
||||
.and_then(|sub_id| cx.draw_lists.checked_index(sub_id))
|
||||
.map(|sub_list| sub_list.overlay_order)
|
||||
.unwrap_or(0);
|
||||
keys.push(order);
|
||||
}
|
||||
let mut order: Vec<usize> = (0..len).collect();
|
||||
// Stable, so entries that didn't draw this frame keep their relative
|
||||
// positions instead of shuffling.
|
||||
order.sort_by_key(|&i| keys[i]);
|
||||
cx.draw_lists[list_id].draw_item_reorder = Some(order);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,12 +27,6 @@ script_mod! {
|
|||
cam_c: uniform(0.0)
|
||||
cam_d: uniform(1.0)
|
||||
cam_pivot: uniform(vec2(0.0, 0.0))
|
||||
// Pan/zoom delta applied BEFORE the camera matrix: glyphs are
|
||||
// emitted in CACHED placement space and ride these uniforms every
|
||||
// frame, exactly like tile geometry rides map_offset — no CPU
|
||||
// re-transform between frames, so labels can never trail the map.
|
||||
cam_scale: uniform(1.0)
|
||||
cam_shift: uniform(vec2(0.0, 0.0))
|
||||
// self.upright (instance from the Rust struct): 1.0 = screen-upright
|
||||
// label (place names, pin/brand text) — its ANCHOR tracks the camera
|
||||
// delta but its orientation must not; the re-place keeps such labels
|
||||
|
|
@ -49,22 +43,14 @@ script_mod! {
|
|||
scaled.x * sn + scaled.y * cs
|
||||
) + origin
|
||||
if self.upright > 0.5 {
|
||||
let anchor2 = origin * self.cam_scale + self.cam_shift
|
||||
let anchor_rel = anchor2 + vec2(0.0, self.lift) - self.cam_pivot
|
||||
let anchor_rel = origin + vec2(0.0, self.lift) - self.cam_pivot
|
||||
let cam_anchor = vec2(
|
||||
anchor_rel.x * self.cam_a + anchor_rel.y * self.cam_b,
|
||||
anchor_rel.x * self.cam_c + anchor_rel.y * self.cam_d
|
||||
) + self.cam_pivot - vec2(0.0, self.lift)
|
||||
var offs = rotated - origin
|
||||
if self.billboard < 0.5 {
|
||||
// Street-cap/city names scale with the gesture; text
|
||||
// inside zoom-constant pins keeps its pixel size.
|
||||
offs = offs * self.cam_scale
|
||||
}
|
||||
rotated = offs + cam_anchor
|
||||
rotated = rotated - origin + cam_anchor
|
||||
} else {
|
||||
let q = rotated * self.cam_scale + self.cam_shift
|
||||
let cam_rel = q + vec2(0.0, self.lift) - self.cam_pivot
|
||||
let cam_rel = rotated + vec2(0.0, self.lift) - self.cam_pivot
|
||||
rotated = vec2(
|
||||
cam_rel.x * self.cam_a + cam_rel.y * self.cam_b,
|
||||
cam_rel.x * self.cam_c + cam_rel.y * self.cam_d
|
||||
|
|
@ -134,10 +120,6 @@ pub struct DrawRotatedText {
|
|||
/// anchor and re-applies the lift, so lifted labels track rotation.
|
||||
#[live(0.0)]
|
||||
pub lift: f32,
|
||||
/// 1.0 = pin-interior text: anchor tracks the pan/zoom delta but glyph
|
||||
/// offsets and size stay constant screen px (like the pin mesh).
|
||||
#[live(0.0)]
|
||||
pub billboard: f32,
|
||||
}
|
||||
|
||||
impl DrawRotatedText {
|
||||
|
|
@ -152,14 +134,6 @@ impl DrawRotatedText {
|
|||
self.draw_vars
|
||||
.set_uniform(cx, live_id!(cam_pivot), &[pivot.x, pivot.y]);
|
||||
}
|
||||
|
||||
/// Pan/zoom delta uniforms applied before the camera matrix: cached
|
||||
/// glyphs render at `p * scale + shift` per frame, GPU-side.
|
||||
pub fn set_pan_delta(&mut self, cx: &mut Cx, scale: f32, shift: Vec2f) {
|
||||
self.draw_vars.set_uniform(cx, live_id!(cam_scale), &[scale]);
|
||||
self.draw_vars
|
||||
.set_uniform(cx, live_id!(cam_shift), &[shift.x, shift.y]);
|
||||
}
|
||||
}
|
||||
|
||||
/// A single glyph positioned along a path, ready to draw.
|
||||
|
|
@ -259,7 +233,6 @@ impl DrawRotatedText {
|
|||
let saved_font_scale = self.draw_super.font_scale;
|
||||
self.draw_super.font_scale = 1.0;
|
||||
self.upright = 0.0;
|
||||
self.billboard = 0.0;
|
||||
for glyph in glyphs {
|
||||
self.draw_glyph_at(
|
||||
cx,
|
||||
|
|
@ -296,7 +269,6 @@ impl DrawRotatedText {
|
|||
let saved_font_scale = self.draw_super.font_scale;
|
||||
self.draw_super.font_scale = 1.0;
|
||||
self.upright = 1.0;
|
||||
self.billboard = 1.0;
|
||||
let scaled_anchor =
|
||||
Point::new(anchor.x * scale + offset.x, anchor.y * scale + offset.y);
|
||||
for glyph in glyphs {
|
||||
|
|
@ -314,7 +286,6 @@ impl DrawRotatedText {
|
|||
);
|
||||
}
|
||||
self.upright = 0.0;
|
||||
self.billboard = 0.0;
|
||||
self.draw_super.font_scale = saved_font_scale;
|
||||
}
|
||||
|
||||
|
|
@ -335,7 +306,6 @@ impl DrawRotatedText {
|
|||
let saved_font_scale = self.draw_super.font_scale;
|
||||
self.draw_super.font_scale = 1.0;
|
||||
self.upright = 1.0;
|
||||
self.billboard = 0.0;
|
||||
let anchor = Point::new(anchor.x * scale + offset.x, anchor.y * scale + offset.y);
|
||||
for glyph in glyphs {
|
||||
self.draw_glyph_at(
|
||||
|
|
|
|||
|
|
@ -44,54 +44,49 @@ script_mod! {
|
|||
// final anchor so turtle alignment can move it like DrawQuad.
|
||||
let transformed_local = self.transform_svg_point(pos * self.svg_scale + self.svg_offset);
|
||||
let transformed = transformed_local + self.rect_pos;
|
||||
let g_uv = unpack2f16(self.geom.uv)
|
||||
let g_color = unpack4u8(self.geom.color)
|
||||
let g_p0s = unpack2f16(self.geom.p0s)
|
||||
let g_p12 = unpack2f16(self.geom.p12)
|
||||
let g_p3c = unpack2f16(self.geom.p3c)
|
||||
self.v_tcoord = g_uv;
|
||||
self.v_color = g_color;
|
||||
self.v_tcoord = vec2(self.geom.u, self.geom.v);
|
||||
self.v_color = vec4(self.geom.color_r, self.geom.color_g, self.geom.color_b, self.geom.color_a);
|
||||
self.v_stroke_mult = self.geom.stroke_mult;
|
||||
self.v_stroke_dist = self.geom.stroke_dist;
|
||||
self.v_shape_id = g_p0s.y;
|
||||
self.v_param0 = g_p0s.x;
|
||||
self.v_shape_id = self.geom.shape_id;
|
||||
self.v_param0 = self.geom.param0;
|
||||
self.v_param5 = self.geom.param5;
|
||||
// Transform gradient geometry params by svg_scale/svg_offset and custom hook
|
||||
let grad_type = g_p0s.x;
|
||||
let grad_type = self.geom.param0;
|
||||
if grad_type > 0.5 && grad_type < 1.5 {
|
||||
// Linear gradient: p1,p2 = start point, p3,p4 = end point
|
||||
let p0 = self.transform_svg_point(g_p12 * self.svg_scale + self.svg_offset) + self.rect_pos;
|
||||
let p1 = self.transform_svg_point(vec2(g_p3c.x, self.geom.param4) * self.svg_scale + self.svg_offset) + self.rect_pos;
|
||||
let p0 = self.transform_svg_point(vec2(self.geom.param1, self.geom.param2) * self.svg_scale + self.svg_offset) + self.rect_pos;
|
||||
let p1 = self.transform_svg_point(vec2(self.geom.param3, self.geom.param4) * self.svg_scale + self.svg_offset) + self.rect_pos;
|
||||
self.v_param1 = p0.x;
|
||||
self.v_param2 = p0.y;
|
||||
self.v_param3 = p1.x;
|
||||
self.v_param4 = p1.y;
|
||||
} else if grad_type > 1.5 {
|
||||
// Radial gradient: p1,p2 = center, p3,p4 = rx, ry
|
||||
let center = self.transform_svg_point(g_p12 * self.svg_scale + self.svg_offset) + self.rect_pos;
|
||||
let center = self.transform_svg_point(vec2(self.geom.param1, self.geom.param2) * self.svg_scale + self.svg_offset) + self.rect_pos;
|
||||
self.v_param1 = center.x;
|
||||
self.v_param2 = center.y;
|
||||
self.v_param3 = g_p3c.x * self.svg_scale.x;
|
||||
self.v_param3 = self.geom.param3 * self.svg_scale.x;
|
||||
self.v_param4 = self.geom.param4 * self.svg_scale.y;
|
||||
} else if g_p0s.y > 0.5 {
|
||||
} else if self.geom.shape_id > 0.5 {
|
||||
// Effect shape with bbox in params: transform bbox by svg_scale/svg_offset
|
||||
let bbox_min = self.transform_svg_point(g_p12 * self.svg_scale + self.svg_offset) + self.rect_pos;
|
||||
let bbox_max = self.transform_svg_point(vec2(g_p3c.x, self.geom.param4) * self.svg_scale + self.svg_offset) + self.rect_pos;
|
||||
let bbox_min = self.transform_svg_point(vec2(self.geom.param1, self.geom.param2) * self.svg_scale + self.svg_offset) + self.rect_pos;
|
||||
let bbox_max = self.transform_svg_point(vec2(self.geom.param3, self.geom.param4) * self.svg_scale + self.svg_offset) + self.rect_pos;
|
||||
self.v_param1 = bbox_min.x;
|
||||
self.v_param2 = bbox_min.y;
|
||||
self.v_param3 = bbox_max.x;
|
||||
self.v_param4 = bbox_max.y;
|
||||
} else {
|
||||
self.v_param1 = g_p12.x;
|
||||
self.v_param2 = g_p12.y;
|
||||
self.v_param3 = g_p3c.x;
|
||||
self.v_param1 = self.geom.param1;
|
||||
self.v_param2 = self.geom.param2;
|
||||
self.v_param3 = self.geom.param3;
|
||||
self.v_param4 = self.geom.param4;
|
||||
}
|
||||
let shifted = transformed + self.draw_list.view_shift;
|
||||
self.v_world = shifted;
|
||||
|
||||
// Early clip rejection in final draw space.
|
||||
let cr = g_p3c.y * max(abs(self.svg_scale.x), abs(self.svg_scale.y));
|
||||
let cr = self.geom.clip_radius * max(abs(self.svg_scale.x), abs(self.svg_scale.y));
|
||||
let is_shadow = self.geom.stroke_mult < -0.5;
|
||||
if cr > 0.0 && !is_shadow {
|
||||
let clip = vec4(
|
||||
|
|
|
|||
|
|
@ -1292,11 +1292,6 @@ pub struct DrawText {
|
|||
#[live]
|
||||
pub temp_y_shift: f32,
|
||||
|
||||
/// Centering height (in unscaled lpxs) for RowAlign::Center; TextFlow sets
|
||||
/// this to the line style's height so mixed-font runs stay baseline-aligned.
|
||||
#[rust]
|
||||
pub align_row_height: Option<f32>,
|
||||
|
||||
/// Per-row horizontal alignment applied by the text layouter when the
|
||||
/// text does not fill the full `max_width_in_lpxs`. `x: 0.0` = left,
|
||||
/// `0.5` = center, `1.0` = right. `y` is currently unused by the
|
||||
|
|
@ -2162,46 +2157,9 @@ impl DrawText {
|
|||
} = walk.width
|
||||
{
|
||||
if let Some(resolved) = max_bound.eval_width(cx) {
|
||||
let turtle = cx.turtle();
|
||||
let padding = turtle.padding();
|
||||
// The bound limits this walk's margin box, so the text
|
||||
// itself gets the bound minus the walk's own horizontal
|
||||
// margins and the enclosing turtle's padding.
|
||||
let mut available =
|
||||
resolved - padding.left - padding.right - walk.margin.width();
|
||||
// When the enclosing turtle is itself an unresolved Fit
|
||||
// with a max bound (a Label sizing itself around this
|
||||
// text), its final width is clamped to the bound minus
|
||||
// its outer margins; the text must shrink by the same
|
||||
// amount, or the clamped clip slices off its tail.
|
||||
if turtle.width().is_nan()
|
||||
&& matches!(
|
||||
turtle.walk().width,
|
||||
crate::turtle::Size::Fit { max: Some(_), .. }
|
||||
)
|
||||
{
|
||||
available -= turtle.walk().margin.width();
|
||||
}
|
||||
// The layouter works in unscaled units; rows are multiplied
|
||||
// by font_scale on output, so the bound must be divided by
|
||||
// it here, as max_layout_width_for_walk does.
|
||||
let available_in_lpxs =
|
||||
(available.max(0.0) as f32) / self.font_scale.max(0.0001);
|
||||
// A bound too narrow for even the truncation ellipsis is no
|
||||
// bound at all: the layouter appends the ellipsis glyph
|
||||
// unconditionally, so a narrower clip would slice it open.
|
||||
// Left unbounded, the text overflows honestly, which lets
|
||||
// an enclosing flow's inline-content clamp hide it and
|
||||
// draw the ellipsis itself.
|
||||
let below_ellipsis_width = self.text_overflow == TextOverflow::Ellipsis
|
||||
&& available_in_lpxs
|
||||
< self
|
||||
.layout(cx, 0.0, 0.0, None, false, Align::default(), "…")
|
||||
.size_in_lpxs
|
||||
.width;
|
||||
if !below_ellipsis_width {
|
||||
max_width_in_lpxs = Some(available_in_lpxs);
|
||||
}
|
||||
let padding = cx.turtle().padding();
|
||||
max_width_in_lpxs =
|
||||
Some((resolved - padding.left - padding.right).max(0.0) as f32);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2257,7 +2215,6 @@ impl DrawText {
|
|||
cx.cx.debug.area(area, vec4(1.0, 1.0, 1.0, 1.0));
|
||||
}
|
||||
|
||||
|
||||
let origin_in_lpxs = Point::new(turtle_rect.pos.x as f32, turtle_rect.pos.y as f32);
|
||||
self.draw_text(cx, origin_in_lpxs, laidout_text);
|
||||
|
||||
|
|
@ -2312,7 +2269,7 @@ impl DrawText {
|
|||
let turtle_rect = cx.turtle().inner_rect();
|
||||
let origin_in_lpxs = Point::new(turtle_rect.pos.x as f32, turtle_pos.y as f32);
|
||||
let first_row_indent_in_lpxs = turtle_pos.x as f32 - origin_in_lpxs.x;
|
||||
let row_height = self.resumable_first_row_min_spacing(cx) as f64;
|
||||
let row_height = cx.turtle().next_row_offset();
|
||||
let max_width_in_lpxs = if !turtle_rect.size.x.is_nan() {
|
||||
Some(turtle_rect.size.x as f32)
|
||||
} else {
|
||||
|
|
@ -2358,7 +2315,6 @@ impl DrawText {
|
|||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
let per_row = false;
|
||||
|
||||
|
||||
if per_row {
|
||||
// ── PER-ROW path ──
|
||||
// Draw each visual row as a separate instance batch so its glyphs
|
||||
|
|
@ -2448,15 +2404,12 @@ impl DrawText {
|
|||
|
||||
// Emit a per-row FinishedWalk covering this row's glyphs +
|
||||
// callback rect-areas.
|
||||
cx.emit_turtle_walk_with_align_height(
|
||||
cx.emit_turtle_walk(
|
||||
Rect {
|
||||
pos: dvec2(row_origin.x as f64, row_top_y),
|
||||
size: dvec2((row.width_in_lpxs * self.font_scale) as f64, row_h),
|
||||
},
|
||||
row_als,
|
||||
Metrics::default(),
|
||||
self.align_row_height
|
||||
.map(|h| (h * self.font_scale) as f64),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -2499,6 +2452,16 @@ impl DrawText {
|
|||
|
||||
self.draw_text(cx, origin_in_lpxs, &text);
|
||||
|
||||
let turtle = cx.turtle_mut();
|
||||
turtle.move_to(dvec2(origin_in_lpxs.x as f64, origin_in_lpxs.y as f64));
|
||||
turtle.allocate_width(used_size_in_lpxs.width as f64);
|
||||
turtle.allocate_height(used_size_in_lpxs.height as f64);
|
||||
turtle.move_to(new_turtle_pos);
|
||||
turtle.set_wrap_spacing(
|
||||
(last_row.ascender_in_lpxs * last_row.line_spacing_scale
|
||||
- last_row.ascender_in_lpxs) as f64,
|
||||
);
|
||||
|
||||
if !callback_before_text {
|
||||
for (row_index, row) in text.rows.iter().enumerate() {
|
||||
let (sx, ex) = row_span_x_bounds_in_lpxs(
|
||||
|
|
@ -2524,169 +2487,21 @@ impl DrawText {
|
|||
}
|
||||
}
|
||||
|
||||
let wrap_spacing = (last_row.ascender_in_lpxs * last_row.line_spacing_scale
|
||||
- last_row.ascender_in_lpxs) as f64;
|
||||
|
||||
if text.rows.len() == 1 {
|
||||
let turtle = cx.turtle_mut();
|
||||
turtle.move_to(dvec2(origin_in_lpxs.x as f64, origin_in_lpxs.y as f64));
|
||||
turtle.allocate_width(used_size_in_lpxs.width as f64);
|
||||
turtle.allocate_height(used_size_in_lpxs.height as f64);
|
||||
turtle.move_to(new_turtle_pos);
|
||||
turtle.set_wrap_spacing(wrap_spacing);
|
||||
|
||||
cx.emit_turtle_walk_with_align_height(
|
||||
Rect {
|
||||
pos: new_turtle_pos,
|
||||
size: dvec2(
|
||||
used_size_in_lpxs.width as f64,
|
||||
used_size_in_lpxs.height as f64,
|
||||
),
|
||||
},
|
||||
align_list_start,
|
||||
Metrics::default(),
|
||||
self.align_row_height.map(|h| (h * self.font_scale) as f64),
|
||||
);
|
||||
} else {
|
||||
// The turtle's row bookkeeping must see this run's internal
|
||||
// row boundaries: RowAlign shifts and row heights are computed
|
||||
// per turtle row when `finish_row` runs, and without a boundary
|
||||
// here every walk since the previous one — including content
|
||||
// that sits on an earlier visual row — is aligned against the
|
||||
// final row's height. Only the finished-walk and row
|
||||
// bookkeeping is per-row; the glyphs stay in one instance
|
||||
// batch, so no per-row shifting of this run's own rows is
|
||||
// possible and its walks carry ranges accordingly.
|
||||
let fs = self.font_scale as f64;
|
||||
let row_top = |row: &crate::text::layouter::LaidoutRow| {
|
||||
origin_in_lpxs.y as f64
|
||||
+ (row.origin_in_lpxs.y - row.ascender_in_lpxs) as f64 * fs
|
||||
};
|
||||
let row_height = |row: &crate::text::layouter::LaidoutRow| {
|
||||
(row.ascender_in_lpxs - row.descender_in_lpxs) as f64 * fs
|
||||
};
|
||||
|
||||
|
||||
let first_row = &text.rows[0];
|
||||
let turtle = cx.turtle_mut();
|
||||
turtle.move_to(dvec2(origin_in_lpxs.x as f64, origin_in_lpxs.y as f64));
|
||||
turtle.allocate_width(used_size_in_lpxs.width as f64);
|
||||
turtle.allocate_height(row_height(first_row));
|
||||
|
||||
// The first row's walk owns every align entry this run emitted
|
||||
// and is immovable — shifting it would displace the later
|
||||
// rows' glyphs, which share its instance batch. When the first
|
||||
// row holds visible text it anchors its row's center line, so
|
||||
// a taller inline item beside it centers on the text; when it
|
||||
// holds nothing visible — no glyphs, or only whitespace (a
|
||||
// continuation that wrapped immediately, often with a single
|
||||
// leading space fitting the line's slack) — the walk is inert,
|
||||
// so the row of preceding content keeps its normal alignment
|
||||
// instead of anchoring to an invisible line.
|
||||
let first_row_role = if first_row.text.trim().is_empty() {
|
||||
crate::turtle::RowAlignRole::Fixed
|
||||
} else {
|
||||
crate::turtle::RowAlignRole::Anchor
|
||||
};
|
||||
cx.emit_turtle_walk_with_role(
|
||||
Rect {
|
||||
pos: dvec2(origin_in_lpxs.x as f64, origin_in_lpxs.y as f64),
|
||||
size: dvec2(
|
||||
(first_row.width_in_lpxs * self.font_scale) as f64,
|
||||
row_height(first_row),
|
||||
),
|
||||
},
|
||||
align_list_start,
|
||||
Metrics::default(),
|
||||
self.align_row_height.map(|h| (h * self.font_scale) as f64),
|
||||
first_row_role,
|
||||
);
|
||||
|
||||
for row_index in 1..text.rows.len() {
|
||||
// A new turtle row starts at origin.y + used_height +
|
||||
// spacing. Anchoring the spacing to the turtle's real used
|
||||
// bottom — which includes any inline content on the
|
||||
// previous visual row that is taller than the text — lands
|
||||
// the turtle exactly on this laidout row's top, so turtle
|
||||
// geometry and glyph geometry agree at every internal row
|
||||
// boundary.
|
||||
let used_bottom = cx.turtle().origin().y + cx.turtle().used_height();
|
||||
let spacing = row_top(&text.rows[row_index]) - used_bottom;
|
||||
cx.turtle_new_line_with_spacing(spacing);
|
||||
cx.turtle_mut().allocate_height(row_height(&text.rows[row_index]));
|
||||
}
|
||||
|
||||
let turtle = cx.turtle_mut();
|
||||
turtle.move_to(new_turtle_pos);
|
||||
turtle.set_wrap_spacing(wrap_spacing);
|
||||
|
||||
// The last row's walk carries an empty align range: this run's
|
||||
// glyphs live in the first row's walk and must not be shifted
|
||||
// again when the last row finishes. Because those glyphs can
|
||||
// never move, the walk anchors the row's center line — a
|
||||
// taller item that joins this row (an inline pill) centers on
|
||||
// the text instead of dragging the row's center below it.
|
||||
let empty_range = cx.align_list_len();
|
||||
cx.emit_turtle_walk_with_role(
|
||||
Rect {
|
||||
pos: new_turtle_pos,
|
||||
size: dvec2(
|
||||
(last_row.width_in_lpxs * self.font_scale) as f64,
|
||||
row_height(last_row),
|
||||
),
|
||||
},
|
||||
empty_range,
|
||||
Metrics::default(),
|
||||
Some(row_height(last_row)),
|
||||
crate::turtle::RowAlignRole::Anchor,
|
||||
);
|
||||
}
|
||||
cx.emit_turtle_walk(
|
||||
Rect {
|
||||
pos: new_turtle_pos,
|
||||
size: dvec2(
|
||||
used_size_in_lpxs.width as f64,
|
||||
used_size_in_lpxs.height as f64,
|
||||
),
|
||||
},
|
||||
align_list_start,
|
||||
);
|
||||
}
|
||||
|
||||
(text.rows.len(), text.is_truncated)
|
||||
}
|
||||
|
||||
/// Returns the `first_row_min_line_spacing_below_in_lpxs` a resumable run
|
||||
/// passes to the layouter for the current turtle. In a centering wrap flow
|
||||
/// this is the offset to the next turtle row (converted into layout units
|
||||
/// by dividing out `font_scale`), so a continuation run's second row
|
||||
/// clears inline content on its first visual row that is taller than the
|
||||
/// text. Other flows pass zero and keep pure font-metric row spacing.
|
||||
/// Every layout of the same run (draw, selection capture, wrap probes)
|
||||
/// must use this same value so they share one layout-cache entry.
|
||||
pub fn resumable_first_row_min_spacing(&self, cx: &Cx2d) -> f32 {
|
||||
if matches!(
|
||||
cx.turtle().layout().flow,
|
||||
Flow::Right {
|
||||
wrap: true,
|
||||
row_align: crate::turtle::RowAlign::Center,
|
||||
..
|
||||
}
|
||||
) {
|
||||
// When the current row holds inline content taller than the text
|
||||
// line (pills), the following row is expected to hold the same
|
||||
// kind of content beside this run's text, and RowAlign::Center
|
||||
// will seat that content's center on the text's center — placing
|
||||
// its top (row_height − text_height)/2 ABOVE the text's top. The
|
||||
// floor therefore positions the second row's text one full
|
||||
// current-row advance below, plus that overhang, so centered
|
||||
// content on the next row starts exactly one wrap gap below the
|
||||
// current row's content with zero residual shift. When the text
|
||||
// is the tallest content, the floor equals `next_row_offset()`
|
||||
// and pure font-metric spacing wins where it is larger.
|
||||
let row_height = cx.turtle().row_height();
|
||||
let text_row_height = self
|
||||
.align_row_height
|
||||
.map(|h| (h * self.font_scale) as f64)
|
||||
.unwrap_or(row_height);
|
||||
let overhang = ((row_height - text_row_height) * 0.5).max(0.0);
|
||||
((row_height + cx.turtle().wrap_spacing() + overhang)
|
||||
/ self.font_scale.max(0.0001) as f64) as f32
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn layout(
|
||||
&self,
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ script_mod! {
|
|||
draw_call: uniform_buffer(draw.DrawCallUniforms)
|
||||
draw_pass: uniform_buffer(draw.DrawPassUniforms)
|
||||
draw_list: uniform_buffer(draw.DrawListUniforms)
|
||||
geom: vertex_buffer(geom.VectorVertexPacked, geom.VectorGeomPacked)
|
||||
geom: vertex_buffer(geom.VectorVertex, geom.VectorGeom)
|
||||
gradient_texture: texture_2d(float)
|
||||
|
||||
v_tcoord: varying(vec2f)
|
||||
|
|
@ -33,27 +33,22 @@ script_mod! {
|
|||
|
||||
vertex: fn() {
|
||||
let pos = vec2(self.geom.x, self.geom.y);
|
||||
let g_uv = unpack2f16(self.geom.uv)
|
||||
let g_color = unpack4u8(self.geom.color)
|
||||
let g_p0s = unpack2f16(self.geom.p0s)
|
||||
let g_p12 = unpack2f16(self.geom.p12)
|
||||
let g_p3c = unpack2f16(self.geom.p3c)
|
||||
self.v_tcoord = g_uv;
|
||||
self.v_color = g_color;
|
||||
self.v_tcoord = vec2(self.geom.u, self.geom.v);
|
||||
self.v_color = vec4(self.geom.color_r, self.geom.color_g, self.geom.color_b, self.geom.color_a);
|
||||
self.v_stroke_mult = self.geom.stroke_mult;
|
||||
self.v_stroke_dist = self.geom.stroke_dist;
|
||||
self.v_shape_id = g_p0s.y;
|
||||
self.v_param0 = g_p0s.x;
|
||||
self.v_param1 = g_p12.x;
|
||||
self.v_param2 = g_p12.y;
|
||||
self.v_param3 = g_p3c.x;
|
||||
self.v_shape_id = self.geom.shape_id;
|
||||
self.v_param0 = self.geom.param0;
|
||||
self.v_param1 = self.geom.param1;
|
||||
self.v_param2 = self.geom.param2;
|
||||
self.v_param3 = self.geom.param3;
|
||||
self.v_param4 = self.geom.param4;
|
||||
self.v_param5 = self.geom.param5;
|
||||
let shifted = pos + self.draw_list.view_shift;
|
||||
self.v_world = shifted;
|
||||
|
||||
// Early clip rejection in local space.
|
||||
let cr = unpack2f16(self.geom.p3c).y;
|
||||
let cr = self.geom.clip_radius;
|
||||
let is_shadow = self.geom.stroke_mult < -0.5;
|
||||
if cr > 0.0 && !is_shadow {
|
||||
let clip = vec4(
|
||||
|
|
@ -721,9 +716,7 @@ impl DrawVector {
|
|||
}
|
||||
|
||||
let geom = self.geometry.get_or_insert_with(|| Geometry::new(cx.cx.cx));
|
||||
let mut packed = crate::vector::pack_vector_vertices(&self.acc_verts);
|
||||
let mut indices = self.acc_indices.clone();
|
||||
geom.update_with_recycled_buffers(cx.cx.cx, &mut indices, &mut packed);
|
||||
geom.update_with_recycled_buffers(cx.cx.cx, &mut self.acc_indices, &mut self.acc_verts);
|
||||
self.draw_vars.geometry_id = Some(geom.geometry_id());
|
||||
cx.new_draw_call(&self.draw_vars);
|
||||
if self.draw_vars.can_instance() {
|
||||
|
|
|
|||
|
|
@ -402,12 +402,8 @@ script_mod! {
|
|||
box: fn(x: float, y: float, w: float, h: float, r: float) {
|
||||
let p = self.pos - vec2(x, y);
|
||||
let size = vec2(0.5 * w, 0.5 * h);
|
||||
// The effective visual radius is 2*r. Clamp it to the half-size:
|
||||
// past that the SDF used to degenerate into a rotated diamond
|
||||
// (e.g. a 22px disc with r=11), instead of saturating at a circle.
|
||||
let k = min(2. * r, min(size.x, size.y));
|
||||
let bp = max(abs(p - size.xy) - (size.xy - vec2(k, k).xy), vec2(0., 0.));
|
||||
self.dist = (length(bp) - k) / self.scale_factor;
|
||||
let bp = max(abs(p - size.xy) - (size.xy - vec2(2. * r, 2. * r).xy), vec2(0., 0.));
|
||||
self.dist = (length(bp) - 2. * r) / self.scale_factor;
|
||||
self.old_shape = self.shape;
|
||||
self.shape = min(self.shape, self.dist);
|
||||
}
|
||||
|
|
@ -417,17 +413,13 @@ script_mod! {
|
|||
let p_r = self.pos - vec2(x, y);
|
||||
let p = abs(p_r - size.xy) - size.xy;
|
||||
|
||||
// Clamp each (doubled) radius to the half-size so oversized radii
|
||||
// saturate at a capsule instead of degenerating into a diamond.
|
||||
let k_top = min(2. * r_top, min(size.x, size.y));
|
||||
let k_bottom = min(2. * r_bottom, min(size.x, size.y));
|
||||
let q_top = p + vec2(k_top, k_top).xy;
|
||||
let q_bottom = p + vec2(k_bottom, k_bottom).xy;
|
||||
let q_top = p + vec2(2. * r_top, 2. * r_top).xy;
|
||||
let q_bottom = p + vec2(2. * r_bottom, 2. * r_bottom).xy;
|
||||
|
||||
// The min(max(q.x,q.y),0) interior term keeps the field continuous; without it
|
||||
// the interior dist was -2r, so switching radius at the midpoint left a coverage seam.
|
||||
let dist_top = min(max(q_top.x, q_top.y), 0.) + length(max(q_top, vec2(0., 0.))) - k_top;
|
||||
let dist_bottom = min(max(q_bottom.x, q_bottom.y), 0.) + length(max(q_bottom, vec2(0., 0.))) - k_bottom;
|
||||
let dist_top = min(max(q_top.x, q_top.y), 0.) + length(max(q_top, vec2(0., 0.))) - 2. * r_top;
|
||||
let dist_bottom = min(max(q_bottom.x, q_bottom.y), 0.) + length(max(q_bottom, vec2(0., 0.))) - 2. * r_bottom;
|
||||
|
||||
self.dist = mix(dist_top, dist_bottom, step(0.5 * h, p_r.y)) / self.scale_factor;
|
||||
|
||||
|
|
@ -440,16 +432,12 @@ script_mod! {
|
|||
let p_r = self.pos - vec2(x, y);
|
||||
let p = abs(p_r - size.xy) - size.xy;
|
||||
|
||||
// Clamp each (doubled) radius to the half-size so oversized radii
|
||||
// saturate at a capsule instead of degenerating into a diamond.
|
||||
let k_left = min(2. * r_left, min(size.x, size.y));
|
||||
let k_right = min(2. * r_right, min(size.x, size.y));
|
||||
let bp_left = max(p + vec2(k_left, k_left).xy, vec2(0., 0.));
|
||||
let bp_right = max(p + vec2(k_right, k_right).xy, vec2(0., 0.));
|
||||
let bp_left = max(p + vec2(2. * r_left, 2. * r_left).xy, vec2(0., 0.));
|
||||
let bp_right = max(p + vec2(2. * r_right, 2. * r_right).xy, vec2(0., 0.));
|
||||
|
||||
self.dist = mix(
|
||||
(length(bp_left) - k_left),
|
||||
(length(bp_right) - k_right),
|
||||
(length(bp_left) - 2. * r_left),
|
||||
(length(bp_right) - 2. * r_right),
|
||||
step(0.5 * w, p_r.x)
|
||||
) / self.scale_factor;
|
||||
|
||||
|
|
@ -471,26 +459,20 @@ script_mod! {
|
|||
let p_r = self.pos - vec2(x, y);
|
||||
let p = abs(p_r - size.xy) - size.xy;
|
||||
|
||||
// Clamp each (doubled) radius to the half-size so oversized radii
|
||||
// saturate instead of degenerating into a diamond.
|
||||
let k_lt = min(2. * r_left_top, min(size.x, size.y));
|
||||
let k_rt = min(2. * r_right_top, min(size.x, size.y));
|
||||
let k_rb = min(2. * r_right_bottom, min(size.x, size.y));
|
||||
let k_lb = min(2. * r_left_bottom, min(size.x, size.y));
|
||||
let bp_lt = max(p + vec2(k_lt, k_lt).xy, vec2(0., 0.));
|
||||
let bp_rt = max(p + vec2(k_rt, k_rt).xy, vec2(0., 0.));
|
||||
let bp_rb = max(p + vec2(k_rb, k_rb).xy, vec2(0., 0.));
|
||||
let bp_lb = max(p + vec2(k_lb, k_lb).xy, vec2(0., 0.));
|
||||
let bp_lt = max(p + vec2(2. * r_left_top, 2. * r_left_top).xy, vec2(0., 0.));
|
||||
let bp_rt = max(p + vec2(2. * r_right_top, 2. * r_right_top).xy, vec2(0., 0.));
|
||||
let bp_rb = max(p + vec2(2. * r_right_bottom, 2. * r_right_bottom).xy, vec2(0., 0.));
|
||||
let bp_lb = max(p + vec2(2. * r_left_bottom, 2. * r_left_bottom).xy, vec2(0., 0.));
|
||||
|
||||
self.dist = mix(
|
||||
mix(
|
||||
(length(bp_lt) - k_lt),
|
||||
(length(bp_lb) - k_lb),
|
||||
(length(bp_lt) - 2. * r_left_top),
|
||||
(length(bp_lb) - 2. * r_left_bottom),
|
||||
step(0.5 * h, p_r.y)
|
||||
),
|
||||
mix(
|
||||
(length(bp_rt) - k_rt),
|
||||
(length(bp_rb) - k_rb),
|
||||
(length(bp_rt) - 2. * r_right_top),
|
||||
(length(bp_rb) - 2. * r_right_bottom),
|
||||
step(0.5 * h, p_r.y)
|
||||
),
|
||||
step(0.5 * w, p_r.x)
|
||||
|
|
|
|||
|
|
@ -400,14 +400,6 @@ impl LayoutContext {
|
|||
.map_or(false, |max| self.rows.len() >= max)
|
||||
}
|
||||
|
||||
/// Whether the row currently being laid out is the last one `max_rows`
|
||||
/// permits.
|
||||
fn current_row_is_last_allowed(&self) -> bool {
|
||||
self.options
|
||||
.max_rows
|
||||
.map_or(false, |max| self.rows.len() + 1 >= max)
|
||||
}
|
||||
|
||||
fn layout(&mut self, len: usize) {
|
||||
if self.remaining_width_in_lpxs().is_none() {
|
||||
self.layout_directly(len);
|
||||
|
|
@ -433,21 +425,6 @@ impl LayoutContext {
|
|||
let next_word = &self.text[self.current_row_end..][..fitter.next_len()];
|
||||
if next_word.chars().all(|char| char.is_whitespace()) {
|
||||
self.layout_directly(fitter.pop());
|
||||
} else if self.options.ellipsis
|
||||
&& self.current_row_is_last_allowed()
|
||||
&& !self.current_row_is_continuation()
|
||||
{
|
||||
// The last permitted row ends in an ellipsis, so word
|
||||
// integrity is moot: fill it to the width limit by
|
||||
// grapheme so the ellipsis truncates at the last glyph
|
||||
// that fits instead of at the last whole word — a word
|
||||
// that wraps away from this row would otherwise leave
|
||||
// it ellipsized far short of the available width.
|
||||
// Continuation rows are excluded: grapheme layout
|
||||
// force-places a grapheme wider than an empty row's
|
||||
// remnant past the width limit, unflagged, whereas
|
||||
// finishing the row truncates within bounds.
|
||||
self.layout_by_grapheme(fitter.pop());
|
||||
} else if self.current_row_is_empty() && !self.current_row_is_continuation() {
|
||||
self.layout_by_grapheme(fitter.pop());
|
||||
} else {
|
||||
|
|
@ -538,21 +515,7 @@ impl LayoutContext {
|
|||
|
||||
self.current_point_in_lpxs.x = 0.0;
|
||||
self.current_point_in_lpxs.y += self.rows.last().map_or(row.ascender_in_lpxs, |prev_row| {
|
||||
let natural_in_lpxs = prev_row.line_spacing_in_lpxs(&row);
|
||||
if self.rows.len() == 1 {
|
||||
// The first row can share its visual row with earlier inline
|
||||
// content that is taller than the text; the caller passes that
|
||||
// row's real height (plus wrap spacing) so the second row's
|
||||
// top edge clears it. The floor is a top-to-top distance, so
|
||||
// it converts to a baseline advance by adding the change in
|
||||
// ascender between the two rows.
|
||||
let min_advance_in_lpxs = self.options.first_row_min_line_spacing_below_in_lpxs
|
||||
+ row.ascender_in_lpxs
|
||||
- prev_row.ascender_in_lpxs;
|
||||
natural_in_lpxs.max(min_advance_in_lpxs)
|
||||
} else {
|
||||
natural_in_lpxs
|
||||
}
|
||||
prev_row.line_spacing_in_lpxs(&row)
|
||||
});
|
||||
let max_width_in_lpxs = self.options.max_width_in_lpxs.unwrap_or(row.width_in_lpxs);
|
||||
let remaining_width_in_lpxs = max_width_in_lpxs - row.width_in_lpxs;
|
||||
|
|
@ -613,24 +576,10 @@ impl LayoutContext {
|
|||
}
|
||||
};
|
||||
|
||||
let mut text_was_truncated = self.rows.len() > max_rows || !all_text_consumed;
|
||||
let text_was_truncated = self.rows.len() > max_rows || !all_text_consumed;
|
||||
|
||||
self.rows.truncate(max_rows);
|
||||
|
||||
// A non-wrapping layout puts every glyph on a single row, so its
|
||||
// overflow shows up as a row wider than the bound rather than as
|
||||
// surplus rows. Row counting alone therefore reports "nothing was
|
||||
// truncated" for the very case the ellipsis exists to handle.
|
||||
// Restricted to non-wrapping layouts because a wrapped row's width
|
||||
// includes any first-row indent and may legitimately reach the bound.
|
||||
if self.options.ellipsis && !self.options.wrap {
|
||||
if let Some(max_width) = self.options.max_width_in_lpxs {
|
||||
if self.rows.last().is_some_and(|row| row.width_in_lpxs > max_width) {
|
||||
text_was_truncated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !text_was_truncated {
|
||||
return self.finish_with(false);
|
||||
}
|
||||
|
|
@ -1041,12 +990,8 @@ impl PartialEq for Style {
|
|||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct LayoutOptions {
|
||||
pub first_row_indent_in_lpxs: f32,
|
||||
/// Minimum distance in logical pixels from the first row's top edge to the
|
||||
/// second row's top edge. A continuation run's first row can share its
|
||||
/// visual row with earlier inline content that is taller than the text;
|
||||
/// callers pass that row's real height (plus wrap spacing) so the second
|
||||
/// row clears it. Zero keeps pure font-metric spacing. Only the first row
|
||||
/// boundary is affected; later rows always use font-metric spacing.
|
||||
// Note: currently does nothing. Only used by `TextFlow`. Should be removed once `TextFlow` is
|
||||
// replaced with `TextFlow2`.
|
||||
pub first_row_min_line_spacing_below_in_lpxs: f32,
|
||||
pub max_width_in_lpxs: Option<f32>,
|
||||
pub wrap: bool,
|
||||
|
|
|
|||
|
|
@ -253,27 +253,6 @@ impl Shaper {
|
|||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
/// Logs one warning per unique codepoint that no loaded font can render,
|
||||
/// so ".notdef" boxes in the UI are explained in the log instead of being
|
||||
/// silently drawn (a missing arrow/symbol glyph is otherwise very hard to
|
||||
/// distinguish from a layout bug).
|
||||
fn warn_missing_glyph_once(text: &str, cluster: usize) {
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Mutex;
|
||||
static WARNED: Mutex<Option<HashSet<char>>> = Mutex::new(None);
|
||||
let Some(ch) = text.get(cluster..).and_then(|s| s.chars().next()) else {
|
||||
return;
|
||||
};
|
||||
let mut warned = WARNED.lock().unwrap();
|
||||
if warned.get_or_insert_with(HashSet::new).insert(ch) {
|
||||
crate::makepad_platform::log!(
|
||||
"no loaded font has a glyph for '{}' (U+{:04X}); rendering .notdef",
|
||||
ch,
|
||||
ch as u32
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn shape_recursive(
|
||||
&mut self,
|
||||
text: &str,
|
||||
|
|
@ -365,11 +344,6 @@ impl Shaper {
|
|||
}
|
||||
} else {
|
||||
let glyph_group = glyph_groups[i];
|
||||
if remaining_fonts.is_empty() {
|
||||
for glyph in glyph_group.iter().filter(|glyph| glyph.id == 0) {
|
||||
Self::warn_missing_glyph_once(text, glyph.cluster);
|
||||
}
|
||||
}
|
||||
// If we've exhausted all fallback fonts and still have
|
||||
// unmapped glyphs (id == 0), use the primary font's .notdef
|
||||
// so a visible placeholder is rendered instead of nothing.
|
||||
|
|
|
|||
|
|
@ -358,11 +358,6 @@ pub enum Base {
|
|||
#[pick]
|
||||
Full,
|
||||
Unused,
|
||||
/// The width available on the enclosing line for inline content,
|
||||
/// accounting for the enclosing widget's own leading geometry and
|
||||
/// trailing insets; see [`Cx2d::find_line_available_width`]. Widths
|
||||
/// only: as a height base this resolves to nothing.
|
||||
Line,
|
||||
}
|
||||
|
||||
/// Specifies how walks should be laid out with respect to each other.
|
||||
|
|
@ -665,20 +660,6 @@ impl Turtle {
|
|||
self.layout.padding.right = right;
|
||||
}
|
||||
|
||||
/// Sets whether this turtle's `Flow::Right` layout wraps onto a new row,
|
||||
/// leaving any other flow untouched.
|
||||
///
|
||||
/// Turning wrapping off confines the following walks to the current row,
|
||||
/// which is how a caller that has run out of rows to give keeps an
|
||||
/// oversized walk from opening one anyway. Such a walk overruns the row's
|
||||
/// width instead, exactly as unwrappable text does. Save the previous
|
||||
/// setting via [`Turtle::layout`] and restore it when done.
|
||||
pub fn set_flow_wrap(&mut self, wrap: bool) {
|
||||
if let Flow::Right { wrap: flow_wrap, .. } = &mut self.layout.flow {
|
||||
*flow_wrap = wrap;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the alignment of each walk of this turtle with respect to it's rectangle.
|
||||
pub fn align(&self) -> Align {
|
||||
self.layout.align
|
||||
|
|
@ -822,9 +803,6 @@ impl Turtle {
|
|||
match base {
|
||||
Base::Full => self.width(),
|
||||
Base::Unused => self.unused_width(),
|
||||
// A line bound spans the whole turtle stack, not one turtle;
|
||||
// it is resolved by `Cx2d::find_line_available_width`.
|
||||
Base::Line => f64::NAN,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -832,7 +810,6 @@ impl Turtle {
|
|||
match base {
|
||||
Base::Full => self.height(),
|
||||
Base::Unused => self.unused_height(),
|
||||
Base::Line => f64::NAN,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1293,37 +1270,6 @@ pub struct FinishedWalk {
|
|||
outer_size: Vec2d,
|
||||
|
||||
metrics: Metrics,
|
||||
|
||||
/// How row alignment may treat this walk (see [`RowAlignRole`]).
|
||||
align_role: RowAlignRole,
|
||||
|
||||
/// Height to center by under `RowAlign::Center` instead of `outer_size.y`.
|
||||
/// Text runs pass their line's height here so mixed-font runs on a row all
|
||||
/// get the same shift and keep their relative baselines.
|
||||
align_height: Option<f64>,
|
||||
}
|
||||
|
||||
/// How row alignment may treat a finished walk.
|
||||
///
|
||||
/// A multi-row text run draws all of its glyphs in one instance batch, so no
|
||||
/// individual row of it can ever be repositioned; the run's per-row walks are
|
||||
/// therefore immovable and declare one of the non-`Shiftable` roles.
|
||||
#[derive(Clone, Copy, Default, Debug, PartialEq, Eq)]
|
||||
pub enum RowAlignRole {
|
||||
/// Row alignment may shift this walk's align range.
|
||||
#[default]
|
||||
Shiftable,
|
||||
/// Immovable, and under `RowAlign::Center` the row's center line anchors
|
||||
/// to this walk's own center instead of the tallest walk's, so every
|
||||
/// shiftable walk on the row — including a taller one, which then shifts
|
||||
/// UP — centers on it. Declared by a wrapped run's rows that hold visible
|
||||
/// text.
|
||||
Anchor,
|
||||
/// Immovable and inert: neither shifted nor an anchor. Declared by a
|
||||
/// wrapped run's first-row walk when that row holds no glyphs (the run
|
||||
/// wrapped immediately), so a row of other content is not anchored to an
|
||||
/// invisible line.
|
||||
Fixed,
|
||||
}
|
||||
|
||||
/// The horizontal shift that centers a `Flow::Right` row's actually-drawn content
|
||||
|
|
@ -1398,9 +1344,6 @@ impl<'a, 'b> Cx2d<'a, 'b> {
|
|||
}
|
||||
|
||||
pub fn find_base_width(&self, base: Base) -> Option<f64> {
|
||||
if let Base::Line = base {
|
||||
return self.find_line_available_width();
|
||||
}
|
||||
self.turtles
|
||||
.iter()
|
||||
.rev()
|
||||
|
|
@ -1410,9 +1353,6 @@ impl<'a, 'b> Cx2d<'a, 'b> {
|
|||
}
|
||||
|
||||
pub fn find_base_height(&self, base: Base) -> Option<f64> {
|
||||
if let Base::Line = base {
|
||||
return None;
|
||||
}
|
||||
self.turtles
|
||||
.iter()
|
||||
.rev()
|
||||
|
|
@ -1421,65 +1361,6 @@ impl<'a, 'b> Cx2d<'a, 'b> {
|
|||
.find(|height| !height.is_nan())
|
||||
}
|
||||
|
||||
/// Returns the width available on the enclosing line for the current
|
||||
/// turtle's content, for a [`Base::Line`] bound.
|
||||
///
|
||||
/// The line is the nearest enclosing turtle with a known width; the
|
||||
/// unresolved `Fit` turtles between it and the current one (an inline
|
||||
/// widget's nesting levels) contribute their leading geometry and
|
||||
/// trailing insets. The line's flow selects between two measurements,
|
||||
/// each of which is final at the moment it is taken:
|
||||
///
|
||||
/// - A wrapping line can relocate the inline widget whole onto a fresh
|
||||
/// row, so the bound is what a fresh row offers: the line's inner
|
||||
/// width minus the widget-internal lead-in before this turtle and
|
||||
/// the trailing insets after it. Content sized to this bound either
|
||||
/// fits where it is, or fits the row the widget is relocated to.
|
||||
/// - A non-wrapping line (including one held non-wrapping by an
|
||||
/// inline-content clamp on the last permitted row) keeps the widget
|
||||
/// where it is, so the bound is the remnant: the distance from the
|
||||
/// pen to the line's inner right edge, minus the trailing insets.
|
||||
///
|
||||
/// The current turtle's own trailing margin is left for the consumer,
|
||||
/// which resolves a `Fit` max bound against the walk's margin box. Its
|
||||
/// leading margin is part of the measured lead-in (the turtle's origin
|
||||
/// lies past it), so a consumer that subtracts the full margin width
|
||||
/// counts the leading side twice — a deliberately conservative overlap
|
||||
/// of a few pixels that keeps a remnant-fitted walk safely inside the
|
||||
/// line's overrun tolerance.
|
||||
pub fn find_line_available_width(&self) -> Option<f64> {
|
||||
let (current, outer_turtles) = self.turtles.split_last()?;
|
||||
// Measured from the current turtle's content origin, not its pen:
|
||||
// the bound is evaluated both before the content is laid out and
|
||||
// again when the turtle closes (the `Fit` max clamp), and the two
|
||||
// must agree — the origin is the content's start either way.
|
||||
let start_x = current.origin().x + current.padding().left;
|
||||
let mut trailing = current.padding().right;
|
||||
// The outermost unresolved turtle inside the line so far: the
|
||||
// inline widget's root, whose origin marks where its lead-in
|
||||
// (icons, padding, spacing before this content) begins.
|
||||
let mut widget_origin_x = current.origin().x;
|
||||
let mut widget_margin_left = 0.0;
|
||||
for turtle in outer_turtles.iter().rev() {
|
||||
if !turtle.width().is_nan() {
|
||||
let inner = turtle.inner_rect();
|
||||
let available = match turtle.layout().flow {
|
||||
Flow::Right { wrap: false, .. } => {
|
||||
inner.pos.x + inner.size.x - start_x - trailing
|
||||
}
|
||||
Flow::Right { wrap: true, .. } | Flow::Down | Flow::Overlay => {
|
||||
inner.size.x - widget_margin_left - (start_x - widget_origin_x) - trailing
|
||||
}
|
||||
};
|
||||
return Some(available.max(0.0));
|
||||
}
|
||||
trailing += turtle.padding().right + turtle.walk().margin.right;
|
||||
widget_origin_x = turtle.origin().x;
|
||||
widget_margin_left = turtle.walk().margin.left;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Starts a root turtle.
|
||||
pub fn begin_root_turtle(&mut self, size: Vec2d, layout: Layout) {
|
||||
self.align_list
|
||||
|
|
@ -1621,11 +1502,7 @@ impl<'a, 'b> Cx2d<'a, 'b> {
|
|||
///
|
||||
/// The current turtle should be finished with the same guard area that was used to start it.
|
||||
pub fn end_turtle_with_guard(&mut self, guard: Area) -> Rect {
|
||||
// The final row's bottom forgiveness is deliberately discarded: the
|
||||
// turtle's used height keeps that row's full physical extent, so
|
||||
// up-centered walks on a last row stay inside the reported rect and
|
||||
// its clip bottom.
|
||||
let _ = self.finish_row(self.align_list.len());
|
||||
self.finish_row(self.align_list.len());
|
||||
self.compute_final_size();
|
||||
|
||||
let mut turtle = self.turtles.last_mut().unwrap();
|
||||
|
|
@ -1883,13 +1760,8 @@ impl<'a, 'b> Cx2d<'a, 'b> {
|
|||
turtle = self.turtles.last_mut().unwrap();
|
||||
if let Some(max) = max {
|
||||
// take the margin into account when calculating a Fit bound
|
||||
// with a relative-to-parent max value. The floor keeps a
|
||||
// bound smaller than the margins (a near-zero line
|
||||
// remnant) from producing a negative width and an
|
||||
// inverted clip.
|
||||
turtle.width = turtle
|
||||
.width
|
||||
.min((max - turtle.walk.margin.width()).max(0.0));
|
||||
// with a relative-to-parent max value.
|
||||
turtle.width = turtle.width.min(max - turtle.walk.margin.width());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1926,7 +1798,6 @@ impl<'a, 'b> Cx2d<'a, 'b> {
|
|||
clip_max.y = clip_min.y + turtle.height();
|
||||
}
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
/// Computes the maximum available height for the current turtle by walking up
|
||||
|
|
@ -2058,8 +1929,6 @@ impl<'a, 'b> Cx2d<'a, 'b> {
|
|||
deferred_before_count: 0,
|
||||
outer_size: size + walk.margin.size(),
|
||||
metrics: walk.metrics,
|
||||
align_role: RowAlignRole::Shiftable,
|
||||
align_height: None,
|
||||
});
|
||||
|
||||
let origin = outer_origin + walk.margin.left_top();
|
||||
|
|
@ -2102,7 +1971,6 @@ impl<'a, 'b> Cx2d<'a, 'b> {
|
|||
}
|
||||
};
|
||||
|
||||
|
||||
let defer_index = self.turtle().deferred_fills.len();
|
||||
self.turtle_mut().current_row_metrics =
|
||||
self.turtle().current_row_metrics.max(walk.metrics);
|
||||
|
|
@ -2111,8 +1979,6 @@ impl<'a, 'b> Cx2d<'a, 'b> {
|
|||
deferred_before_count: defer_index,
|
||||
outer_size,
|
||||
metrics: walk.metrics,
|
||||
align_role: RowAlignRole::Shiftable,
|
||||
align_height: None,
|
||||
});
|
||||
|
||||
let origin = outer_origin + walk.margin.left_top();
|
||||
|
|
@ -2293,31 +2159,6 @@ impl<'a, 'b> Cx2d<'a, 'b> {
|
|||
rect: Rect,
|
||||
align_list_start: usize,
|
||||
metrics: Metrics,
|
||||
) {
|
||||
self.emit_turtle_walk_with_align_height(rect, align_list_start, metrics, None)
|
||||
}
|
||||
|
||||
/// Like [`emit_turtle_walk_with_metrics`] but also sets the walk's
|
||||
/// centering height for `RowAlign::Center` (see `FinishedWalk::align_height`).
|
||||
pub fn emit_turtle_walk_with_align_height(
|
||||
&mut self,
|
||||
rect: Rect,
|
||||
align_list_start: usize,
|
||||
metrics: Metrics,
|
||||
align_height: Option<f64>,
|
||||
) {
|
||||
self.emit_turtle_walk_with_role(rect, align_list_start, metrics, align_height, RowAlignRole::Shiftable)
|
||||
}
|
||||
|
||||
/// Like [`emit_turtle_walk_with_align_height`] but with an explicit
|
||||
/// [`RowAlignRole`].
|
||||
pub fn emit_turtle_walk_with_role(
|
||||
&mut self,
|
||||
rect: Rect,
|
||||
align_list_start: usize,
|
||||
metrics: Metrics,
|
||||
align_height: Option<f64>,
|
||||
align_role: RowAlignRole,
|
||||
) {
|
||||
let turtle = self.turtles.last().unwrap();
|
||||
self.finished_walks.push(FinishedWalk {
|
||||
|
|
@ -2325,8 +2166,6 @@ impl<'a, 'b> Cx2d<'a, 'b> {
|
|||
deferred_before_count: turtle.deferred_fills.len(),
|
||||
outer_size: rect.size,
|
||||
metrics,
|
||||
align_role,
|
||||
align_height,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -2378,20 +2217,7 @@ impl<'a, 'b> Cx2d<'a, 'b> {
|
|||
}
|
||||
|
||||
pub fn turtle_new_line_internal(&mut self, spacing: f64, align_list_start: usize) {
|
||||
let row_bottom_forgiveness = self.finish_row(align_list_start);
|
||||
if row_bottom_forgiveness > 0.0 {
|
||||
// An anchored row's up-centered walks overhang the row's anchor
|
||||
// symmetrically, and the top overhang already intrudes into the
|
||||
// gap above the row; forgiving the same amount below it keeps the
|
||||
// gaps on both sides of the row equal. The reduction happens only
|
||||
// on the new-line path — a turtle's final row is finished by
|
||||
// `end_turtle_with_guard`, which discards the forgiveness — so a
|
||||
// turtle's reported height always covers its last row's full
|
||||
// physical extent.
|
||||
let used_width = self.turtle().used_width();
|
||||
let reduced_used_height = self.turtle().used_height() - row_bottom_forgiveness;
|
||||
self.turtle_mut().set_used(used_width, reduced_used_height);
|
||||
}
|
||||
self.finish_row(align_list_start);
|
||||
let new_pos = dvec2(
|
||||
self.turtle().origin.x + self.turtle().padding().left,
|
||||
self.turtle().origin.y + self.turtle().used_height() + spacing,
|
||||
|
|
@ -2400,34 +2226,28 @@ impl<'a, 'b> Cx2d<'a, 'b> {
|
|||
self.turtle_mut().allocate_height(0.0);
|
||||
}
|
||||
|
||||
/// Finishes the current row: applies its row alignment and rolls the row
|
||||
/// bookkeeping forward.
|
||||
///
|
||||
/// Returns the row's bottom forgiveness (see [`Cx2d::finish_row_center`]);
|
||||
/// rows under `RowAlign::Top` and `RowAlign::Bottom` always return zero.
|
||||
fn finish_row(&mut self, align_list_start: usize) -> f64 {
|
||||
fn finish_row(&mut self, align_list_start: usize) {
|
||||
let row_align = if let Flow::Right { row_align, .. } = self.turtle().flow() {
|
||||
row_align
|
||||
} else {
|
||||
RowAlign::Top
|
||||
};
|
||||
|
||||
let row_bottom_forgiveness = match row_align {
|
||||
match row_align {
|
||||
RowAlign::Top => {
|
||||
// No per-walk shifts needed — items stay at the row top.
|
||||
0.0
|
||||
}
|
||||
RowAlign::Bottom => {
|
||||
self.finish_row_bottom(align_list_start);
|
||||
0.0
|
||||
}
|
||||
RowAlign::Center => self.finish_row_center(align_list_start),
|
||||
};
|
||||
RowAlign::Center => {
|
||||
self.finish_row_center(align_list_start);
|
||||
}
|
||||
}
|
||||
|
||||
self.turtle_mut().prev_row_metrics = self.turtle().current_row_metrics;
|
||||
self.turtle_mut().current_row_metrics = Metrics::default();
|
||||
self.finished_rows.push(self.finished_walks.len());
|
||||
row_bottom_forgiveness
|
||||
}
|
||||
|
||||
/// Baseline-aligns every finished walk in the current row so that its
|
||||
|
|
@ -2476,11 +2296,6 @@ impl<'a, 'b> Cx2d<'a, 'b> {
|
|||
let finished_walks_start = self.current_row_walks_start();
|
||||
let finished_walks_end = self.finished_walks.len();
|
||||
for finished_walk_index in finished_walks_start..finished_walks_end {
|
||||
// Immovable walks (a wrapped run's rows — their glyphs live in one
|
||||
// shared batch) cannot be baseline-shifted either.
|
||||
if self.finished_walks[finished_walk_index].align_role != RowAlignRole::Shiftable {
|
||||
continue;
|
||||
}
|
||||
let finished_walk_height = self.finished_walks[finished_walk_index].outer_size.y;
|
||||
let finished_walk_metrics = self.finished_walks[finished_walk_index].metrics;
|
||||
|
||||
|
|
@ -2495,7 +2310,6 @@ impl<'a, 'b> Cx2d<'a, 'b> {
|
|||
// The total amount by which we have to shift the current finished walk.
|
||||
let shift = descender_shift + baseline_shift + line_spacing_shift;
|
||||
|
||||
|
||||
let start = self.finished_walks[finished_walk_index].align_list_start;
|
||||
let end = if finished_walk_index + 1 < self.finished_walks.len() {
|
||||
self.finished_walks[finished_walk_index + 1].align_list_start
|
||||
|
|
@ -2529,93 +2343,28 @@ impl<'a, 'b> Cx2d<'a, 'b> {
|
|||
/// Vertically centers every finished walk in the current row on the row's
|
||||
/// vertical center line.
|
||||
///
|
||||
/// Without an anchor walk, the row centers on its tallest walk: shifts are
|
||||
/// downward only and every walk stays entirely within the row's bounds, so
|
||||
/// `used_height` is untouched. With an anchor walk (an immovable wrapped-run
|
||||
/// row), every shiftable walk centers on the anchor's center line instead,
|
||||
/// so a walk taller than the anchor shifts UP and overhangs the anchor's
|
||||
/// box symmetrically — by equal amounts above and below it.
|
||||
/// The row height is the max height of any walk on the row, so the tallest
|
||||
/// walk (e.g. an inline pill widget) has a zero shift and stays put. Shorter
|
||||
/// walks (e.g. surrounding text) are shifted down by half the difference,
|
||||
/// so their vertical centers land on the same horizontal line as the tallest
|
||||
/// walk's center. For symmetrically-padded pills this makes the pill's
|
||||
/// internal text visually align with the surrounding text.
|
||||
///
|
||||
/// An up-shift is clamped so that no walk's top rises above the turtle's
|
||||
/// own rectangle top: that edge is also the turtle's clip top, and content
|
||||
/// shifted above it renders with its top edge cut off. The clamp can only
|
||||
/// restrict an up-shift; it never turns one into a downward shift.
|
||||
///
|
||||
/// Returns the row's bottom forgiveness: how far the row's allocated
|
||||
/// bottom extent hangs below the bottom of its anchor-centered content,
|
||||
/// capped at twice the largest up-shift actually applied. The new-line
|
||||
/// path subtracts this from the advance to the next row, so a centered
|
||||
/// walk's symmetric overhang intrudes equally into the row gaps above and
|
||||
/// below it instead of pushing the next row further down. Anchorless rows
|
||||
/// return zero, which leaves the advance untouched.
|
||||
fn finish_row_center(&mut self, align_list_start: usize) -> f64 {
|
||||
/// This alignment does not grow the row's `used_height` — every walk stays
|
||||
/// entirely within the row's bounds because each shift is at most
|
||||
/// `(row_height - walk_height) / 2` and each walk has height
|
||||
/// `<= row_height`.
|
||||
fn finish_row_center(&mut self, align_list_start: usize) {
|
||||
let current_row_height = self.turtle().row_height();
|
||||
|
||||
let finished_walks_start = self.current_row_walks_start();
|
||||
let finished_walks_end = self.finished_walks.len();
|
||||
|
||||
// An anchor walk stands for content that cannot be shifted, so the
|
||||
// row's center line is anchored to its center rather than the tallest
|
||||
// walk's. Shiftable walks then move toward that line in either
|
||||
// direction: a taller item (an inline pill beside a wrapped run's
|
||||
// text row) moves UP to center on the text. Without an anchor, the
|
||||
// row centers on its tallest walk and shifts are downward only.
|
||||
let mut anchor_center: Option<f64> = None;
|
||||
for finished_walk_index in finished_walks_start..finished_walks_end {
|
||||
let finished_walk = &self.finished_walks[finished_walk_index];
|
||||
if finished_walk.align_role == RowAlignRole::Anchor {
|
||||
let center = finished_walk
|
||||
.align_height
|
||||
.unwrap_or(finished_walk.outer_size.y)
|
||||
* 0.5;
|
||||
anchor_center = Some(anchor_center.map_or(center, |c: f64| c.max(center)));
|
||||
}
|
||||
}
|
||||
let finished_walk_height = self.finished_walks[finished_walk_index].outer_size.y;
|
||||
let shift = (current_row_height - finished_walk_height) * 0.5;
|
||||
|
||||
|
||||
// The largest post-shift "effective bottom" of any walk on this row,
|
||||
// relative to the row top: the walk's own box displaced by the shift
|
||||
// that was actually applied to it. An up-shifted walk's bottom rises
|
||||
// by exactly its shift, so the row's visual extent ends that much
|
||||
// above its allocation, and only that surplus may be forgiven.
|
||||
let mut max_effective_bottom: f64 = 0.0;
|
||||
// The largest upward shift actually applied on this row; it bounds
|
||||
// the returned forgiveness so that allocations no walk accounts for
|
||||
// (pre-allocated text row boxes, vertical margins) are never forgiven.
|
||||
let mut max_up_overhang: f64 = 0.0;
|
||||
|
||||
for finished_walk_index in finished_walks_start..finished_walks_end {
|
||||
let finished_walk = &self.finished_walks[finished_walk_index];
|
||||
if finished_walk.align_role != RowAlignRole::Shiftable {
|
||||
max_effective_bottom = max_effective_bottom.max(finished_walk.outer_size.y);
|
||||
continue;
|
||||
}
|
||||
let finished_walk_height = finished_walk
|
||||
.align_height
|
||||
.unwrap_or(finished_walk.outer_size.y);
|
||||
let shift = match anchor_center {
|
||||
Some(center) => {
|
||||
// The row top is the turtle's position while the row
|
||||
// finishes; the walk's top after shifting is the row top
|
||||
// plus the shift. A negative bound keeps the walk's top at
|
||||
// or below the turtle's own rectangle top (its clip top);
|
||||
// the `min(0.0)` keeps the bound from ever forcing a
|
||||
// downward shift.
|
||||
let min_shift =
|
||||
(self.turtle().origin().y - self.turtle().pos().y).min(0.0);
|
||||
(center - finished_walk_height * 0.5).max(min_shift)
|
||||
}
|
||||
None => (current_row_height - finished_walk_height) * 0.5,
|
||||
};
|
||||
|
||||
let applied = !((anchor_center.is_none() && shift <= 0.0) || shift == 0.0);
|
||||
let applied_shift = if applied { shift } else { 0.0 };
|
||||
max_up_overhang = max_up_overhang.max((-applied_shift).max(0.0));
|
||||
max_effective_bottom =
|
||||
max_effective_bottom.max(finished_walk.outer_size.y + applied_shift);
|
||||
|
||||
|
||||
if !applied {
|
||||
if shift <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -2627,13 +2376,6 @@ impl<'a, 'b> Cx2d<'a, 'b> {
|
|||
};
|
||||
self.move_align_list(start, end, 0.0, shift, false);
|
||||
}
|
||||
|
||||
if anchor_center.is_none() {
|
||||
return 0.0;
|
||||
}
|
||||
let row_bottom_forgiveness = (current_row_height - max_effective_bottom.max(0.0))
|
||||
.clamp(0.0, max_up_overhang);
|
||||
row_bottom_forgiveness
|
||||
}
|
||||
|
||||
/// Shifts the rendered content in the align list range `[start, end)` by
|
||||
|
|
|
|||
|
|
@ -2,89 +2,6 @@ use makepad_svg::path::{LineCap, LineJoin, VectorPath};
|
|||
use makepad_svg::tessellate::{compute_clip_radii, Tessellator, VVertex};
|
||||
|
||||
pub const VECTOR_FLOATS_PER_VERTEX: usize = 19;
|
||||
/// Packed GPU layout: see `pack_vector_record` / VectorVertexPacked.
|
||||
pub const VECTOR_PACKED_FLOATS_PER_VERTEX: usize = 12;
|
||||
|
||||
#[inline]
|
||||
fn f16_bits(value: f32) -> u32 {
|
||||
// IEEE 754 binary16 encode (round-to-nearest-even, clamps to inf).
|
||||
let bits = value.to_bits();
|
||||
let sign = (bits >> 16) & 0x8000;
|
||||
let exp = ((bits >> 23) & 0xff) as i32;
|
||||
let frac = bits & 0x007f_ffff;
|
||||
if exp == 0xff {
|
||||
return sign | 0x7c00 | if frac != 0 { 0x200 } else { 0 };
|
||||
}
|
||||
let e = exp - 127 + 15;
|
||||
if e >= 0x1f {
|
||||
return sign | 0x7c00;
|
||||
}
|
||||
if e <= 0 {
|
||||
if e < -10 {
|
||||
return sign;
|
||||
}
|
||||
let frac = frac | 0x0080_0000;
|
||||
let shift = (14 - e) as u32;
|
||||
let half = frac >> shift;
|
||||
let rem = frac & ((1 << shift) - 1);
|
||||
let round = (rem > (1 << (shift - 1)))
|
||||
|| (rem == (1 << (shift - 1)) && (half & 1) != 0);
|
||||
return sign | (half + round as u32);
|
||||
}
|
||||
let half = ((e as u32) << 10) | (frac >> 13);
|
||||
let rem = frac & 0x1fff;
|
||||
let round = (rem > 0x1000) || (rem == 0x1000 && (half & 1) != 0);
|
||||
sign | (half + round as u32)
|
||||
}
|
||||
|
||||
/// Two floats into one f32 slot as an f16 pair; unpacked in-shader with
|
||||
/// `unpack2f16`. Public so other packed vertex layouts reuse this rounding
|
||||
/// rather than growing a second, subtly different implementation.
|
||||
#[inline]
|
||||
pub fn pack_pair_f16(a: f32, b: f32) -> f32 {
|
||||
f32::from_bits(f16_bits(a) | (f16_bits(b) << 16))
|
||||
}
|
||||
|
||||
/// Four 0..1 channels into one f32 slot as unorm8x4; unpacked in-shader
|
||||
/// with `unpack4u8`.
|
||||
#[inline]
|
||||
pub fn pack_unorm8x4(r: f32, g: f32, b: f32, a: f32) -> f32 {
|
||||
let q = |x: f32| (x.clamp(0.0, 1.0) * 255.0 + 0.5) as u32;
|
||||
f32::from_bits(q(r) | (q(g) << 8) | (q(b) << 16) | (q(a) << 24))
|
||||
}
|
||||
|
||||
/// One 19-float logical record -> the 12-slot packed layout.
|
||||
#[inline]
|
||||
pub fn pack_vector_record(record: &[f32]) -> [f32; VECTOR_PACKED_FLOATS_PER_VERTEX] {
|
||||
[
|
||||
record[0],
|
||||
record[1],
|
||||
pack_pair_f16(record[2], record[3]),
|
||||
pack_unorm8x4(record[4], record[5], record[6], record[7]),
|
||||
record[8],
|
||||
// stroke_dist stays f32: multi-km merged roads exceed f16 range
|
||||
// (inf -> NaN varyings) and dash phase needs the precision.
|
||||
record[9],
|
||||
pack_pair_f16(record[11], record[10]),
|
||||
pack_pair_f16(record[12], record[13]),
|
||||
// clip_radius clamped into f16 range: huge radii mean "never
|
||||
// clipped" either way.
|
||||
pack_pair_f16(record[14], record[17].min(60000.0)),
|
||||
record[15],
|
||||
record[16],
|
||||
record[18],
|
||||
]
|
||||
}
|
||||
|
||||
/// Pack a whole 19-stride vertex buffer for GPU upload.
|
||||
pub fn pack_vector_vertices(vertices: &[f32]) -> Vec<f32> {
|
||||
let count = vertices.len() / VECTOR_FLOATS_PER_VERTEX;
|
||||
let mut out = Vec::with_capacity(count * VECTOR_PACKED_FLOATS_PER_VERTEX);
|
||||
for record in vertices.chunks_exact(VECTOR_FLOATS_PER_VERTEX) {
|
||||
out.extend_from_slice(&pack_vector_record(record));
|
||||
}
|
||||
out
|
||||
}
|
||||
pub const VECTOR_ZBIAS_STEP: f32 = 0.000001;
|
||||
/// Selects DrawVector's signed-coordinate analytic fill fringe. Ordinary
|
||||
/// fills use `1e6`; a distinct sentinel lets the same vertex format carry a
|
||||
|
|
@ -257,21 +174,22 @@ pub fn append_expanded_stroke_geometry(
|
|||
let ramp = (total_dist * 0.35).min(96.0).max(1e-3);
|
||||
|
||||
let base = (acc_verts.len() / VECTOR_FLOATS_PER_VERTEX) as u32;
|
||||
let start = acc_verts.len();
|
||||
let floats = verts.len() * VECTOR_FLOATS_PER_VERTEX;
|
||||
// One resize + slot writes into the zeroed tail: the per-vertex
|
||||
// extend_from_slice of a stack array still re-checked capacity and
|
||||
// copied through a temporary 19 floats at a time — measurable on the
|
||||
// face/fringe path that now routes every morphable surface here.
|
||||
acc_verts.resize(start + floats, 0.0);
|
||||
let shape_id = params.shape_id + EXPAND_STROKE_SHAPE_OFFSET;
|
||||
let decked = deck_m > 0.0 || deck_override.is_some();
|
||||
for (vi, ((v, anchor), record)) in verts
|
||||
.iter()
|
||||
.zip(anchors)
|
||||
.zip(acc_verts[start..].chunks_exact_mut(VECTOR_FLOATS_PER_VERTEX))
|
||||
.enumerate()
|
||||
{
|
||||
for (vi, (v, anchor)) in verts.iter().zip(anchors).enumerate() {
|
||||
acc_verts.push(anchor[0]);
|
||||
acc_verts.push(anchor[1]);
|
||||
acc_verts.push(v.u);
|
||||
acc_verts.push(v.v);
|
||||
acc_verts.push(params.color[0]);
|
||||
acc_verts.push(params.color[1]);
|
||||
acc_verts.push(params.color[2]);
|
||||
acc_verts.push(params.color[3]);
|
||||
acc_verts.push(params.stroke_mult);
|
||||
acc_verts.push(v.stroke_dist);
|
||||
acc_verts.push(params.shape_id + EXPAND_STROKE_SHAPE_OFFSET);
|
||||
acc_verts.push(params.params[0]);
|
||||
acc_verts.push(v.x - anchor[0]);
|
||||
acc_verts.push(v.y - anchor[1]);
|
||||
acc_verts.push(expand_class);
|
||||
let deck_v = if let Some(decks) = deck_override {
|
||||
decks.get(vi).copied().unwrap_or(0.0)
|
||||
} else if deck_m > 0.0 {
|
||||
|
|
@ -281,36 +199,22 @@ pub fn append_expanded_stroke_geometry(
|
|||
} else {
|
||||
params.params[4]
|
||||
};
|
||||
acc_verts.push(deck_v);
|
||||
// A lifted deck is semantically ABOVE whatever it crosses: bump its
|
||||
// tilt micro-depth with the lift, or high-rank strokes underneath
|
||||
// (rail over secondary) still depth-win near the crossing.
|
||||
let param5 = if decked {
|
||||
acc_verts.push(if deck_m > 0.0 || deck_override.is_some() {
|
||||
params.params[5] + 0.30 * (deck_v / 2.0).min(1.0)
|
||||
} else {
|
||||
params.params[5]
|
||||
};
|
||||
record[0] = anchor[0];
|
||||
record[1] = anchor[1];
|
||||
record[2] = v.u;
|
||||
record[3] = v.v;
|
||||
record[4] = params.color[0];
|
||||
record[5] = params.color[1];
|
||||
record[6] = params.color[2];
|
||||
record[7] = params.color[3];
|
||||
record[8] = params.stroke_mult;
|
||||
record[9] = v.stroke_dist;
|
||||
record[10] = shape_id;
|
||||
record[11] = params.params[0];
|
||||
record[12] = v.x - anchor[0];
|
||||
record[13] = v.y - anchor[1];
|
||||
record[14] = expand_class;
|
||||
record[15] = deck_v;
|
||||
record[16] = param5;
|
||||
record[17] = v.clip_radius;
|
||||
record[18] = params.zbias;
|
||||
});
|
||||
acc_verts.push(v.clip_radius);
|
||||
acc_verts.push(params.zbias);
|
||||
}
|
||||
|
||||
acc_indices.extend(indices.iter().map(|&idx| base + idx));
|
||||
for &idx in indices {
|
||||
acc_indices.push(base + idx);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn append_tessellated_geometry(
|
||||
|
|
@ -340,39 +244,37 @@ pub fn append_tessellated_geometry_decked(
|
|||
}
|
||||
|
||||
let base = (acc_verts.len() / VECTOR_FLOATS_PER_VERTEX) as u32;
|
||||
acc_verts.reserve(verts.len() * VECTOR_FLOATS_PER_VERTEX);
|
||||
for (vi, v) in verts.iter().enumerate() {
|
||||
let deck_v = match deck_override {
|
||||
Some(decks) => decks.get(vi).copied().unwrap_or(0.0),
|
||||
None => params.params[4],
|
||||
};
|
||||
let param5 = if deck_v > 0.0 {
|
||||
acc_verts.push(v.x);
|
||||
acc_verts.push(v.y);
|
||||
acc_verts.push(v.u);
|
||||
acc_verts.push(v.v);
|
||||
acc_verts.push(params.color[0]);
|
||||
acc_verts.push(params.color[1]);
|
||||
acc_verts.push(params.color[2]);
|
||||
acc_verts.push(params.color[3]);
|
||||
acc_verts.push(params.stroke_mult);
|
||||
acc_verts.push(v.stroke_dist);
|
||||
acc_verts.push(params.shape_id);
|
||||
acc_verts.push(params.params[0]);
|
||||
acc_verts.push(params.params[1]);
|
||||
acc_verts.push(params.params[2]);
|
||||
acc_verts.push(params.params[3]);
|
||||
acc_verts.push(deck_v);
|
||||
acc_verts.push(if deck_v > 0.0 {
|
||||
params.params[5] + 0.30 * (deck_v / 2.0).min(1.0)
|
||||
} else {
|
||||
params.params[5]
|
||||
};
|
||||
acc_verts.extend_from_slice(&[
|
||||
v.x,
|
||||
v.y,
|
||||
v.u,
|
||||
v.v,
|
||||
params.color[0],
|
||||
params.color[1],
|
||||
params.color[2],
|
||||
params.color[3],
|
||||
params.stroke_mult,
|
||||
v.stroke_dist,
|
||||
params.shape_id,
|
||||
params.params[0],
|
||||
params.params[1],
|
||||
params.params[2],
|
||||
params.params[3],
|
||||
deck_v,
|
||||
param5,
|
||||
v.clip_radius,
|
||||
params.zbias,
|
||||
]);
|
||||
});
|
||||
acc_verts.push(v.clip_radius);
|
||||
acc_verts.push(params.zbias);
|
||||
}
|
||||
|
||||
acc_indices.extend(indices.iter().map(|&idx| base + idx));
|
||||
for &idx in indices {
|
||||
acc_indices.push(base + idx);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,15 +6,8 @@ app_main!(App);
|
|||
|
||||
script_mod! {
|
||||
use mod.prelude.widgets.*
|
||||
let state = {
|
||||
counter: 0
|
||||
}
|
||||
mod.state = state
|
||||
startup() do #(App::script_component(vm)){
|
||||
ui: Root{
|
||||
on_startup:||{ // right now render isnt called automatically yet
|
||||
ui.main_view.render()
|
||||
}
|
||||
main_window := Window{
|
||||
window.inner_size: vec2(420, 220)
|
||||
body +: {
|
||||
|
|
@ -24,11 +17,9 @@ script_mod! {
|
|||
flow: Down
|
||||
spacing: 12
|
||||
align: Center
|
||||
on_render: ||{
|
||||
counter_label := Label{
|
||||
text: "Count: " + state.counter
|
||||
draw_text.text_style.font_size: 24
|
||||
}
|
||||
counter_label := Label{
|
||||
text: "Count: 0"
|
||||
draw_text.text_style.font_size: 24
|
||||
}
|
||||
}
|
||||
increment_button := Button{
|
||||
|
|
@ -44,15 +35,15 @@ script_mod! {
|
|||
pub struct App {
|
||||
#[live]
|
||||
ui: WidgetRef,
|
||||
#[rust]
|
||||
counter: i32,
|
||||
}
|
||||
|
||||
impl MatchEvent for App {
|
||||
fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) {
|
||||
if self.ui.button(cx, ids!(increment_button)).clicked(actions) {
|
||||
script_eval!(cx,{
|
||||
mod.state.counter += 1
|
||||
ui.main_view.render()
|
||||
});
|
||||
self.counter += 1;
|
||||
self.ui.label(cx, ids!(counter_label)).set_text(cx, &format!("Count: {}", self.counter));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,3 @@ makepad-widgets = { path = "../../widgets", features = ["maps", "pdf", "voice"]
|
|||
makepad-code-editor = { path = "../../code_editor" }
|
||||
makepad-ai = { path = "../../libs/makepad_ai" }
|
||||
makepad-converse = { path = "../../libs/converse" }
|
||||
makepad-game-sim = { path = "../../libs/game/sim" }
|
||||
makepad-game-render = { path = "../../libs/game/render" }
|
||||
makepad-game-blocks = { path = "../../libs/game/blocks" }
|
||||
makepad-game-script = { path = "../../libs/game/script" }
|
||||
|
|
|
|||
|
|
@ -1,72 +0,0 @@
|
|||
// Speedway — a complete 4-car race built only from blocks.
|
||||
// The engine drives the cars, tracks the laps and sorts the standings; this
|
||||
// file is layout and rules. Compare with sandbox3d.splash, where every actor
|
||||
// hand-rolled its own physics and AI.
|
||||
|
||||
let LAPS = 3
|
||||
let TRACK = [
|
||||
vec3(0, 0, -60), vec3(34, 0, -52), vec3(52, 0, -30), vec3(56, 0, 0),
|
||||
vec3(52, 0, 30), vec3(34, 0, 52), vec3(0, 0, 60), vec3(-34, 0, 52),
|
||||
vec3(-52, 0, 30), vec3(-56, 0, 0), vec3(-52, 0, -30), vec3(-34, 0, -52),
|
||||
]
|
||||
let COLORS = [#xe8594f, #x4f8fe8, #x4fe87a, #xe8d24f]
|
||||
let NAMES = ["You", "Rosa", "Kim", "Bex"]
|
||||
|
||||
game.sky({})
|
||||
game.terrain({size: 260, cells: 81, smooth: true, seed: 4, freq: 0.006, amp: 3,
|
||||
plaza: {r: 120, ramp: 20, h: 0}, color: #x6b8f4a})
|
||||
|
||||
// Track surface: a slab under every corner, plus barriers on the outside.
|
||||
for p in TRACK {
|
||||
game.box({pos: vec3(p.x, 0.1, p.z), size: vec3(22, 0.2, 22), color: #x3b3f47, collide: false})
|
||||
let out = p.normalized()
|
||||
game.box({pos: vec3(out.x * 15 + p.x, 1.2, out.z * 15 + p.z),
|
||||
size: vec3(6, 2.4, 6), color: #xd8d8d8, tag: "wall"})
|
||||
}
|
||||
|
||||
// Start grid + gates. One spawnpoint and one checkpoint per corner.
|
||||
for i in 0..TRACK.len() {
|
||||
game.checkpoint({pos: vec3(TRACK[i].x, 3, TRACK[i].z), size: vec3(11, 6, 11)})
|
||||
}
|
||||
for i in 0..4 {
|
||||
game.spawnpoint({pos: vec3(-6 + i * 4, 1.5, -60 + (i % 2) * 6), yaw: 0})
|
||||
}
|
||||
|
||||
// Four cars: seat 0 is the player, the rest follow the racing line.
|
||||
let cars = []
|
||||
for i in 0..4 {
|
||||
let car = game.car({color: COLORS[i], player: i == 0, top_speed: 26 - i * 0.5})
|
||||
game.place(car, i)
|
||||
game.label(car, NAMES[i])
|
||||
if i > 0 {
|
||||
game.autodrive(car, {points: TRACK, pace: 0.86 + i * 0.03})
|
||||
}
|
||||
cars.push(car)
|
||||
}
|
||||
game.camera({chase: cars[0], height: 3.2, boom: 13, pitch: -0.22, lag: 0.25})
|
||||
game.race({laps: LAPS})
|
||||
|
||||
let done = false
|
||||
game.on_tick(|dt, input| {
|
||||
// Standings board, leader first.
|
||||
let board = ""
|
||||
let order = game.standings()
|
||||
for i in 0..order.len() {
|
||||
let s = order[i]
|
||||
board = board + (i + 1) + ". " + game.tag(s.entity) + " lap " + min(s.lap + 1, LAPS) + "/" + LAPS + "\n"
|
||||
}
|
||||
game.text("board", board, {anchor: "top_left"})
|
||||
game.bar("speed", game.speed(cars[0]) / 26, {anchor: "bottom"})
|
||||
|
||||
if !done && game.finished(cars[0]) {
|
||||
done = true
|
||||
game.text("center", "P" + game.rank(cars[0]) + " — press R to race again", {})
|
||||
game.jingle("C5 E5 G5 C6", 140)
|
||||
}
|
||||
if input.reset_pressed {
|
||||
done = false
|
||||
game.text("center", "", {})
|
||||
for i in 0..4 { game.place(cars[i], i) }
|
||||
game.race({laps: LAPS})
|
||||
}
|
||||
})
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
// Wanderhome — the scenery counterpart to racing.splash.
|
||||
//
|
||||
// racing.splash is the model answer for GAMEPLAY: the engine drives the cars
|
||||
// and counts the laps, so the file is layout and rules. This one is the model
|
||||
// answer for a PLACE: the engine lays the streets, picks the artwork and makes
|
||||
// it solid, so the file is intent.
|
||||
//
|
||||
// Every prop here is a real model from the stock library. Nothing is a
|
||||
// coloured box, and no model id is written by hand — they all come back from
|
||||
// game.find_model, which returns DIFFERENT models rather than one repeated.
|
||||
|
||||
let SEED = 11
|
||||
let ROADS = "kenney/city-kit-roads"
|
||||
let HOUSES = "kenney/city-kit-suburban"
|
||||
|
||||
game.sky({})
|
||||
game.sun({time_of_day: 8.0})
|
||||
|
||||
// ---- the town: streets with buildings fronting them --------------------
|
||||
// Junction types are never named here — a crossing produces a crossroad and a
|
||||
// tee produces a T, purely from how the grid connects.
|
||||
let town = game.town({
|
||||
roads_kit: ROADS,
|
||||
buildings_kit: HOUSES,
|
||||
extent: 18,
|
||||
block: 6,
|
||||
density: 0.7,
|
||||
seed: SEED,
|
||||
})
|
||||
game.log("town laid")
|
||||
|
||||
// ---- a wood around it --------------------------------------------------
|
||||
// Four DIFFERENT conifers, not one conifer four times: `count` is what stops
|
||||
// a wood looking stamped.
|
||||
let trees = game.find_model("pine tree", {count: 4, seed: SEED})
|
||||
let rocks = game.find_model("rock stone", {count: 3, spread: "variants", seed: SEED})
|
||||
|
||||
for i in 0..40 {
|
||||
let a = i * 0.157
|
||||
let r = 46.0 + (i % 7) * 2.5
|
||||
game.model(trees[i % 4], {
|
||||
pos: vec3(cos(a) * r, 0.0, sin(a) * r),
|
||||
yaw: a * 2.0,
|
||||
})
|
||||
}
|
||||
for i in 0..9 {
|
||||
let a = i * 0.7
|
||||
game.model(rocks[i % 3], {
|
||||
pos: vec3(cos(a) * 38.0, 0.0, sin(a) * 38.0),
|
||||
yaw: a,
|
||||
})
|
||||
}
|
||||
|
||||
// ---- somewhere to go ---------------------------------------------------
|
||||
// The same generator gives a crypt, a cavern or a space station depending
|
||||
// only on the kit; every room is reachable by construction.
|
||||
let crypt = game.dungeon({
|
||||
kit: "kenney/modular-dungeon-kit",
|
||||
extent: 20,
|
||||
min_room: 5,
|
||||
seed: SEED,
|
||||
})
|
||||
game.log("crypt laid")
|
||||
|
||||
// ---- someone to be -----------------------------------------------------
|
||||
let hero = game.character({
|
||||
pos: vec3(0, 2, 0),
|
||||
size: vec3(0.6, 1.7, 0.6),
|
||||
color: #x4f8fe8,
|
||||
speed: 6.0,
|
||||
})
|
||||
game.camera({third_person: hero, height: 1.6, boom: 9, pitch: -0.3})
|
||||
game.text("hint", "Arrows to walk — find the crypt", {anchor: "top"})
|
||||
|
||||
game.on_tick(|dt, input| {
|
||||
game.drive(hero, {move_x: input.move_x, move_z: input.move_z, jump: input.jump})
|
||||
})
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1427,7 +1427,6 @@ impl AppMain for App {
|
|||
vm.bx.captured_errors = Some(Vec::new());
|
||||
crate::makepad_widgets::script_mod(vm);
|
||||
crate::makepad_code_editor::script_mod(vm);
|
||||
makepad_game_render::script_mod(vm);
|
||||
crate::game_view::script_mod(vm);
|
||||
let value = self::script_mod(vm);
|
||||
let errors = vm.take_errors();
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
{"version":1,"agent_id":[1],"title":"Chat 1","backend_id":"openai_localhost","active":true,"status":"ready","pending":false,"updated_at":1785223053.212714,"messages":[],"history":[]}
|
||||
|
|
@ -108,7 +108,7 @@ script_mod! {
|
|||
min_zoom: 3.0
|
||||
mbtiles_path: "local/maps/europe-shortbread.mbtiles"
|
||||
detail_mbtiles_path: "local/maps/europe-osm-detail.mbtiles"
|
||||
bridge_dz_mbtiles_path: "local/maps/nl-bridge-dz.mbtiles"
|
||||
bridge_dz_mbtiles_path: "local/maps/ams-bridge-dz.mbtiles"
|
||||
buildings_3d: true
|
||||
// shiny.md: baked AO/shadows/water/foliage ride
|
||||
// the widget theme defaults; the showcase app
|
||||
|
|
|
|||
|
|
@ -4,600 +4,11 @@ script_mod! {
|
|||
use mod.prelude.widgets.*
|
||||
use mod.widgets.*
|
||||
|
||||
// Mirrors a chat client's room-list message preview: bold sender, text,
|
||||
// and a mention pill whose rounded background overdraws its layout rect
|
||||
// through negative padding, all inside a RowAlign.Center wrapping flow.
|
||||
let ReproPreview = View{
|
||||
height: Fit, show_bg: true, draw_bg +: {color: #333}
|
||||
Html{
|
||||
width: Fill, height: Fit
|
||||
max_lines: 2
|
||||
text_overflow: Ellipsis
|
||||
font_size: 9.3
|
||||
flow: Flow.Right{wrap: true, row_align: RowAlign.Center}
|
||||
align: Align{ y: 0.5 }
|
||||
text_style_normal +: { font_size: 9.3, line_spacing: 1.32 }
|
||||
text_style_bold +: { font_size: 9.3, line_spacing: 1.32 }
|
||||
pill := View {
|
||||
width: Fit, height: Fit,
|
||||
RoundedView {
|
||||
width: Fit, height: Fit,
|
||||
flow: Right,
|
||||
align: Align{ y: 0.5 }
|
||||
spacing: 1,
|
||||
padding: Inset{ left: 4.5, right: 3.0, bottom: -3.5, top: -3.5 }
|
||||
margin: Inset{ top: 1, right: 1 }
|
||||
show_bg: true,
|
||||
draw_bg +: { color: #000, border_radius: 4.5 }
|
||||
RoundedView {
|
||||
width: 13, height: 13,
|
||||
show_bg: true,
|
||||
draw_bg +: { color: #1fc7a8, border_radius: 6.5 }
|
||||
}
|
||||
Label {
|
||||
flow: Right,
|
||||
draw_text +: {
|
||||
color: #f,
|
||||
text_style +: { font_size: 8.5, line_spacing: 1.0 }
|
||||
}
|
||||
text: "Sam Carter",
|
||||
}
|
||||
}
|
||||
}
|
||||
body: "<b>Riley Hayes</b>: a message with one pill <pill></pill>"
|
||||
}
|
||||
}
|
||||
|
||||
// Mirrors a chat timeline message: pills and short text runs mixed in a
|
||||
// RowAlign.Center wrapping flow with no line clamp. The trailing "hello"
|
||||
// follows a pill, so at narrow widths it wraps as a continuation whose
|
||||
// first row is empty — the case where a row starts with regular text.
|
||||
let TimelineRepro = View{
|
||||
height: Fit, show_bg: true, draw_bg +: {color: #333}
|
||||
Html{
|
||||
width: Fill, height: Fit
|
||||
font_size: 9.3
|
||||
flow: Flow.Right{wrap: true, row_align: RowAlign.Center}
|
||||
align: Align{ y: 0.5 }
|
||||
text_style_normal +: { font_size: 9.3, line_spacing: 1.32 }
|
||||
text_style_bold +: { font_size: 9.3, line_spacing: 1.32 }
|
||||
pill := View {
|
||||
width: Fit, height: Fit,
|
||||
RoundedView {
|
||||
width: Fit, height: Fit,
|
||||
flow: Right,
|
||||
align: Align{ y: 0.5 }
|
||||
spacing: 1,
|
||||
padding: Inset{ left: 4.5, right: 3.0, bottom: -3.5, top: -3.5 }
|
||||
margin: Inset{ top: 1, right: 1 }
|
||||
show_bg: true,
|
||||
draw_bg +: { color: #000, border_radius: 4.5 }
|
||||
RoundedView {
|
||||
width: 13, height: 13,
|
||||
show_bg: true,
|
||||
draw_bg +: { color: #1fc7a8, border_radius: 6.5 }
|
||||
}
|
||||
Label {
|
||||
flow: Right,
|
||||
draw_text +: {
|
||||
color: #f,
|
||||
text_style +: { font_size: 8.5, line_spacing: 1.0 }
|
||||
}
|
||||
text: "Jordan Lee",
|
||||
}
|
||||
}
|
||||
}
|
||||
body: "<pill></pill> @ <pill></pill> <pill></pill> <pill></pill> hello <pill></pill> <pill></pill>"
|
||||
}
|
||||
}
|
||||
|
||||
// Same shape at chat-timeline metrics: font 11 with 1.3 line spacing
|
||||
// and the default pill geometry (avatar 16, padding -3/-3, no top margin).
|
||||
// At these metrics the text advance and the pill's outer height differ,
|
||||
// unlike the 9.3pt preview metrics where they coincide.
|
||||
let TimelineRepro11 = View{
|
||||
height: Fit, show_bg: true, draw_bg +: {color: #333}
|
||||
Html{
|
||||
width: Fill, height: Fit
|
||||
font_size: 11
|
||||
flow: Flow.Right{wrap: true, row_align: RowAlign.Center}
|
||||
align: Align{ y: 0.5 }
|
||||
text_style_normal +: { font_size: 11, line_spacing: 1.3 }
|
||||
text_style_bold +: { font_size: 11, line_spacing: 1.3 }
|
||||
pill := View {
|
||||
width: Fit, height: Fit,
|
||||
RoundedView {
|
||||
width: Fit, height: Fit,
|
||||
flow: Right,
|
||||
align: Align{ y: 0.5 }
|
||||
spacing: 1,
|
||||
padding: Inset{ left: 6, right: 4, bottom: -3, top: -3 }
|
||||
margin: Inset{ right: 1 }
|
||||
show_bg: true,
|
||||
draw_bg +: { color: #000, border_radius: 6.0 }
|
||||
RoundedView {
|
||||
width: 16, height: 16,
|
||||
show_bg: true,
|
||||
draw_bg +: { color: #1fc7a8, border_radius: 8.0 }
|
||||
}
|
||||
Label {
|
||||
flow: Right,
|
||||
draw_text +: {
|
||||
color: #f,
|
||||
text_style +: { font_size: 11, line_spacing: 1.0 }
|
||||
}
|
||||
text: "Jordan Lee",
|
||||
}
|
||||
}
|
||||
}
|
||||
// A pill whose title falls back to the CJK font, whose line
|
||||
// metrics differ from the Latin font's.
|
||||
cjk := View {
|
||||
width: Fit, height: Fit,
|
||||
RoundedView {
|
||||
width: Fit, height: Fit,
|
||||
flow: Right,
|
||||
align: Align{ y: 0.5 }
|
||||
spacing: 1,
|
||||
padding: Inset{ left: 6, right: 4, bottom: -3, top: -3 }
|
||||
margin: Inset{ right: 1 }
|
||||
show_bg: true,
|
||||
draw_bg +: { color: #000, border_radius: 6.0 }
|
||||
RoundedView {
|
||||
width: 16, height: 16,
|
||||
show_bg: true,
|
||||
draw_bg +: { color: #cccccc, border_radius: 8.0 }
|
||||
}
|
||||
Label {
|
||||
flow: Right,
|
||||
draw_text +: {
|
||||
color: #f,
|
||||
text_style +: { font_size: 11, line_spacing: 1.0 }
|
||||
}
|
||||
text: "王小明",
|
||||
}
|
||||
}
|
||||
}
|
||||
red := View {
|
||||
width: Fit, height: Fit,
|
||||
RoundedView {
|
||||
width: Fit, height: Fit,
|
||||
flow: Right,
|
||||
align: Align{ y: 0.5 }
|
||||
spacing: 1,
|
||||
padding: Inset{ left: 6, right: 4, bottom: -3, top: -3 }
|
||||
margin: Inset{ right: 1 }
|
||||
show_bg: true,
|
||||
draw_bg +: { color: #e4335a, border_radius: 6.0 }
|
||||
RoundedView {
|
||||
width: 16, height: 16,
|
||||
show_bg: true,
|
||||
draw_bg +: { color: #1fc7a8, border_radius: 8.0 }
|
||||
}
|
||||
Label {
|
||||
flow: Right,
|
||||
draw_text +: {
|
||||
color: #f,
|
||||
text_style +: { font_size: 11, line_spacing: 1.0 }
|
||||
}
|
||||
text: "Riley Hayes",
|
||||
}
|
||||
}
|
||||
}
|
||||
body: "<cjk></cjk> @ <pill></pill> <pill></pill> <red></red> hello <pill></pill> <pill></pill>"
|
||||
}
|
||||
}
|
||||
|
||||
// D1 repro: zero content padding (a chat timeline's message body) and a
|
||||
// FIRST row holds both a pill and the anchored first row of a wrapped text
|
||||
// run. Centering the pill on the anchor lifts it above the widget's clip
|
||||
// top unless the up-shift is clamped, which renders the pill's rounded top
|
||||
// as a flat cut edge.
|
||||
let TimelineReproD1 = View{
|
||||
height: Fit, show_bg: true, draw_bg +: {color: #333}
|
||||
Html{
|
||||
width: Fill, height: Fit
|
||||
font_size: 11
|
||||
padding: 0.0
|
||||
flow: Flow.Right{wrap: true, row_align: RowAlign.Center}
|
||||
align: Align{ y: 0.5 }
|
||||
text_style_normal +: { font_size: 11, line_spacing: 1.3 }
|
||||
text_style_bold +: { font_size: 11, line_spacing: 1.3 }
|
||||
cjk := View {
|
||||
width: Fit, height: Fit,
|
||||
RoundedView {
|
||||
width: Fit, height: Fit,
|
||||
flow: Right,
|
||||
align: Align{ y: 0.5 }
|
||||
spacing: 1,
|
||||
padding: Inset{ left: 6, right: 4, bottom: -3, top: -3 }
|
||||
margin: Inset{ right: 1 }
|
||||
show_bg: true,
|
||||
draw_bg +: { color: #000, border_radius: 6.0 }
|
||||
RoundedView {
|
||||
width: 16, height: 16,
|
||||
show_bg: true,
|
||||
draw_bg +: { color: #cccccc, border_radius: 8.0 }
|
||||
}
|
||||
Label {
|
||||
flow: Right,
|
||||
draw_text +: {
|
||||
color: #f,
|
||||
text_style +: { font_size: 11, line_spacing: 1.0 }
|
||||
}
|
||||
text: "王小明",
|
||||
}
|
||||
}
|
||||
}
|
||||
body: "<cjk></cjk> hello there this is a longer message that wraps"
|
||||
}
|
||||
}
|
||||
|
||||
mod.widgets.DemoHtml = UIZooTabLayout_B{
|
||||
desc +: {
|
||||
Markdown{body: "# Html\n\nThe Html widget renders HTML content."}
|
||||
}
|
||||
demos +: {
|
||||
H4{text: "Fit-max bounded pill labels at widths across the cap"}
|
||||
P{text: "A pill-like label bounded to 60% of the enclosing width, at widths walking across the cap. Truncation must always show a trailing ellipsis, never a bare cut."}
|
||||
View{
|
||||
width: Fill, height: Fit, flow: Down, spacing: 6
|
||||
View{ width: 300, height: Fit, show_bg: true, draw_bg +: {color: #333}
|
||||
View{ width: Fit, height: Fit,
|
||||
View{ width: Fit, height: Fit,
|
||||
RoundedView { width: Fit, height: Fit, flow: Right, align: Align{ y: 0.5 }, spacing: 1,
|
||||
padding: Inset{ left: 6, right: 4, bottom: -3, top: -3 }
|
||||
margin: Inset{ right: 1 }
|
||||
show_bg: true, draw_bg +: { color: #000, border_radius: 6.0 }
|
||||
RoundedView { width: 16, height: 16, show_bg: true, draw_bg +: { color: #1fc7a8, border_radius: 8.0 } }
|
||||
Label {
|
||||
width: Fit{max: FitBound.Rel{base: Base.Full, factor: 0.6}},
|
||||
max_lines: 1, text_overflow: Ellipsis,
|
||||
draw_text +: { color: #f, text_style +: { font_size: 11, line_spacing: 1.0 } }
|
||||
text: "@somebody-with-a-long-name:example.org" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
View{ width: 220, height: Fit, show_bg: true, draw_bg +: {color: #333}
|
||||
View{ width: Fit, height: Fit,
|
||||
View{ width: Fit, height: Fit,
|
||||
RoundedView { width: Fit, height: Fit, flow: Right, align: Align{ y: 0.5 }, spacing: 1,
|
||||
padding: Inset{ left: 6, right: 4, bottom: -3, top: -3 }
|
||||
margin: Inset{ right: 1 }
|
||||
show_bg: true, draw_bg +: { color: #000, border_radius: 6.0 }
|
||||
RoundedView { width: 16, height: 16, show_bg: true, draw_bg +: { color: #1fc7a8, border_radius: 8.0 } }
|
||||
Label {
|
||||
width: Fit{max: FitBound.Rel{base: Base.Full, factor: 0.6}},
|
||||
max_lines: 1, text_overflow: Ellipsis,
|
||||
draw_text +: { color: #f, text_style +: { font_size: 11, line_spacing: 1.0 } }
|
||||
text: "@somebody-with-a-long-name:example.org" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
View{ width: 160, height: Fit, show_bg: true, draw_bg +: {color: #333}
|
||||
View{ width: Fit, height: Fit,
|
||||
View{ width: Fit, height: Fit,
|
||||
RoundedView { width: Fit, height: Fit, flow: Right, align: Align{ y: 0.5 }, spacing: 1,
|
||||
padding: Inset{ left: 6, right: 4, bottom: -3, top: -3 }
|
||||
margin: Inset{ right: 1 }
|
||||
show_bg: true, draw_bg +: { color: #000, border_radius: 6.0 }
|
||||
RoundedView { width: 16, height: 16, show_bg: true, draw_bg +: { color: #1fc7a8, border_radius: 8.0 } }
|
||||
Label {
|
||||
width: Fit{max: FitBound.Rel{base: Base.Full, factor: 0.6}},
|
||||
max_lines: 1, text_overflow: Ellipsis,
|
||||
draw_text +: { color: #f, text_style +: { font_size: 11, line_spacing: 1.0 } }
|
||||
text: "@quokka:example.org" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
View{ width: 160, height: Fit, show_bg: true, draw_bg +: {color: #333}
|
||||
Html{ width: Fill, height: Fit, font_size: 11
|
||||
flow: Flow.Right{wrap: true, row_align: RowAlign.Center}
|
||||
text_style_normal +: { font_size: 11, line_spacing: 1.3 }
|
||||
pill := View { width: Fit, height: Fit,
|
||||
RoundedView { width: Fit, height: Fit, flow: Right, align: Align{ y: 0.5 }, spacing: 1,
|
||||
padding: Inset{ left: 6, right: 4, bottom: -3, top: -3 }
|
||||
margin: Inset{ right: 1 }
|
||||
show_bg: true, draw_bg +: { color: #000, border_radius: 6.0 }
|
||||
RoundedView { width: 16, height: 16, show_bg: true, draw_bg +: { color: #1fc7a8, border_radius: 8.0 } }
|
||||
Label {
|
||||
width: Fit{max: FitBound.Rel{base: Base.Full, factor: 0.6}},
|
||||
max_lines: 1, text_overflow: Ellipsis,
|
||||
draw_text +: { color: #f, text_style +: { font_size: 11, line_spacing: 1.0 } }
|
||||
text: "@somebody-with-a-long-name:example.org" }
|
||||
}
|
||||
}
|
||||
body: "hi <pill></pill> ok"
|
||||
}
|
||||
}
|
||||
}
|
||||
Hr{}
|
||||
|
||||
H4{text: "Line-bounded pill labels (Base.Line)"}
|
||||
P{text: "Pill labels bounded by the line width actually available to the pill. A long name ellipsizes at the line edge; a mid-line pill relocates whole to the next row; a pill held on the last clamped row squeezes into the remnant and stays visible."}
|
||||
View{
|
||||
width: Fill, height: Fit, flow: Down, spacing: 6
|
||||
// A long name in a narrow container: ellipsis lands near the
|
||||
// container's right edge, not at a fixed fraction of it.
|
||||
View{ width: 160, height: Fit, show_bg: true, draw_bg +: {color: #333}
|
||||
View{ width: Fit, height: Fit,
|
||||
View{ width: Fit, height: Fit,
|
||||
RoundedView { width: Fit, height: Fit, flow: Right, align: Align{ y: 0.5 }, spacing: 1,
|
||||
padding: Inset{ left: 6, right: 4, bottom: -3, top: -3 }
|
||||
margin: Inset{ right: 1 }
|
||||
show_bg: true, draw_bg +: { color: #000, border_radius: 6.0 }
|
||||
RoundedView { width: 16, height: 16, show_bg: true, draw_bg +: { color: #1fc7a8, border_radius: 8.0 } }
|
||||
Label {
|
||||
width: Fit{max: FitBound.Rel{base: Base.Line, factor: 1.0}},
|
||||
max_lines: 1, text_overflow: Ellipsis,
|
||||
draw_text +: { color: #f, text_style +: { font_size: 11, line_spacing: 1.0 } }
|
||||
text: "@somebody-with-a-long-name:example.org" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// A long-name pill mid-line in a wrapping flow: the pill
|
||||
// relocates whole to its own row and ellipsizes at the row
|
||||
// edge; the text before and after stays intact.
|
||||
View{ width: 200, height: Fit, show_bg: true, draw_bg +: {color: #333}
|
||||
Html{ width: Fill, height: Fit, font_size: 11
|
||||
flow: Flow.Right{wrap: true, row_align: RowAlign.Center}
|
||||
text_style_normal +: { font_size: 11, line_spacing: 1.3 }
|
||||
pill := View { width: Fit, height: Fit,
|
||||
RoundedView { width: Fit, height: Fit, flow: Right, align: Align{ y: 0.5 }, spacing: 1,
|
||||
padding: Inset{ left: 6, right: 4, bottom: -3, top: -3 }
|
||||
margin: Inset{ right: 1 }
|
||||
show_bg: true, draw_bg +: { color: #000, border_radius: 6.0 }
|
||||
RoundedView { width: 16, height: 16, show_bg: true, draw_bg +: { color: #1fc7a8, border_radius: 8.0 } }
|
||||
Label {
|
||||
width: Fit{max: FitBound.Rel{base: Base.Line, factor: 1.0}},
|
||||
max_lines: 1, text_overflow: Ellipsis,
|
||||
draw_text +: { color: #f, text_style +: { font_size: 11, line_spacing: 1.0 } }
|
||||
text: "@somebody-with-a-long-name:example.org" }
|
||||
}
|
||||
}
|
||||
body: "hi <pill></pill> ok"
|
||||
}
|
||||
}
|
||||
// A long-name pill landing on the LAST permitted row of a
|
||||
// clamped flow: the line clamp holds it in place, so the
|
||||
// title squeezes into the row remnant and the pill remains
|
||||
// visible with its own ellipsis, not hidden behind the
|
||||
// flow's.
|
||||
View{ width: 210, height: Fit, show_bg: true, draw_bg +: {color: #333}
|
||||
Html{ width: Fill, height: Fit, font_size: 11
|
||||
max_lines: 2
|
||||
text_overflow: Ellipsis
|
||||
flow: Flow.Right{wrap: true, row_align: RowAlign.Center}
|
||||
text_style_normal +: { font_size: 11, line_spacing: 1.3 }
|
||||
pill := View { width: Fit, height: Fit,
|
||||
RoundedView { width: Fit, height: Fit, flow: Right, align: Align{ y: 0.5 }, spacing: 1,
|
||||
padding: Inset{ left: 6, right: 4, bottom: -3, top: -3 }
|
||||
margin: Inset{ right: 1 }
|
||||
show_bg: true, draw_bg +: { color: #000, border_radius: 6.0 }
|
||||
RoundedView { width: 16, height: 16, show_bg: true, draw_bg +: { color: #1fc7a8, border_radius: 8.0 } }
|
||||
Label {
|
||||
width: Fit{max: FitBound.Rel{base: Base.Line, factor: 1.0}},
|
||||
max_lines: 1, text_overflow: Ellipsis,
|
||||
draw_text +: { color: #f, text_style +: { font_size: 11, line_spacing: 1.0 } }
|
||||
text: "@somebody-with-a-long-name:example.org" }
|
||||
}
|
||||
}
|
||||
body: "one two three four five six seven <pill></pill>"
|
||||
}
|
||||
}
|
||||
// A short name in a wide container fits fully inline: the
|
||||
// line bound must not truncate a name the line can hold.
|
||||
View{ width: 400, height: Fit, show_bg: true, draw_bg +: {color: #333}
|
||||
Html{ width: Fill, height: Fit, font_size: 11
|
||||
flow: Flow.Right{wrap: true, row_align: RowAlign.Center}
|
||||
text_style_normal +: { font_size: 11, line_spacing: 1.3 }
|
||||
pill := View { width: Fit, height: Fit,
|
||||
RoundedView { width: Fit, height: Fit, flow: Right, align: Align{ y: 0.5 }, spacing: 1,
|
||||
padding: Inset{ left: 6, right: 4, bottom: -3, top: -3 }
|
||||
margin: Inset{ right: 1 }
|
||||
show_bg: true, draw_bg +: { color: #000, border_radius: 6.0 }
|
||||
RoundedView { width: 16, height: 16, show_bg: true, draw_bg +: { color: #1fc7a8, border_radius: 8.0 } }
|
||||
Label {
|
||||
width: Fit{max: FitBound.Rel{base: Base.Line, factor: 1.0}},
|
||||
max_lines: 1, text_overflow: Ellipsis,
|
||||
draw_text +: { color: #f, text_style +: { font_size: 11, line_spacing: 1.0 } }
|
||||
text: "@quokka:example.org" }
|
||||
}
|
||||
}
|
||||
body: "hi <pill></pill> ok"
|
||||
}
|
||||
}
|
||||
}
|
||||
Hr{}
|
||||
|
||||
H4{text: "REPRO: timeline message, pills + text, no line clamp"}
|
||||
P{text: "Rows starting with regular text after a pill-heavy row must keep the text and the pills on one center line, pill tops must never be cut, and inter-line spacing must be uniform."}
|
||||
View{
|
||||
width: Fill, height: Fit, flow: Down, spacing: 6
|
||||
TimelineRepro11 { width: 420 }
|
||||
TimelineRepro11 { width: 310 }
|
||||
TimelineRepro11 { width: 240 }
|
||||
TimelineRepro { width: 260 }
|
||||
TimelineRepro { width: 200 }
|
||||
TimelineReproD1 { width: 240 }
|
||||
}
|
||||
Hr{}
|
||||
|
||||
H4{text: "REPRO: sender + wrapped text + trailing pill, chat-preview style"}
|
||||
P{text: "Bold sender, small text with 1.32 line spacing, RowAlign.Center, and a pill whose background overdraws its layout rect via negative padding. Five widths walk the pill from inline, to wrapped, to sharing a row with wrapped text, to overrunning the last line."}
|
||||
View{
|
||||
width: Fill, height: Fit, flow: Down, spacing: 6
|
||||
ReproPreview { width: 280 }
|
||||
ReproPreview { width: 230 }
|
||||
ReproPreview { width: 190 }
|
||||
ReproPreview { width: 170 }
|
||||
ReproPreview { width: 150 }
|
||||
ReproPreview { width: 130 }
|
||||
}
|
||||
Hr{}
|
||||
|
||||
H4{text: "Html with ellipsis, inline code, and a narrow width (2 lines)"}
|
||||
P{text: "An inline code span too wide for the remaining room wraps to its own line. That wrap must consume one of the two allowed lines, so the text below must never exceed two rows at any window width."}
|
||||
View{
|
||||
width: Fill, height: Fit, flow: Down, spacing: 4
|
||||
View{ width: 230, height: Fit, show_bg: true, draw_bg +: {color: #333}
|
||||
Html{ width: Fill height: Fit max_lines: 2 text_overflow: Ellipsis
|
||||
body: "Sam Carter: and <code><details></code> / <code><summary></code> is fully working (see: https://example.com/doc/inline-code-demo for the full writeup)" } }
|
||||
View{ width: 260, height: Fit, show_bg: true, draw_bg +: {color: #333}
|
||||
Html{ width: Fill height: Fit max_lines: 2 text_overflow: Ellipsis
|
||||
body: "Sam Carter: and <code><details></code> / <code><summary></code> is fully working (see: https://example.com/doc/inline-code-demo for the full writeup)" } }
|
||||
View{ width: 320, height: Fit, show_bg: true, draw_bg +: {color: #333}
|
||||
Html{ width: Fill height: Fit max_lines: 2 text_overflow: Ellipsis
|
||||
body: "Sam Carter: and <code><details></code> / <code><summary></code> is fully working (see: https://example.com/doc/inline-code-demo for the full writeup)" } }
|
||||
View{ width: 410, height: Fit, show_bg: true, draw_bg +: {color: #333}
|
||||
Html{ width: Fill height: Fit max_lines: 2 text_overflow: Ellipsis
|
||||
body: "Sam Carter: and <code><details></code> / <code><summary></code> is fully working (see: https://example.com/doc/inline-code-demo for the full writeup)" } }
|
||||
}
|
||||
H4{text: "Html with an atomic inline widget at max_lines 2"}
|
||||
P{text: "An inline widget too wide for the room left on its row is relocated whole onto a new row. That row counts against max_lines like any other, and once the budget is spent no further widget may draw."}
|
||||
View{
|
||||
width: Fill, height: Fit, flow: Down, spacing: 4
|
||||
View{ width: 250, height: Fit, show_bg: true, draw_bg +: {color: #333}
|
||||
Html{ width: Fill height: Fit max_lines: 2 text_overflow: Ellipsis
|
||||
pill := RoundedView{
|
||||
width: Fit, height: Fit, flow: Right
|
||||
padding: Inset{left: 6, right: 6, top: 1, bottom: 1}
|
||||
margin: Inset{left: 2, right: 2}
|
||||
show_bg: true, draw_bg +: { color: #4488cc, border_radius: 6.0 }
|
||||
Label{ flow: Right, draw_text +: { color: #ffffff }, text: "@quill:example.org" }
|
||||
}
|
||||
body: "hey <pill></pill> and <pill></pill> plus a good deal more text that has to be clamped" }
|
||||
}
|
||||
View{ width: 300, height: Fit, show_bg: true, draw_bg +: {color: #333}
|
||||
Html{ width: Fill height: Fit max_lines: 2 text_overflow: Ellipsis
|
||||
pill := RoundedView{
|
||||
width: Fit, height: Fit, flow: Right
|
||||
padding: Inset{left: 6, right: 6, top: 1, bottom: 1}
|
||||
margin: Inset{left: 2, right: 2}
|
||||
show_bg: true, draw_bg +: { color: #4488cc, border_radius: 6.0 }
|
||||
Label{ flow: Right, draw_text +: { color: #ffffff }, text: "@quill:example.org" }
|
||||
}
|
||||
body: "hey <pill></pill> and <pill></pill> plus a good deal more text that has to be clamped" }
|
||||
}
|
||||
}
|
||||
|
||||
H4{text: "Text that fills both lines, then a mention pill"}
|
||||
P{text: "The worst realistic case: the text before the pill ends on the last allowed line without being cut, so nothing has truncated yet, and the pill then does not fit in what is left of that line."}
|
||||
View{
|
||||
width: Fill, height: Fit, flow: Down, spacing: 4
|
||||
View{ width: 250, height: Fit, show_bg: true, draw_bg +: {color: #333}
|
||||
Html{ width: Fill height: Fit max_lines: 2 text_overflow: Ellipsis
|
||||
pill := RoundedView{
|
||||
width: Fit, height: Fit, flow: Right
|
||||
padding: Inset{left: 6, right: 6, top: 1, bottom: 1}
|
||||
margin: Inset{left: 2, right: 2}
|
||||
show_bg: true, draw_bg +: { color: #4488cc, border_radius: 6.0 }
|
||||
Label{ flow: Right, draw_text +: { color: #ffffff }, text: "@quill:example.org" }
|
||||
}
|
||||
body: "Thanks for the review, that all makes sense, so I am assigning it to <pill></pill>" }
|
||||
}
|
||||
View{ width: 250, height: Fit, show_bg: true, draw_bg +: {color: #333}
|
||||
Html{ width: Fill height: Fit max_lines: 2 text_overflow: Ellipsis
|
||||
pill := RoundedView{
|
||||
width: Fit, height: Fit, flow: Right
|
||||
padding: Inset{left: 6, right: 6, top: 1, bottom: 1}
|
||||
margin: Inset{left: 2, right: 2}
|
||||
show_bg: true, draw_bg +: { color: #4488cc, border_radius: 6.0 }
|
||||
Label{ flow: Right, draw_text +: { color: #ffffff }, text: "@quill:example.org" }
|
||||
}
|
||||
body: "Thanks for the review and all of your detailed comments, that all makes sense to me, so I am going to assign it over to <pill></pill> later today" }
|
||||
}
|
||||
}
|
||||
|
||||
H4{text: "A mention pill inside a <summary>"}
|
||||
P{text: "The summary line opens with a fold button, so the same overrun applies there: the pill charges its row like any other inline widget."}
|
||||
View{
|
||||
width: 250, height: Fit, show_bg: true, draw_bg +: {color: #333}
|
||||
Html{ width: Fill height: Fit max_lines: 2 text_overflow: Ellipsis
|
||||
pill := RoundedView{
|
||||
width: Fit, height: Fit, flow: Right
|
||||
padding: Inset{left: 6, right: 6, top: 1, bottom: 1}
|
||||
margin: Inset{left: 2, right: 2}
|
||||
show_bg: true, draw_bg +: { color: #4488cc, border_radius: 6.0 }
|
||||
Label{ flow: Right, draw_text +: { color: #ffffff }, text: "@quill:example.org" }
|
||||
}
|
||||
body: "<details><summary>a pill inside a summary <pill></pill></summary>hidden body content</details>" }
|
||||
}
|
||||
|
||||
H4{text: "Consecutive inline widgets with no text between them"}
|
||||
P{text: "Nothing but widgets, so no text run follows to notice the rows they open; each widget charges its own row instead. On the last allowed row wrapping is switched off, so the widget that no longer fits is held there and clipped rather than opening a third row, and the ones after it are dropped."}
|
||||
View{
|
||||
width: 250, height: Fit, show_bg: true, draw_bg +: {color: #333}
|
||||
Html{ width: Fill height: Fit max_lines: 2 text_overflow: Ellipsis
|
||||
pill := RoundedView{
|
||||
width: Fit, height: Fit, flow: Right
|
||||
padding: Inset{left: 6, right: 6, top: 1, bottom: 1}
|
||||
margin: Inset{left: 2, right: 2}
|
||||
show_bg: true, draw_bg +: { color: #4488cc, border_radius: 6.0 }
|
||||
Label{ flow: Right, draw_text +: { color: #ffffff }, text: "@quill:example.org" }
|
||||
}
|
||||
body: "<pill></pill><pill></pill><pill></pill><pill></pill><pill></pill><pill></pill>" }
|
||||
}
|
||||
|
||||
H4{text: "Inline widget wider than the whole line"}
|
||||
P{text: "Left: an unbounded pill overflows the container, because relocating it to a fresh row still does not make it fit. Right: bounding its label to a fraction of the enclosing width keeps the pill inside the line and ellipsizes the name."}
|
||||
View{
|
||||
width: Fill, height: Fit, flow: Down, spacing: 4
|
||||
View{ width: 250, height: Fit, show_bg: true, draw_bg +: {color: #333}
|
||||
Html{ width: Fill height: Fit max_lines: 2 text_overflow: Ellipsis
|
||||
pill := RoundedView{
|
||||
width: Fit, height: Fit, flow: Right
|
||||
padding: Inset{left: 6, right: 6, top: 1, bottom: 1}
|
||||
margin: Inset{left: 2, right: 2}
|
||||
show_bg: true, draw_bg +: { color: #4488cc, border_radius: 6.0 }
|
||||
Label{ flow: Right, draw_text +: { color: #ffffff }
|
||||
text: "@a-really-long-display-name-that-cannot-fit:example.org" }
|
||||
}
|
||||
body: "hey <pill></pill> and some trailing words" }
|
||||
}
|
||||
View{ width: 250, height: Fit, show_bg: true, draw_bg +: {color: #333}
|
||||
Html{ width: Fill height: Fit max_lines: 2 text_overflow: Ellipsis
|
||||
pill := RoundedView{
|
||||
width: Fit, height: Fit, flow: Right
|
||||
padding: Inset{left: 6, right: 6, top: 1, bottom: 1}
|
||||
margin: Inset{left: 2, right: 2}
|
||||
show_bg: true, draw_bg +: { color: #4488cc, border_radius: 6.0 }
|
||||
Label{ flow: Right, draw_text +: { color: #ffffff }
|
||||
width: Fit{max: FitBound.Rel{base: Base.Full, factor: 0.6}}
|
||||
max_lines: 1, text_overflow: Ellipsis
|
||||
text: "@a-really-long-display-name-that-cannot-fit:example.org" }
|
||||
}
|
||||
body: "hey <pill></pill> and some trailing words" }
|
||||
}
|
||||
}
|
||||
|
||||
H4{text: "Html list with max_lines 3"}
|
||||
P{text: "A list item's bullet and its first line of text share one visual line, so three allowed lines must show three items."}
|
||||
View{
|
||||
width: 320, height: Fit, show_bg: true, draw_bg +: {color: #333}
|
||||
Html{ width: Fill height: Fit max_lines: 3 text_overflow: Ellipsis
|
||||
body: "<ul><li>alpha</li><li>beta</li><li>gamma</li><li>delta</li></ul>" }
|
||||
}
|
||||
|
||||
H4{text: "Non-wrapping Label: max_lines 1 + Ellipsis"}
|
||||
P{text: "A single-line, non-wrapping Label overflows sideways rather than onto extra rows, so its ellipsis must still appear when max_lines is also set."}
|
||||
View{
|
||||
width: Fill, height: Fit, flow: Down, spacing: 4
|
||||
View{ width: 230, height: Fit, show_bg: true, draw_bg +: {color: #333}
|
||||
Label{ width: Fill, height: Fit, flow: Flow.Right{wrap: false}, padding: 0
|
||||
max_lines: 1, text_overflow: Ellipsis
|
||||
text: "A label whose text is far too long to fit on one line" } }
|
||||
View{ width: 230, height: Fit, show_bg: true, draw_bg +: {color: #333}
|
||||
Label{ width: Fill, height: Fit, flow: Flow.Right{wrap: false}, padding: 0
|
||||
text_overflow: Ellipsis
|
||||
text: "Same label with ellipsis but no max_lines set" } }
|
||||
}
|
||||
Hr{}
|
||||
|
||||
Html{
|
||||
width: Fill height: Fit
|
||||
body: "<H1>H1 Headline</H1><H2>H2 Headline</H2><H3>H3 Headline</H3><H4>H4 Headline</H4><H5>H5 Headline</H5><H6>H6 Headline</H6>This is <b>bold</b> and <i>italic text</i>.<sep><b><i>Bold italic</i></b>, <u>underlined</u>, and <s>strike through</s> text. <p>This is a paragraph</p> <code>A code block</code>. <br/> And this is a <a href='https://www.google.com/'>link</a><br/><ul><li>lorem</li><li>ipsum</li><li>dolor</li></ul><ol><li>lorem</li><li>ipsum</li><li>dolor</li></ol><br/> <blockquote>Blockquote</blockquote> <pre>pre</pre><sub>sub</sub><del>del</del>"
|
||||
|
|
@ -623,19 +34,6 @@ script_mod! {
|
|||
body: "The <b>quick brown fox</b> jumps over the <i>lazy dog</i>. Pack my box with <b><i>five dozen</i></b> liquor jugs. How <u>vexingly quick</u> daft zebras jump! The five boxing wizards jump quickly. Sphinx of black quartz, judge my vow. Two driven jocks help fax my big quiz."
|
||||
}
|
||||
|
||||
Hr{}
|
||||
H4{text: "Html with ellipsis, inline code, and a narrow width (2 lines)"}
|
||||
P{text: "An inline code span too wide for the remaining room wraps to its own line. That wrap must consume one of the two allowed lines, so the text below must never exceed two rows at any window width."}
|
||||
View{
|
||||
width: 320, height: Fit
|
||||
Html{
|
||||
width: Fill height: Fit
|
||||
max_lines: 2
|
||||
text_overflow: Ellipsis
|
||||
body: "Sam Carter: and <code><details></code> / <code><summary></code> is fully working (see: https://example.com/doc/inline-code-demo for the full writeup)"
|
||||
}
|
||||
}
|
||||
|
||||
Hr{}
|
||||
H4{text: "Html with ellipsis and emoji (1 line)"}
|
||||
P{text: "Html with multi-byte emoji mixed into styled text."}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,3 @@ edition = "2021"
|
|||
|
||||
[dependencies]
|
||||
makepad-widgets = { path = "../../widgets", version = "2.0.0" }
|
||||
robius-file-picker = "0.3"
|
||||
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
robius-android-env = { version = "0.2", features = ["makepad"] }
|
||||
|
|
|
|||
|
|
@ -22,9 +22,6 @@ pub use makepad_widgets;
|
|||
|
||||
use makepad_widgets::*;
|
||||
|
||||
mod picker;
|
||||
use picker::{pick_local_video, PickedMediaAction};
|
||||
|
||||
app_main!(App);
|
||||
|
||||
// A public HLS (m3u8) adaptive-bitrate stream. The Video widget's backend plays HLS natively
|
||||
|
|
@ -137,13 +134,7 @@ script_mod! {
|
|||
width: 64
|
||||
height: Fit
|
||||
text: "Pause"
|
||||
draw_bg.color: #x00000099
|
||||
}
|
||||
|
||||
open_button := Button{
|
||||
width: 56
|
||||
height: Fit
|
||||
text: "Open"
|
||||
show_bg: true
|
||||
draw_bg.color: #x00000099
|
||||
}
|
||||
|
||||
|
|
@ -224,6 +215,7 @@ script_mod! {
|
|||
width: 52
|
||||
height: Fit
|
||||
text: "1.0x"
|
||||
show_bg: true
|
||||
draw_bg.color: #x00000099
|
||||
}
|
||||
|
||||
|
|
@ -231,6 +223,7 @@ script_mod! {
|
|||
width: 56
|
||||
height: Fit
|
||||
text: "Full"
|
||||
show_bg: true
|
||||
draw_bg.color: #x00000099
|
||||
}
|
||||
}
|
||||
|
|
@ -308,8 +301,6 @@ pub struct VideoPlayer {
|
|||
tick_started: bool,
|
||||
#[rust(false)]
|
||||
media_started: bool,
|
||||
#[rust]
|
||||
pending_source: Option<String>,
|
||||
}
|
||||
|
||||
impl Widget for VideoPlayer {
|
||||
|
|
@ -333,7 +324,6 @@ impl Widget for VideoPlayer {
|
|||
// "no gesture -> hide" rule takes over (armed from show_controls_bar / gesture paths).
|
||||
}
|
||||
if self.tick_timer.is_event(event).is_some() {
|
||||
self.try_open_pending(cx);
|
||||
self.refresh_progress(cx);
|
||||
}
|
||||
if self.hud_timer.is_event(event).is_some() {
|
||||
|
|
@ -633,39 +623,6 @@ impl VideoPlayer {
|
|||
self.view(cx, ids!(hud)).set_visible(cx, false);
|
||||
self.redraw(cx);
|
||||
}
|
||||
|
||||
/// Open a new path, URL, or `content://` URI (e.g. from the system picker).
|
||||
fn request_open_media(&mut self, cx: &mut Cx, src: &str) {
|
||||
let src = src.trim();
|
||||
if src.is_empty() {
|
||||
return;
|
||||
}
|
||||
let video = self.video(cx, ids!(video));
|
||||
if video.is_unprepared() {
|
||||
play_media(cx, &video, src);
|
||||
self.paused = false;
|
||||
self.button(cx, ids!(play_button)).set_text(cx, "Pause");
|
||||
self.show_controls_bar(cx);
|
||||
return;
|
||||
}
|
||||
self.pending_source = Some(src.to_string());
|
||||
video.stop_and_cleanup_resources(cx);
|
||||
self.show_controls_bar(cx);
|
||||
}
|
||||
|
||||
fn try_open_pending(&mut self, cx: &mut Cx) {
|
||||
let Some(src) = self.pending_source.clone() else {
|
||||
return;
|
||||
};
|
||||
let video = self.video(cx, ids!(video));
|
||||
if !video.is_unprepared() {
|
||||
return;
|
||||
}
|
||||
self.pending_source = None;
|
||||
play_media(cx, &video, &src);
|
||||
self.paused = false;
|
||||
self.button(cx, ids!(play_button)).set_text(cx, "Pause");
|
||||
}
|
||||
}
|
||||
|
||||
/// Points a [`Video`] widget at a media source given as a plain string, auto-detecting:
|
||||
|
|
@ -677,16 +634,19 @@ impl VideoPlayer {
|
|||
/// This is the "plugin" entry point: callers just pass a path or URL and the right
|
||||
/// [`VideoDataSource`] is chosen and applied.
|
||||
fn play_media(cx: &mut Cx, video: &VideoRef, src: &str) {
|
||||
let Some((path, is_network)) = parse_media_ref(src) else {
|
||||
return;
|
||||
};
|
||||
let lower_path = path.split(['?', '#']).next().unwrap_or(&path).to_ascii_lowercase();
|
||||
let lower_path = src.split(['?', '#']).next().unwrap_or(src).to_ascii_lowercase();
|
||||
let is_manifest = lower_path.ends_with(".m3u8") || lower_path.ends_with(".mpd");
|
||||
|
||||
let is_network = src.starts_with("http://")
|
||||
|| src.starts_with("https://")
|
||||
|| src.starts_with("file://");
|
||||
|
||||
// Both HLS/DASH manifests and progressive files go through Network when given a URL; a local
|
||||
// path uses Filesystem. (Local .m3u8 is unusual and not handled specially here.)
|
||||
let source = if is_network {
|
||||
VideoDataSource::Network { url: path }
|
||||
VideoDataSource::Network { url: src.to_string() }
|
||||
} else {
|
||||
VideoDataSource::Filesystem { path }
|
||||
VideoDataSource::Filesystem { path: src.to_string() }
|
||||
};
|
||||
|
||||
let kind = if is_manifest { "HLS/DASH manifest" } else { "progressive file" };
|
||||
|
|
@ -697,30 +657,6 @@ fn play_media(cx: &mut Cx, video: &VideoRef, src: &str) {
|
|||
video.begin_playback(cx);
|
||||
}
|
||||
|
||||
/// Parse a filesystem path, `file://` / `content://` URI, or `http(s)://` URL.
|
||||
fn parse_media_ref(src: &str) -> Option<(String, bool)> {
|
||||
let src = src.trim();
|
||||
if src.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let is_network = src.starts_with("http://") || src.starts_with("https://");
|
||||
if let Some(rest) = src.strip_prefix("file://") {
|
||||
let rest = rest
|
||||
.strip_prefix('/')
|
||||
.filter(|r| r.as_bytes().get(1) == Some(&b':'))
|
||||
.unwrap_or(rest);
|
||||
return Some((rest.to_string(), false));
|
||||
}
|
||||
if src.starts_with("content://") {
|
||||
return Some((src.to_string(), false));
|
||||
}
|
||||
if is_network {
|
||||
Some((src.to_string(), true))
|
||||
} else {
|
||||
Some((src.to_string(), false))
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes the target scrub position for a horizontal seek drag.
|
||||
///
|
||||
/// Bilibili-style sensitivity: a full-width drag scrubs at most ~120s (or the whole clip, if
|
||||
|
|
@ -764,20 +700,6 @@ pub struct App {
|
|||
|
||||
impl MatchEvent for App {
|
||||
fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) {
|
||||
for action in actions {
|
||||
if let Some(picked) = action.downcast_ref::<PickedMediaAction>() {
|
||||
if let Some(err) = picked.error.as_ref() {
|
||||
error!("video_player: open file failed: {err}");
|
||||
continue;
|
||||
}
|
||||
let Some(path) = picked.path_or_uri.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
if let Some(mut vp) = self.ui.widget(cx, ids!(player)).borrow_mut::<VideoPlayer>() {
|
||||
vp.request_open_media(cx, path);
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.ui.button(cx, ids!(play_button)).clicked(actions) {
|
||||
if let Some(mut vp) = self.ui.widget(cx, ids!(player)).borrow_mut::<VideoPlayer>() {
|
||||
vp.toggle_play_pause(cx);
|
||||
|
|
@ -790,12 +712,6 @@ impl MatchEvent for App {
|
|||
vp.show_controls_bar(cx);
|
||||
}
|
||||
}
|
||||
if self.ui.button(cx, ids!(open_button)).clicked(actions) {
|
||||
pick_local_video();
|
||||
if let Some(mut vp) = self.ui.widget(cx, ids!(player)).borrow_mut::<VideoPlayer>() {
|
||||
vp.show_controls_bar(cx);
|
||||
}
|
||||
}
|
||||
if self.ui.button(cx, ids!(fullscreen_button)).clicked(actions) {
|
||||
self.is_fullscreen = !self.is_fullscreen;
|
||||
let window = self.ui.window(cx, ids!(main_window));
|
||||
|
|
@ -820,18 +736,6 @@ impl AppMain for App {
|
|||
}
|
||||
|
||||
fn handle_event(&mut self, cx: &mut Cx, event: &Event) {
|
||||
if let Event::Drop(drop) = event {
|
||||
for item in drop.items.iter() {
|
||||
if let DragItem::FilePath { path, .. } = item {
|
||||
if let Some(mut vp) =
|
||||
self.ui.widget(cx, ids!(player)).borrow_mut::<VideoPlayer>()
|
||||
{
|
||||
vp.request_open_media(cx, path);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.match_event(cx, event);
|
||||
self.ui.handle_event(cx, event, &mut Scope::empty());
|
||||
}
|
||||
|
|
@ -895,20 +799,4 @@ mod tests {
|
|||
// Zero duration is a no-op.
|
||||
assert_eq!(scrub_target_ms(5_000, w, w, 0), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_media_ref_handles_content_and_file_urls() {
|
||||
assert_eq!(
|
||||
parse_media_ref("content://media/external/video/media/42"),
|
||||
Some(("content://media/external/video/media/42".into(), false))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_media_ref("file:///C:/Videos/clip.mp4"),
|
||||
Some(("C:/Videos/clip.mp4".into(), false))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_media_ref("https://example.com/x.m3u8"),
|
||||
Some(("https://example.com/x.m3u8".into(), true))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
//! Native file picker via `robius-file-picker` (desktop + Android).
|
||||
|
||||
use makepad_widgets::*;
|
||||
use robius_file_picker::FileDialog;
|
||||
|
||||
/// Posted from the picker callback onto the UI action queue.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct PickedMediaAction {
|
||||
/// `None` means cancelled or picker error (see `error`).
|
||||
pub path_or_uri: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
fn video_file_filters() -> Vec<(String, Vec<String>)> {
|
||||
vec![
|
||||
(
|
||||
"Video".into(),
|
||||
vec![
|
||||
"mp4".into(),
|
||||
"mkv".into(),
|
||||
"webm".into(),
|
||||
"avi".into(),
|
||||
"mov".into(),
|
||||
"flv".into(),
|
||||
"ts".into(),
|
||||
"m4v".into(),
|
||||
"wmv".into(),
|
||||
"mpg".into(),
|
||||
"mpeg".into(),
|
||||
"m3u8".into(),
|
||||
],
|
||||
),
|
||||
("All Files".into(), vec!["*".into()]),
|
||||
]
|
||||
}
|
||||
|
||||
/// Open the native video/file picker. Result arrives as [`PickedMediaAction`].
|
||||
pub fn pick_local_video() {
|
||||
let mut dialog = FileDialog::new().set_title("Open Video");
|
||||
dialog = dialog.set_filters(video_file_filters());
|
||||
let result = dialog.pick_video(|result| {
|
||||
let action = match result {
|
||||
Ok(Some(file)) => {
|
||||
let path_or_uri = file
|
||||
.path()
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.or_else(|| file.uri().map(|u| u.to_string()));
|
||||
if let Some(ref s) = path_or_uri {
|
||||
log!("video_player: picked {}", s);
|
||||
}
|
||||
PickedMediaAction {
|
||||
path_or_uri,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
Ok(None) => PickedMediaAction {
|
||||
path_or_uri: None,
|
||||
error: None,
|
||||
},
|
||||
Err(e) => {
|
||||
error!("video_player: pick error: {e}");
|
||||
PickedMediaAction {
|
||||
path_or_uri: None,
|
||||
error: Some(e.to_string()),
|
||||
}
|
||||
}
|
||||
};
|
||||
Cx::post_action(action);
|
||||
});
|
||||
if let Err(e) = result {
|
||||
error!("video_player: failed to show file dialog: {e}");
|
||||
Cx::post_action(PickedMediaAction {
|
||||
path_or_uri: None,
|
||||
error: Some(e.to_string()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
# append ip:port lines — nodes join the running spiral within 30s
|
||||
10.0.0.169:8384
|
||||
10.0.0.217:8384
|
||||
10.0.0.123:8384
|
||||
10.0.0.100:8384
|
||||
10.0.0.203:8384
|
||||
10.0.0.235:8384
|
||||
10.0.0.166:8384
|
||||
10.0.0.162:8384
|
||||
10.0.0.160:8384
|
||||
130
glass.md
Normal file
130
glass.md
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
# Apple Glass UI Shader Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Add first-class glass UI surfaces that can tint, refract, highlight, and blur the scene behind them without each widget inventing its own offscreen path. The first implementation lives in `Window`: each window owns a `GaussStack`, and overlay widgets request access to that stack through a generic API. Longer term, the same request/capture model can become a backend-level hookable renderflow.
|
||||
|
||||
## Current State
|
||||
|
||||
- `widgets/src/glass_panel.rs` already defines a `GlassPanel` style with tint, border, specular, noise, and a `use_scene_blur` knob, but it does not sample a real blurred scene texture yet.
|
||||
- UI rendering is pass-based. `platform/src/os/cx_shared.rs::compute_pass_repaint_order` builds `passes_todo`, and each backend renders that ordered list to a window or texture. It now logs the pass count when the count changes.
|
||||
- Views can create child texture passes through `ViewOptimize::Texture`, but that is a widget-local cache, not a frame-level background blur stack.
|
||||
- `Window` can split a frame into an offscreen scene capture plus root-pass overlays when an overlay widget requests glass.
|
||||
- The immediate problem for real glass is that a shader cannot reliably sample the current framebuffer underneath itself while drawing in the same render pass. We need renderflow-owned intermediate textures.
|
||||
|
||||
## Window-Owned Architecture
|
||||
|
||||
The first slice keeps the renderflow local to `Window` so we can validate behavior before changing every backend:
|
||||
|
||||
1. `Window` owns a `GaussStack`.
|
||||
2. A generic `request_window_gauss(cx)` API records that the current window has an overlay needing the gauss mipchain.
|
||||
3. The next redraw captures normal window contents into the stack's high-resolution scene pass.
|
||||
4. Overlay drawlists are still collected by the window overlay, but are assigned to the root window pass while the scene is captured.
|
||||
5. `Window::end()` builds the mip chain, draws the sharp scene back into the root pass, then appends overlays.
|
||||
6. Glass widgets bind the scene texture plus mip textures if the stack is active; otherwise they draw a fallback material.
|
||||
|
||||
This keeps `Window` free of specific widget types. It only asks whether the window has a gauss request, and it exposes textures through a generic snapshot.
|
||||
|
||||
## Future Renderflow Hook
|
||||
|
||||
Initial hook points:
|
||||
|
||||
- `before_pass(draw_pass_id)`: allocate or update resources before a UI pass is rendered.
|
||||
- `after_pass(draw_pass_id)`: capture pass output or schedule dependent passes.
|
||||
- `before_present(window_pass_id)`: composite final effects before presenting the window.
|
||||
- `resize_or_dpi_changed(window_pass_id, size, dpi)`: invalidate per-window blur resources.
|
||||
|
||||
The default renderflow hook would do nothing, so existing apps keep the same behavior. The current `Window` implementation is the prototype for the hook contract.
|
||||
|
||||
## Glass Blur Stack
|
||||
|
||||
The `GaussStack` owns per-window blur resources:
|
||||
|
||||
- `scene_texture`: the resolved color output before glass overlays.
|
||||
- `mip_textures`: a chain of downsampled textures for larger blur radii.
|
||||
- `scene_pass`: the high-resolution drawpass that captures normal content.
|
||||
- `mip_passes`: drawpasses that build the pyramid.
|
||||
- `uniforms`: blur radius, saturation, tint, refraction, noise seed, and scale.
|
||||
|
||||
The first implementation uses a conservative full-window mip stack:
|
||||
|
||||
1. Render normal content into an offscreen scene texture.
|
||||
2. Downsample scene texture into a mip pyramid.
|
||||
3. Draw the unblurred scene texture back into the root window pass.
|
||||
4. Draw overlay widgets that sample the scene texture and mip textures in screen coordinates.
|
||||
5. Present the composed final pass.
|
||||
|
||||
Once stable, optimize by scissoring or tiling around glass rectangles.
|
||||
|
||||
## Widget Integration
|
||||
|
||||
Glass widgets should use the request/snapshot API:
|
||||
|
||||
- Register demand by calling `request_window_gauss(cx)` while drawing inside an overlay drawlist.
|
||||
- Expose script properties for `blur_radius`, `saturation`, `tint_color`, `tint_alpha`, `border_alpha`, `specular_strength`, `noise_strength`, and `refraction_strength`.
|
||||
- Bind window-provided scene/mip textures into the glass draw shader.
|
||||
- Keep a fallback path when no blur stack exists: current tint/noise/highlight shader behavior.
|
||||
|
||||
The widget should not allocate render targets directly. It only declares intent: "this overlay draw needs backdrop glass with these parameters."
|
||||
|
||||
## Shader Work
|
||||
|
||||
Implement shaders in layers:
|
||||
|
||||
1. `DrawGlassPanel` / `GaussRoundedView`: final panel shader that samples blurred backdrop texture, optional sharp scene texture, and procedural noise.
|
||||
2. `DrawBlurDownsample`: box or tent downsample.
|
||||
3. Mip-level interpolation and a small multi-tap gather for smoother large kernels.
|
||||
4. Optional `DrawGlassMask`: writes rounded-rect mask for region-limited blur.
|
||||
|
||||
The first pass should favor stable visuals over perfect physical accuracy:
|
||||
|
||||
- Background blur sampled in screen/window coordinates.
|
||||
- Tint and saturation correction after blur.
|
||||
- Rounded SDF clipping in the glass panel shader.
|
||||
- Border/highlight generated in the panel shader.
|
||||
- Noise dither kept subtle and time-stable unless animation is explicitly requested.
|
||||
|
||||
## Backend Path
|
||||
|
||||
Implementation order:
|
||||
|
||||
1. Validate the window-owned `GaussStack` on the existing backend paths.
|
||||
2. Clean up the request/snapshot API so multiple glass widgets can share one stack.
|
||||
3. Add backend-neutral renderflow structs and hook APIs in `platform/src` if the window-local model proves too limiting.
|
||||
4. Convert the Metal repaint loop to execute renderflow ops.
|
||||
5. Keep other backends on the default no-hook flow until the API is stable.
|
||||
|
||||
## Invalidation And Performance
|
||||
|
||||
Blur resources must be rebuilt when:
|
||||
|
||||
- Window size changes.
|
||||
- DPI changes.
|
||||
- Glass parameters require a different stack resolution or radius class.
|
||||
- The source pass repaints.
|
||||
- The overlay request state changes from no glass to glass, or glass to no glass.
|
||||
|
||||
Performance controls:
|
||||
|
||||
- Downsample before blur.
|
||||
- Bucket blur radii into a small number of stack variants.
|
||||
- Skip blur work when no visible glass regions exist.
|
||||
- Reuse textures across frames.
|
||||
- Add counters for renderflow op count, blur texture size, and pass count.
|
||||
|
||||
## Milestones
|
||||
|
||||
1. Log current pass count from the shared pass planner.
|
||||
2. Add `Window`-owned `GaussStack` with scene and mip drawpasses.
|
||||
3. Add generic overlay request/snapshot API with no widget-type leakage into `Window`.
|
||||
4. Add a `GaussRoundedView` proof widget and splash popup demo.
|
||||
5. Add screenshots and pass-count checks through Studio remote runs.
|
||||
6. Add smoother multi-tap upsample/selection for large blur kernels.
|
||||
7. Optimize region-limited blur after the full-window path is correct.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Should glass split a window into "content before glass" and "glass overlay" draw lists automatically, or should glass widgets force a renderflow capture at their depth?
|
||||
- Do we want one blur stack per window or one per source pass?
|
||||
- How much refraction should be supported in the first shader without requiring normal maps or distance fields from surrounding UI?
|
||||
- Should renderflow hooks be internal-only at first, or exposed to app/widget authors once stabilized?
|
||||
346
gps.md
Normal file
346
gps.md
Normal file
|
|
@ -0,0 +1,346 @@
|
|||
# GPS layer: search, current position, routing
|
||||
|
||||
## Status 2026-07-27 — basic navigator SHIPPED (M0 + M1 + M3 + sim-M2/M4)
|
||||
|
||||
Built and verified end-to-end (search "centraal" → route → simulated drive →
|
||||
"You have arrived" banner):
|
||||
|
||||
- `libs/map_nav` — geo, `region.search` (builder+query, prefix/category/proximity),
|
||||
`region.graph` (CSR, per-mode speeds, oneway, turn restrictions, snap grid, A*),
|
||||
maneuver generation, `NavSession` (map-matching, off-route → reroute). 26 unit tests.
|
||||
- `tools/map_tiles nav-build <pbf> <basename> [--bbox]` — one scan → both artifacts
|
||||
(Noord-Holland: 7.8s, 89MB graph / 107MB search, 1.39M directed edges, 1.8M docs
|
||||
incl. full addresses). `nav-probe` for CLI search/route checks.
|
||||
NH pbf at `local/maps/noord-holland-latest.osm.pbf`, artifacts `local/maps/noord-holland.*`.
|
||||
- MapView M0: `MapViewAction` (ViewportChanged/Tapped/LongPressed/MarkerClicked),
|
||||
camera API + `fly_to` (zoom-out-in arc), `overlay.rs` (route casing+fill with
|
||||
traveled dim, pin markers, position puck w/ accuracy + heading), `mbtiles_path`
|
||||
DSL property (per-app tile archive override). Map now respects the `handled`
|
||||
flag so floating panels win hits (EventOrder::Up).
|
||||
- `examples/map` is the navigator app: debounced worker-thread search, result list,
|
||||
long-press / "Set position here" → puck, route bar (Drive/Bike/Walk), Start →
|
||||
simulated turn-by-turn with banner + follow cam + recenter.
|
||||
|
||||
Not yet: real GPS (CoreLocation), heading-up camera, voice, CH scaling, v2 pbf
|
||||
index extras (opening hours, admin hierarchy), typo tolerance. Plan below is the
|
||||
original roadmap.
|
||||
|
||||
Plan for the interaction layer on top of the local map stack: a search engine over the
|
||||
map data (places, shops, streets, addresses), a live "current position" puck, and
|
||||
turn-by-turn routing — a proper offline GPS app. This work sits between the renderer
|
||||
(being improved in `widgets/src/map/`) and the OSM import pipeline (`tools/map_tiles/`),
|
||||
and deliberately touches both only through small, agreed contracts so all three tracks
|
||||
can proceed in parallel.
|
||||
|
||||
## Current state (what we build on)
|
||||
|
||||
- **Renderer**: `MapView` (`widgets/src/map/view.rs`) renders Shortbread vector tiles
|
||||
from a local mbtiles file. Camera = `center_norm` (normalized web mercator `Vec2d`)
|
||||
+ fractional `zoom`. No rotation. Pan/scroll-zoom gestures work. There is **no public
|
||||
camera API, no widget actions, no tap reporting, no fly-to animation** — the widget is
|
||||
render-only today. `examples/map` is a bare demo with an empty `handle_actions`.
|
||||
- **Data**: `noord-holland-shortbread-1.0.mbtiles`, z0–14. The z14 layers carry real
|
||||
search fodder: `pois` (name, amenity, shop, cuisine, tourism…), `addresses`
|
||||
(housenumber/housename — **no street name**), `street_labels` (street names),
|
||||
`place_labels` (cities/towns + population), `public_transport` (stations),
|
||||
`buildings`, `sites`, `land`. The `streets` layer has `kind`, `oneway`,
|
||||
`oneway_reverse`, `bridge`, `tunnel`, `link` — enough for a stopgap routing graph,
|
||||
but no turn restrictions and no stable node ids.
|
||||
- **Import pipeline**: `tools/map_tiles` already parses raw `.osm.pbf` (`osmpbf` dep)
|
||||
and builds a full-tag "detail archive" that retains OSM ids + complete tag maps.
|
||||
This is the natural source of truth for search v2 and the routing graph.
|
||||
- **Reusable primitives**:
|
||||
- Zoom-constant screen-space symbols: `ICON_SHAPE_ID` path in the map vertex shader
|
||||
+ `append_icon_mesh` (`tile.rs`) — exactly what markers and the position puck need.
|
||||
- `DrawVector` immediate-mode path API (`move_to`/`line_to`/`stroke`) — route overlay.
|
||||
- `DrawRotatedText` + the label collision machinery — overlay labels.
|
||||
- Background work pattern: `TagThreadPool` + `ToUIReceiver` (used for tile decode) —
|
||||
reuse for search queries, index build, and route computation.
|
||||
- `MbtilesReader` (`libs/mbtile_reader`): sync, cheap to open, direct tile lookup.
|
||||
- Overlay injection point: end of `MapView::draw_walk`, after `place_and_draw_labels`.
|
||||
|
||||
## Architecture
|
||||
|
||||
One new pure-logic crate plus additions to the widget and the import tool:
|
||||
|
||||
```
|
||||
libs/map_nav/ NEW — no UI deps, fully unit-testable
|
||||
src/search/ index format, builder, query engine, ranking
|
||||
src/graph/ routing graph format, builder helpers, A* query
|
||||
src/nav/ NavSession state machine (map-matching, instructions, reroute)
|
||||
src/geo.rs shared: fixed-point coords, mercator helpers, haversine
|
||||
|
||||
tools/map_tiles/ import pipeline (other agent) gains two subcommands:
|
||||
search-index pbf (or tiles) -> region.search
|
||||
route-graph pbf -> region.graph
|
||||
|
||||
widgets/src/map/
|
||||
view.rs camera API, actions, fly-to, overlay draw hook
|
||||
overlay.rs NEW — markers, route polyline, position puck
|
||||
(search UI + nav HUD live in the example app first, promoted to widgets later)
|
||||
|
||||
examples/map/ grows into the actual GPS app (search box, nav HUD)
|
||||
```
|
||||
|
||||
Rationale: `libs/map_nav` mirrors what `libs/mbtile_reader` is for tiles — a dependency
|
||||
shared by the import tool (which *builds* artifacts) and the app/widget (which *queries*
|
||||
them). File formats live in one place; the import agent and we only need to agree on
|
||||
the artifact contracts (below).
|
||||
|
||||
All heavy work (index build, search queries, route computation, reroutes) runs on the
|
||||
existing thread-pool pattern and posts results back over `ToUIReceiver`. The UI thread
|
||||
never blocks on any of this.
|
||||
|
||||
Coordinate conventions: `f64` lon/lat at every public API boundary; normalized web
|
||||
mercator internally (matches `center_norm`); `u32` fixed-point (norm-mercator × 2³²)
|
||||
in file formats.
|
||||
|
||||
## Milestone 0 — MapView interaction API (prerequisite for everything)
|
||||
|
||||
The widget must become programmable before search or nav can exist. Small, mechanical,
|
||||
should land first and unblocks all later milestones. Coordinate with the renderer agent
|
||||
since it touches `view.rs`, but it's additive.
|
||||
|
||||
1. **Actions**: add `MapViewAction` and emit via `cx.widget_action`:
|
||||
- `ViewportChanged { center_lon, center_lat, zoom }` (end of gesture + end of fly-to)
|
||||
- `Tapped { lon, lat, abs }` (finger up without drag)
|
||||
- `LongPressed { lon, lat, abs }` (for "set position here" / "route to here")
|
||||
2. **Camera API** on `MapView`/`MapViewRef`:
|
||||
- `set_center(lon, lat)`, `set_zoom(z)`, `center() -> (f64, f64)`, `zoom() -> f64`
|
||||
- `screen_to_lonlat(abs) -> (f64, f64)` and inverse (the math already exists
|
||||
privately in `geometry.rs`)
|
||||
- write `center_lon/center_lat/zoom` back after gestures (today they're stale
|
||||
input-only seeds)
|
||||
3. **`fly_to(lon, lat, zoom)`**: animated camera. Copy the existing timer pattern
|
||||
(`zoom_settle_timer` / tile-fade). Ease center in mercator space + zoom with a
|
||||
zoom-out-then-in arc when the target is far (keeps tiles loadable mid-flight and
|
||||
reads like every mapping app). ~150 lines including easing.
|
||||
4. **Marker layer** (`overlay.rs`): `set_markers(Vec<MapMarker { lon, lat, icon, color, id }>)`,
|
||||
drawn with the zoom-constant symbol path; hit-test markers *before* the map's finger
|
||||
grab and emit `MarkerClicked { id }`. Add a pin SVG to `ICON_SVGS`.
|
||||
5. **Overlay draw hook**: draw order = tiles → labels → route polyline → markers →
|
||||
position puck (puck always on top).
|
||||
|
||||
Deliverable: `examples/map` can tap the map, log lon/lat, drop a pin, and `fly_to` it.
|
||||
|
||||
## Milestone 1 — Search
|
||||
|
||||
### Two data stages (don't block on the import agent)
|
||||
|
||||
- **v1 — index built from the tiles we already ship.** Scan all z14 tiles
|
||||
(Noord-Holland: 5,780 tiles ≤ ~400 KB — a seconds-scale, one-time background job on
|
||||
first launch, cached to disk). Extract named/categorized features from `pois`,
|
||||
`place_labels`, `street_labels`, `public_transport`, `buildings`, `sites`, `land`.
|
||||
The MVT decoder already produces per-feature tag maps at tile-build time; v1 adds an
|
||||
`MvtSink` that collects (name, tags, centroid) instead of tessellating. Known v1
|
||||
limits: no full addresses (Shortbread `addresses` lacks street names), features can
|
||||
appear in multiple tiles (dedupe by name_key + quantized location), no house-number
|
||||
interpolation.
|
||||
- **v2 — index emitted by the import pipeline from the pbf.** Full `addr:street` +
|
||||
`addr:housenumber` + `addr:city` + postcode geocoding, OSM ids, opening hours /
|
||||
website / phone in result details, admin hierarchy for display names
|
||||
("Albert Heijn — Zaandam"). Same file format, better builder. This is the contract
|
||||
to agree with the import agent; v1 exists so the search UI, ranking, and UX are
|
||||
finished before v2 data arrives.
|
||||
|
||||
### Index format (`region.search`, owned by `libs/map_nav`)
|
||||
|
||||
Design goals: mmap/stream-friendly (match the disk-streamable ethos of the tile store),
|
||||
zero-copy query, no runtime deps. Layout:
|
||||
|
||||
- **Doc table**: fixed-size records — coord (2×u32 fixed-point), category (u16 enum:
|
||||
city, town, street, supermarket, restaurant, cafe, station, …), static rank (u8:
|
||||
population for places, category weight for POIs), offsets into a string pool
|
||||
(display name, secondary line).
|
||||
- **Token index**: all names normalized (reuse/extend `normalize_label_key` in
|
||||
`label.rs`: lowercase, unaccent, strip punctuation) and tokenized. Sorted token
|
||||
table → binary search gives exact *and prefix* ranges for free. Each token has a
|
||||
postings list of doc ids, pre-sorted by static rank so top-k short-circuits.
|
||||
- **Category synonyms**: small static table in code mapping query words to categories
|
||||
("supermarkt"/"supermarket"/"groceries" → shop=supermarket; "pizza" →
|
||||
amenity=restaurant|fast_food + cuisine=pizza), so category queries return nearby
|
||||
instances rather than name matches. NL + EN synonyms first.
|
||||
- Typo tolerance (trigram fallback) is a later add — the format reserves a section id
|
||||
for it; v1 is prefix-only, which covers the autocomplete-style UX.
|
||||
|
||||
### Query pipeline (background thread, debounced ~150 ms)
|
||||
|
||||
1. Normalize + tokenize; last token treated as a prefix.
|
||||
2. Intersect postings (rarest token first); or category expansion when a token hits
|
||||
the synonym table.
|
||||
3. Score: match quality (exact > prefix, full-name > partial) + static rank +
|
||||
proximity boost (distance to current position if set, else viewport center) —
|
||||
`log2(distance)` penalty so "pizza" means "pizza near me".
|
||||
4. Top 10 → `SearchResults` action to the UI thread.
|
||||
|
||||
### Search UI (`examples/map` first)
|
||||
|
||||
Floating panel over the map: `TextInput` + results list (name, category icon,
|
||||
secondary line, distance). Enter/click → `fly_to` + drop marker; category queries can
|
||||
pin all top-N results. Escape/map-tap dismisses. Promote to a reusable
|
||||
`MapSearchPanel` widget once the UX settles.
|
||||
|
||||
Deliverable: type "centraal", get Amsterdam Centraal ranked above street matches,
|
||||
fly there. Type "supermarkt", get the nearest ten with pins.
|
||||
|
||||
## Milestone 2 — Current position
|
||||
|
||||
### Position provider
|
||||
|
||||
`PositionSample { lon, lat, accuracy_m, heading_deg: Option, speed_mps: Option, time }`
|
||||
behind a `PositionSource` abstraction with three implementations, in build order:
|
||||
|
||||
1. **Manual** — long-press → "set my position here". Trivial, immediately useful.
|
||||
2. **Simulated** — plays back a polyline (later: a computed route) at a given speed
|
||||
with synthetic heading + configurable GPS noise. This is the workhorse: it's how
|
||||
nav (M4) gets developed and tested on a desk, and it feeds the same code path a
|
||||
real receiver would.
|
||||
3. **Platform** — real location services. Makepad's platform layer has no geolocation
|
||||
API yet, so this is a new `Cx` service: `cx.start_location_updates()` →
|
||||
`Event::LocationUpdate`. macOS (CoreLocation via the existing objc bindings) first
|
||||
since that's the dev machine; iOS shares CoreLocation; Android (FusedLocation via
|
||||
JNI), web (`navigator.geolocation`) after. Permission plumbing per platform.
|
||||
Schedule late — everything above it works against Manual/Simulated.
|
||||
|
||||
### Rendering + camera behavior
|
||||
|
||||
- **Puck**: accuracy circle drawn in *map* space (scales with zoom), blue dot +
|
||||
white ring + heading wedge drawn zoom-constant (`ICON_SHAPE_ID` path). Interpolate
|
||||
between 1 Hz fixes at frame rate (short lerp, mild dead-reckoning by speed/heading)
|
||||
so the puck glides instead of teleporting.
|
||||
- **Follow mode**: camera tracks the puck. Any user pan/zoom breaks follow; a
|
||||
recenter button (standard GPS UX) re-engages it. Follow state lives in the app,
|
||||
driven via the M0 camera API — the widget stays dumb.
|
||||
|
||||
Deliverable: simulated drive around Amsterdam with a gliding puck, follow mode, and
|
||||
working recenter button.
|
||||
|
||||
## Milestone 3 — Routing
|
||||
|
||||
### Graph build (import pipeline, contract with the import agent)
|
||||
|
||||
Go straight to the **pbf-derived graph** — `osmpbf` is already a dependency of
|
||||
`tools/map_tiles`, and the tile-derived alternative (stitching clipped z14 `streets`
|
||||
geometry) has no turn restrictions, lossy connectivity at tile borders, and
|
||||
crossing-vs-junction ambiguity. Not worth building twice. (If the interaction track
|
||||
ever gets far ahead of the import track, a tile-stitched graph behind the same file
|
||||
format is the documented fallback — `bridge`/`tunnel`/`layer` disambiguate most
|
||||
crossings — but it's plan B, not the path.)
|
||||
|
||||
Builder (`route-graph` subcommand, logic in `libs/map_nav::graph::build`):
|
||||
|
||||
1. Pass 1 over ways: keep `highway=*` filtered by access profile; count node usage.
|
||||
2. Vertices = nodes used ≥2× + way endpoints. Edges = way segments between vertices,
|
||||
carrying: length (m), speed class (from `highway` kind, `maxspeed` when present),
|
||||
oneway, access flags per profile (car / bike / foot — Netherlands, so bike is not
|
||||
optional), and the full segment geometry for drawing.
|
||||
3. Turn restrictions from relations (`no_left_turn`, `only_straight_on`, …) stored as
|
||||
(via-node, from-edge, to-edge) ban/only lists.
|
||||
4. Serialize `region.graph`: CSR adjacency, u32 vertex ids, fixed-point coords, packed
|
||||
edge attrs, geometry pool, plus a uniform **grid spatial index over edges** for
|
||||
nearest-edge snapping.
|
||||
|
||||
Scale check: Noord-Holland is on the order of a million edges — plain in-memory graph,
|
||||
loads in well under a second.
|
||||
|
||||
### Query engine (`libs/map_nav::graph::query`)
|
||||
|
||||
- Snap start/goal: grid lookup → project point onto candidate edges → virtual split.
|
||||
- **Bidirectional A*** with a per-profile cost function (time-based; distance mode as
|
||||
a flag). Province-scale queries land in tens of ms on a worker thread — no
|
||||
preprocessing needed at this size. When we go country/Europe scale, add Contraction
|
||||
Hierarchies *in the builder* (query side barely changes); the file format reserves a
|
||||
section for shortcut edges now so the format doesn't break later.
|
||||
- Turn restrictions handled by edge-based expansion at restricted via-nodes only
|
||||
(cheap, standard trick — avoids paying edge-based-graph cost everywhere).
|
||||
- Output `Route { polyline, length_m, duration_s, legs: Vec<Maneuver> }`.
|
||||
|
||||
### Route rendering
|
||||
|
||||
Route polyline via `DrawVector` in the overlay hook: casing + fill in a distinct
|
||||
color, simplified by zoom (`geometry.rs` already has simplification helpers), the
|
||||
already-traveled portion dimmed once nav is active. Start/end markers from M0.
|
||||
|
||||
Deliverable: long-press → "route here" from current position; route line + distance
|
||||
and time appear.
|
||||
|
||||
## Milestone 4 — Turn-by-turn navigation
|
||||
|
||||
### Instruction generation (`libs/map_nav::nav`)
|
||||
|
||||
Walk the route's vertices: where bearing delta + road-class change warrant it, emit
|
||||
`Maneuver { kind, at: coord, street_name, distance_from_start }` with kinds
|
||||
Depart / Continue / TurnSlightLeft…SharpRight / Roundabout { exit_n } / Uturn /
|
||||
Arrive. Roundabout exit counting from the graph topology. Street names come from way
|
||||
tags (already in the graph edges).
|
||||
|
||||
### NavSession state machine
|
||||
|
||||
Owned by the app, fed `PositionSample`s, emits actions:
|
||||
|
||||
- **Map-matching**: project position onto route polyline near last known progress,
|
||||
gated by heading agreement; monotonic progress (never snaps backward on noise).
|
||||
- **Progress**: distance to next maneuver, remaining distance/time, ETA (recomputed
|
||||
from live speed with per-class fallback speeds).
|
||||
- **Off-route**: > ~30 m from route (accuracy-scaled) for > ~5 s → `Rerouting` state,
|
||||
fire a background route query from current position, swap route on arrival. The
|
||||
simulated source with noise (M2) is the test harness for exactly this.
|
||||
- Maneuver announcements at distance thresholds (e.g. 500 m / 100 m / now) → actions
|
||||
the UI turns into banner changes. Optional later: voice via `libs/tts` (kokoro) —
|
||||
the announcement action is designed so a TTS consumer just subscribes.
|
||||
|
||||
### Nav UI (`examples/map`)
|
||||
|
||||
- Top banner: maneuver arrow icon + "300 m — turn left onto Prinsengracht".
|
||||
- Bottom bar: ETA, remaining km/min, end-navigation button.
|
||||
- Camera: follow mode centered ahead of the puck (position ~1/3 from bottom), zoom
|
||||
scaled by speed. **North-up in v1.** Heading-up requires camera rotation, which the
|
||||
renderer doesn't have — the transform is affine so it's feasible, but it's the
|
||||
renderer agent's call; flagged as a coordinated stretch goal, not a v1 dependency.
|
||||
|
||||
Deliverable: simulated drive follows a computed route with live banner instructions,
|
||||
and going off-route visibly triggers a reroute.
|
||||
|
||||
## Milestone 5 — Real-world hardening (post-MVP, ordered by value)
|
||||
|
||||
1. macOS CoreLocation backend (first real-GPS platform), then iOS/Android/web.
|
||||
2. Search v2 index from pbf (full addresses, postcodes, admin hierarchy, details).
|
||||
3. Camera rotation + heading-up nav (with renderer agent).
|
||||
4. Voice guidance via `libs/tts`.
|
||||
5. Typo tolerance (trigram section), multi-stop routes, avoid-motorway/ferry flags.
|
||||
6. Europe scale: CH preprocessing, sharded search index, both keyed by region files.
|
||||
|
||||
## Artifact contracts (the import-agent interface, agree early)
|
||||
|
||||
| Artifact | Producer | Consumer | Contents |
|
||||
|---|---|---|---|
|
||||
| `region.mbtiles` | import pipeline | renderer | exists today (Shortbread tiles) |
|
||||
| `region.search` | `map_tiles search-index` (v1: app self-builds from tiles) | search engine | doc table + token index + string pool, format owned by `libs/map_nav` |
|
||||
| `region.graph` | `map_tiles route-graph` | router | CSR graph + turn restrictions + edge geometry + snap grid, format owned by `libs/map_nav` |
|
||||
|
||||
Formats versioned with a magic + version header; builders and readers live in the same
|
||||
crate so they can't drift.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Unit** (`libs/map_nav`, no UI): tokenizer/normalizer, ranking ("dam" must rank
|
||||
Dam square over side streets), postings intersection; graph snap edge cases (near
|
||||
junctions, dual carriageways); A* against hand-verified routes (a handful of
|
||||
Noord-Holland pairs sanity-checked against an online router, asserted within a
|
||||
tolerance); turn-restriction respect; maneuver generation on synthetic geometries
|
||||
(roundabout exit counts).
|
||||
- **NavSession sim tests**: drive a noisy simulated position along a route, assert
|
||||
instruction sequence, ETA monotonicity, and that a forced detour triggers exactly
|
||||
one reroute.
|
||||
- **Headless render tests** (existing `MAKEPAD=headless` PNG harness +
|
||||
`examples/*/tests` pattern): marker/route/puck overlay snapshots, search panel
|
||||
open-with-results snapshot.
|
||||
|
||||
## Suggested execution order
|
||||
|
||||
M0 is small and unblocks everything — do it first. After M0, search (M1) and position
|
||||
(M2) are independent and can proceed in parallel; both are pure-add. M3 starts with
|
||||
the `region.graph` contract conversation with the import agent, then builder + A*
|
||||
(testable entirely without UI), then the overlay. M4 builds strictly on M2+M3. The
|
||||
riskiest unknowns are deliberately early: the MapView API touch (renderer-agent
|
||||
coordination) is M0, and the graph contract conversation opens at the start of M3.
|
||||
233
handoff.md
Normal file
233
handoff.md
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
# handoff — the Makepad AI Game Maker (godot-like livecoding agent)
|
||||
|
||||
State as of 2026-07-10. Everything below is in the working tree, **nothing committed**.
|
||||
Written as the handoff for `examples/gamemaker`: a voice-driven game maker for kids
|
||||
where Claude Code live-edits a single splash script and the game hot-reloads in-process
|
||||
— the replacement for the Godot-backed `examples/godot` (which still exists, untouched,
|
||||
as the reference implementation).
|
||||
|
||||
## 1. What it is
|
||||
|
||||
```
|
||||
kid holds F1, talks (Whisper) ──► Claude Code (via makepad_ai::ClaudeCodeAgent)
|
||||
▲ │ edits ~/games/<name>/game.splash
|
||||
│ TTS speaks replies ▼
|
||||
Game Maker app ── chat pane │ GameView pane: splash isolate + fixed-60Hz world,
|
||||
│ re-evals on every edit (aichat-style streaming),
|
||||
│ broken edits never replace the running world
|
||||
```
|
||||
|
||||
- One process. No engine restarts, no focus stealing. A clean edit is visible mid-turn
|
||||
("the world grows while the AI talks"); a broken edit keeps the last-good world
|
||||
running and returns line-numbered errors to the agent.
|
||||
- The whole game is ONE file, `game.splash`, written in a curated ~45-verb `game.*`
|
||||
DSL (deliberately smaller than GDScript — easier to prompt, verify, and keep stable).
|
||||
- The agent self-verifies through `tools/ag`: screenshots, deterministic input-tape
|
||||
playtests with numeric probes, error/log round-trip.
|
||||
|
||||
## 2. Engine architecture (examples/gamemaker)
|
||||
|
||||
**Host** (`src/main.rs`, cloned from the godot example): chat/voice/TTS/session shell,
|
||||
per-game state in `<game>/.gamemaker/` (chat log, Claude session id, model), project
|
||||
dropdown over `~/games/*/game.splash` (`GAMEMAKER_HOME` overrides), harness re-stamped
|
||||
into each game on switch (`tools/`, `CLAUDE.md` — app-owned, kid files never touched).
|
||||
System prompt teaches kid-talk rules + the DSL + the always-check-errors workflow.
|
||||
Permission policy: `Edit/Write(./**)` + `Bash(./tools/ag:*)` only.
|
||||
|
||||
**GameView** (`src/game_view.rs`, ~2k lines — splitting into modules was repeatedly
|
||||
deferred, do it before it grows again):
|
||||
- **Script hosting**: dedicated splash isolate (`cx.alloc_splash_vm_with_network`),
|
||||
incremental re-eval via `eval_with_append_source` parser checkpoints (the same
|
||||
machinery aichat uses to stream `runsplash` blocks), instruction limits (2M per
|
||||
eval / 500k per tick), pointer-stable ScriptMod keying. Reload triggers: 250ms mtime
|
||||
watch + immediately on the agent's Edit/Write ToolRequest completing.
|
||||
- **Last-good semantics**: eval snapshots the world (entities, parts, labels, terrain,
|
||||
sky, HUD), rebuilds from scratch, rolls back wholesale on failure. Errors go to
|
||||
`.agent/game.log` + `.agent/last_error.txt` (cleared-to-empty on success BY DESIGN —
|
||||
"empty = your edit is live") via a platform addition: `ScriptVm::take_errors` +
|
||||
`captured_errors` sink (streaming evals otherwise silence errors).
|
||||
- **Dispatch design**: one native `game` handle over `Rc<RefCell<GameWorld>>`; every
|
||||
`game.verb(...)` is a match arm in `game_dispatch` — synchronous mutation, no async
|
||||
widget trampoline, deterministic ordering. Adding a verb = one arm + one doc row.
|
||||
- **Physics**: deliberately tiny — gravity + axis-separated AABB sweeps, kinematic
|
||||
carry, sensors, mover ground detection, terrain height-lookup collision with 0.55
|
||||
step-up. **The physics body NEVER rotates — only the visual model yaws.** This
|
||||
matches Godot exactly (their CharacterBody3D does the same); it is the reason a
|
||||
mini-engine reaches parity. box3d swap remains a marked seam (see §5).
|
||||
- **Renderer**: offscreen 3D pass composited into the pane. Shaders (script-DSL,
|
||||
JIT-compiled on headless): `DrawGameCube` (instanced lit boxes + emission + fog),
|
||||
`DrawGameAlpha` (translucent pass: sensors, water, blob shadows), `DrawGameSky`
|
||||
(gradient dome), `DrawGameTerrain` (triangulated heightfield, per-vertex color).
|
||||
Near plane **1.0** (Godot's CAM_NEAR) so lens-overlapping creatures clip open
|
||||
instead of filling the screen. Billboard labels project into a 2D overlay with
|
||||
automatic 4-copy dark outlines; HUD slots + crosshair are plain overlay draws.
|
||||
- **Input**: ActionMap — keyboard (WASD/arrows, Space, F=shoot, G=grab) + gamepad
|
||||
(`cx.game_input_states`: stick analog w/ deadzone, dpad, A=jump/X=shoot/B=grab,
|
||||
edge-detected) merged at read time via `PadState` (never into the key-held set, so
|
||||
devices can't cancel each other). Input snapshot per tick: held/pressed booleans,
|
||||
raw `axis_x/axis_z`, **camera-relative `move_x/move_z`** (the canonical walk input;
|
||||
the axes rotated by effective camera yaw — derived from the camera basis, and the
|
||||
fix for "controls don't match the camera": the first documented recipe rotated by
|
||||
−yaw). Tapes zero the pad and pin yaw+pitch → byte-identical replays.
|
||||
- **Audio** (`src/synth.rs`): polyphonic synth (osc + percussive envelope, 24-voice
|
||||
cap) mixed into the app's audio callback under the TTS voice; mute button silences
|
||||
both. Named bank (18: jump shoot zap grab angry calm rescue shove board coin hurt
|
||||
win lose squeak roar bark moo clank whip) + `game.beep{}` + `game.jingle("C5 E5")`.
|
||||
The Godot corpus AI hand-built a synth in GDScript when it had no audio API —
|
||||
that's why this is an engine service.
|
||||
- **Agent harness** (`tools/ag` + file RPC through `.agent/`): `peek` (4 live frames
|
||||
via `Cx::capture_next_frame_to_file` — a platform addition riding the studio
|
||||
screenshot pipeline — + entity state), `test N tape` (restart, frame-indexed tape
|
||||
through the ActionMap, captures + `probe.txt`, `sheet.py` contact sheet), `errors`,
|
||||
`logs`. Tape format is the Godot harness's JSON unchanged. Errors are also
|
||||
PUSHED into the agent chat (GameViewAction → auto fix wake-up on post-turn broken
|
||||
evals and idle runtime errors; 2-wake-up guard, reset by a kid message;
|
||||
GAMEMAKER_NO_AGENT=1 disables the agent for token-safe headless tests). Engine
|
||||
registration errors surface in the status bar, never in the kid chat.
|
||||
|
||||
**DSL surface** — the authoritative, always-in-sync docs:
|
||||
- **repo-root `splashgame.md`** — THE agent-facing API contract, loaded into the
|
||||
system prompt at runtime (fs read + include_str fallback), exactly the pattern
|
||||
aichat uses for `splash.md`. Adding a verb = dispatch arm + a row here.
|
||||
- `resources/template/CLAUDE.md` — per-game workflow only (ag ritual, house style,
|
||||
gotchas); points at the system prompt for the API.
|
||||
- `resources/aigame-dsl.md` — developer guide (execution model, extension points).
|
||||
Spawning: `box/mover/spawn/terrain/part/label`. Driving: `on_tick(dt, input)`, `walk/
|
||||
jump/on_floor/pos/vel/set_pos/set_vel/face/yaw`, `find/tag/distance`, timers `after`,
|
||||
`on_touch`. Look: `set_color/glow/scale/move_part/sky/camera` (orbit / `side:` /
|
||||
`third_person:` with occlusion pull-in ignoring `"scenery"`). Systems: `attach`
|
||||
(seat + `mode:"ride"` w/ spin)/`detach`, `speed_mult`, `beam`, `ground_y/ground_peak`,
|
||||
`rand/rand_range` (seeded per eval — tape-deterministic, better than the corpus's
|
||||
`randomize()`), HUD `text(slot,…)/crosshair`, labels w/ ids + `label_text`, sounds.
|
||||
Terrain noise is shaped engine-side (`freq/offset/amp/step/min/max/plaza`, ≤384
|
||||
cells, `bands:` height-color thresholds — snow bands are what make hills read as
|
||||
mountains) so big worlds cost no script instruction budget.
|
||||
|
||||
## 3. How parity was reached (method matters as much as the result)
|
||||
|
||||
1. **Corpus first**: the AI-generated Godot game (`~/games/my-game`, grew to ~44
|
||||
creatures) was inventoried exhaustively (`aigame_port_inventory.md`), and every
|
||||
engine feature exists because the corpus used it — nothing speculative. Confirmed
|
||||
non-needs (no tweens/particles/navmesh/shaders in 2600+ generated lines) were
|
||||
deliberately NOT built.
|
||||
2. **Port as acceptance test**: the game was ported to
|
||||
`resources/fixtures/sandbox3d.splash` (932 lines, 90 entities — ~2100 lines of
|
||||
GDScript; the compression is the argument the DSL is at the right altitude).
|
||||
Each port round produced findings (`aigame_port_findings.md`); findings became
|
||||
engine verbs; the fixture re-ported until clean. Gap analysis lives in
|
||||
`aigame_parity_gap.md`; the original plan in `aigame.md`.
|
||||
3. **Verification discipline**: headless eval cleanliness + numeric probes against
|
||||
Godot ground truth (plaza floor exactly 7.9, walk exactly 6.0, jump apex JUMP²/2G,
|
||||
ride-debuff exactly 3.0) + determinism (two tape runs byte-identical). Pixels on
|
||||
GPU were checked by rik; pure-headless pixels are blocked on §5.5.
|
||||
|
||||
The fixture runs the CURRENT game: Giant DogDay guardian (intercept-charge, beam
|
||||
bonk-arc), 3 headcrabs (leap→latch→speed debuff→jump to shake off), the Prototype
|
||||
weeping-angel (LOS dot vs camera), Baba Chops (glow-ramp fire eyes, ram), Nightmare
|
||||
Huggy (arm-reach via move_part), CatNap sleep-curl (scale), Kissy bodyguard, heal
|
||||
quest, 10 farm animals (per-kind sfx pitch), trucks + passengers, grapple hand on G
|
||||
(terrain yank / creature haul, beam cable), 257×257 smooth terrain at Godot's exact
|
||||
constants with banded snow mountains, sky/fog/blob shadows, crosshair/hint/flash HUD.
|
||||
Installed for rik as `~/games/dogday-world`.
|
||||
|
||||
## 4. Infrastructure built along the way (reusable beyond gamemaker)
|
||||
|
||||
- **Headless render/test pipeline**: `MAKEPAD=headless` builds render frames to PNG
|
||||
via CPU raster + JIT-compiled Rust shaders (`--draws=N`,
|
||||
`MAKEPAD_HEADLESS_OUT_DIR`); warm cache at `examples/splash/target`. gamemaker has
|
||||
its own `build.rs` mirroring the platform's env→cfg wiring.
|
||||
- **Three JIT shader-compiler bugs fixed** (platform/script + headless preamble):
|
||||
scalar casts `u32(x)` → `as` casts; `Mat4f*Mat4f` missing in the runtime preamble;
|
||||
heterogeneous constructors splat-padding because `ShaderType::Id` args weren't
|
||||
scope-resolved. Regression stages 4k/4l in `platform/script/test/src/main.rs`.
|
||||
- **`ScriptVm::take_errors` + `captured_errors` sink** (platform/script/src/vm.rs) —
|
||||
the error round-trip primitive; should be upstreamed into the `Splash` widget too
|
||||
(aichat runsplash blocks still render blank on error).
|
||||
- **`Cx::capture_next_frame_to_file`** (platform/src/os/cx_shared.rs + metal path) —
|
||||
in-app window capture without the studio bridge.
|
||||
- **xr on box3d**: the whole `makepad-xr` crate ported off Rapier3D (raycast vehicle
|
||||
reimplemented in `xr/src/scene/raycast_vehicle.rs`; 4 pre-existing test failures
|
||||
match the Rapier baseline exactly). box3d `body_set_mass_data` stale
|
||||
world-inverse-inertia bug found + fixed with a regression test.
|
||||
- The **godot example harness** got the no-focus-steal treatment first (open -g,
|
||||
unfocusable offscreen capture window, last-good relaunch policy) — kept as-is.
|
||||
|
||||
## 5. Remaining work for FULL parity (ranked)
|
||||
|
||||
1. **Caves / overhangs** — the only dropped world feature. Heightfields can't carve.
|
||||
Options: engine `game.tunnel(a, b, r)` laying rock-slab roofs (matches the Godot
|
||||
`_carve_caves` approach — it lays roof slabs too, it does NOT boolean-carve), or
|
||||
accept boxes-as-caves authored by the AI.
|
||||
2. **`game.raycast(from, dir, {mask})`** — the grapple currently probes terrain
|
||||
height only; it can't catch trees/boxes/creatures mid-flight, and ledge-probe AI
|
||||
(the corpus's no-navmesh pathing trick) can't be written. One verb unlocks both.
|
||||
3. **Real shadow maps** — blob shadows ground creatures but the 21:54 Godot capture
|
||||
has directional shadows. A single-cascade sun map over the play area is enough.
|
||||
4. **Camera-overlap creature fade** — near-clip 1.0 fixed the giant-polygon fill;
|
||||
inside a crowd you can still sit within a body. Fade entities whose AABB
|
||||
intersects a small camera sphere (Godot mitigates via its spring-arm feel).
|
||||
5. **Headless offscreen-pass compositing** (task #9) — box3d example renders nothing
|
||||
headless; gamemaker's pane is blank in pure-headless frames (GPU capture path is
|
||||
fine). Blocks CI-grade visual verification of `ag test` sheets. Look at
|
||||
`platform/src/os/headless/event_loop.rs` pass scheduling + overlay draw lists +
|
||||
NextFrame-only apps never drawing.
|
||||
6. **box3d under the verbs** — the mini-AABB physics is the deliberate seam
|
||||
(`TODO(aigame)` in game_view.rs). Swap when games need slopes-with-momentum,
|
||||
stacking, ragdolls, vehicles-with-suspension. box3d is deterministic and already
|
||||
in-tree; keep the tape guarantees.
|
||||
7. **The 2D side-scroller** (`main.tscn`) was never ported — `side:` camera + boxes
|
||||
cover it in principle; port it as a second fixture to harden 2D ergonomics
|
||||
(AnimatableBody movers + Camera2D limits analogues).
|
||||
8. **Engine polish debt**: terrain band colors interact with auto-shade (bands win —
|
||||
fine, but no slope shading within a band); `game.terrain` columns mode still
|
||||
spawns per-cell entities (instanced draw + height collision would retire it);
|
||||
part transforms have no rot lerp shortest-path handling; gamepad stick vertical
|
||||
sign untested on hardware (one-line flip if inverted).
|
||||
9. **Perf headroom**: 90 entities × parts ≈ fine; the tick budget (500k instructions)
|
||||
fits the fixture's ~15 actors of AI — a 100-creature brawl will need either budget
|
||||
raise or engine-side steering helpers (`walk_towards`, `flee`) which the corpus's
|
||||
shared `_drive()` suggests anyway.
|
||||
10. **Future (from aigame.md phase 3)**: host the scene layer on `XrNode` so the same
|
||||
game.splash runs on Quest (xr is on box3d now, and has hands/multiplayer);
|
||||
upstream the error round-trip to `Splash`; batch corpus regression harness
|
||||
(splash_preview-style: run recorded kid asks through the real CLI against the
|
||||
DSL guide, eval + tape-smoke each result).
|
||||
|
||||
## 6. Run / verify cheatsheet
|
||||
|
||||
```bash
|
||||
# the app (from the makepad repo root; Whisper model resolves from CWD)
|
||||
cargo run -p makepad-example-gamemaker --release
|
||||
# rik's install: ~/games/dogday-world (set as .last), full fixture
|
||||
|
||||
# headless smoke of any game dir
|
||||
MAKEPAD=headless CARGO_TARGET_DIR=examples/splash/target \
|
||||
MAKEPAD_HEADLESS_OUT_DIR=/tmp/frames GAMEMAKER_HOME=<games-root> \
|
||||
cargo run -p makepad-example-gamemaker --release -- --draws=6
|
||||
# then: <game>/.agent/game.log ("eval #N: ok, E entities"), last_error.txt empty
|
||||
# AND count startup script errors: ... 2>&1 | grep -cE "^\[E\]" must be 0
|
||||
# (shader/widget REGISTRATION errors print as [E] lines but do not fail the run —
|
||||
# a missed [E] once shipped a build with the terrain shader dead)
|
||||
|
||||
# agent-side playtest, in a game dir (app running)
|
||||
./tools/ag errors | logs | peek | test 200 tools/tapes/<tape>.json
|
||||
# sheet: .agent/sheet.png probes: .agent/probe.txt (byte-identical across runs)
|
||||
|
||||
# engine tests touched by this work
|
||||
cargo run -p makepad-script-test --release # shader codegen stages 4k/4l
|
||||
cargo test -p makepad-box3d --release # incl. mass-data regression
|
||||
cargo test -p makepad-xr --release # 127 pass / 4 pre-existing
|
||||
```
|
||||
|
||||
## 7. Document index
|
||||
|
||||
| doc | what |
|
||||
|---|---|
|
||||
| `aigame.md` | the original migration plan (Godot → splash), phases + rationale |
|
||||
| `aigame_port_inventory.md` | exhaustive Godot API usage of the generated corpus |
|
||||
| `aigame_port_findings.md` | port findings v1+v2: API cleanups, fidelity ledgers, open gaps |
|
||||
| `aigame_parity_gap.md` | the final gap matrix (current game vs engine) + fork specs |
|
||||
| `examples/gamemaker/resources/aigame-dsl.md` | developer guide to the DSL + engine internals |
|
||||
| `splashgame.md` (repo root) | THE agent-facing API contract, loaded into the system prompt (keep in sync!) |
|
||||
| `examples/gamemaker/resources/template/CLAUDE.md` | per-game workflow stamped into each project |
|
||||
| this file | orientation + what's left |
|
||||
228
isolate.md
Normal file
228
isolate.md
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
# Splash UI-Thread Isolate Changes
|
||||
|
||||
## Target
|
||||
|
||||
Run each inline `Splash` widget in its own script VM while still creating and mutating native widgets on the UI thread.
|
||||
|
||||
The MVP is not a worker-thread sandbox. It is UI-thread VM isolation with sampled wall-clock guards so Splash scripts cannot monopolize the UI loop indefinitely.
|
||||
|
||||
## VM Identity
|
||||
|
||||
Use one VM id type everywhere:
|
||||
|
||||
```rust
|
||||
#[derive(Clone, Copy, Eq, PartialEq, Hash)]
|
||||
struct SplashVmId(u64);
|
||||
|
||||
const MAIN_SPLASH_VM_ID: SplashVmId = SplashVmId(0);
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- `SplashVmId(0)` is the existing app VM.
|
||||
- New Splash isolates allocate ids from `1` upward.
|
||||
- Widget/script routing carries `SplashVmId`, not a `ScriptVmOwner` enum.
|
||||
- Unknown app widgets default to `MAIN_SPLASH_VM_ID`.
|
||||
|
||||
## Cx Storage
|
||||
|
||||
Add isolated VM storage under `CxScriptData`:
|
||||
|
||||
```rust
|
||||
struct IsolatedScriptVms {
|
||||
next_id: u64,
|
||||
vms: HashMap<SplashVmId, IsolatedSplashVm>,
|
||||
widget_owners: HashMap<WidgetUid, SplashVmId>,
|
||||
}
|
||||
|
||||
struct IsolatedSplashVm {
|
||||
std: ScriptStd,
|
||||
vm: Option<Box<ScriptVmBase>>,
|
||||
initialized: bool,
|
||||
generation: u64,
|
||||
wake_pending: bool,
|
||||
consecutive_over_budget: u32,
|
||||
}
|
||||
```
|
||||
|
||||
Add helpers:
|
||||
|
||||
- `Cx::with_script_vm_id(vm_id, f)`: routes id `0` to the app VM, non-zero ids to isolated VMs.
|
||||
- `Cx::with_splash_vm(id, f)`: non-zero isolate helper.
|
||||
- subtree owner registration/unregistration for widgets produced by a Splash view.
|
||||
|
||||
## Splash Eval
|
||||
|
||||
Change `Splash::eval_body` from app-VM eval to isolate eval:
|
||||
|
||||
```text
|
||||
Splash::set_text
|
||||
-> allocate or reuse SplashVmId
|
||||
-> initialize restricted Splash VM once
|
||||
-> eval_with_append_source under wall-clock budget
|
||||
-> View::script_from_value in the same VM
|
||||
-> register produced widget subtree with that SplashVmId
|
||||
-> replace the native View
|
||||
-> redraw
|
||||
```
|
||||
|
||||
The restricted Splash VM registers the widget/runtime modules needed for normal widget DSL, but does not inherit host app script globals. Filesystem, process spawning, and arbitrary network remain disabled by default.
|
||||
|
||||
## Widget Message Routing
|
||||
|
||||
Make widget async routing VM-id-aware.
|
||||
|
||||
Update widget-to-script requests:
|
||||
|
||||
```rust
|
||||
struct WidgetToScriptCallRequest {
|
||||
vm_id: SplashVmId,
|
||||
target_uid: WidgetUid,
|
||||
// existing callback/function/arg fields
|
||||
}
|
||||
```
|
||||
|
||||
Dispatch:
|
||||
|
||||
- `vm_id == MAIN_SPLASH_VM_ID`: use existing app `cx.with_vm`.
|
||||
- non-zero `vm_id`: use `cx.with_splash_vm`.
|
||||
|
||||
Update script-to-widget async calls the same way:
|
||||
|
||||
- pending returns keyed by `(SplashVmId, ScriptThreadId)`;
|
||||
- `ui` handle injection is per VM;
|
||||
- `View.render()` callbacks and returned objects apply in the same VM that requested them.
|
||||
|
||||
Move `TextInput` callback execution onto the same VM-id-aware queue instead of directly calling `cx.with_vm`.
|
||||
|
||||
## UI Loop Pumping
|
||||
|
||||
Use the existing UI service points:
|
||||
|
||||
- after `Cx::call_event_handler`, pump app tasks, then pump runnable Splash isolates;
|
||||
- on `Event::Signal`, pump isolates with `wake_pending` or queued work;
|
||||
- on `TimerEvent`, route script timers by `SplashVmId`;
|
||||
- on network responses, only route to Splash isolates if network is explicitly enabled.
|
||||
|
||||
Scheduling policy:
|
||||
|
||||
- round-robin non-zero Splash ids;
|
||||
- per-isolate soft wall-clock slice;
|
||||
- aggregate per-frame Splash wall-clock cap;
|
||||
- one wake request per isolate while work is pending, using `wake_pending` to avoid signal storms.
|
||||
|
||||
## Tasks And Async
|
||||
|
||||
Each isolate owns its own `ScriptStd`, including `ScriptTasks`.
|
||||
|
||||
Add a budgeted task pump:
|
||||
|
||||
```rust
|
||||
fn handle_script_tasks_budgeted(
|
||||
host: &mut dyn Any,
|
||||
std: &mut ScriptStd,
|
||||
script_vm: &mut Option<Box<ScriptVmBase>>,
|
||||
budget: &mut ScriptRunBudget,
|
||||
) -> ScriptPumpOutcome;
|
||||
```
|
||||
|
||||
Stop pumping when:
|
||||
|
||||
- no work progresses;
|
||||
- the isolate time slice is exhausted;
|
||||
- the frame-wide Splash time budget is exhausted;
|
||||
- the isolate hits hard overrun policy.
|
||||
|
||||
Do not discard unfinished work on a soft time yield. Leave pending resumes, task queues, and pump-hook work in that isolate's `ScriptStd`.
|
||||
|
||||
`widget_async` task hooks must be installed per `ScriptStd` or per `SplashVmId`, not behind one global app-only boolean.
|
||||
|
||||
## Timers
|
||||
|
||||
Make script timers VM-id-aware:
|
||||
|
||||
```rust
|
||||
struct CxScriptTimer {
|
||||
vm_id: SplashVmId,
|
||||
id: LiveId,
|
||||
repeat: bool,
|
||||
timer: Timer,
|
||||
callback: ScriptFnRef,
|
||||
}
|
||||
```
|
||||
|
||||
`std.start_timeout`, `std.start_interval`, and `std.stop_timer` operate on the current VM id. `stop_timer` only removes timers owned by that VM id.
|
||||
|
||||
Timer callbacks run through `Cx::with_script_vm_id` under the timer callback wall-clock budget. On repeated interval overruns, stop the timer and report an isolate diagnostic.
|
||||
|
||||
## Sampled Wall-Clock Guard
|
||||
|
||||
Add a wall-clock budget checked inside `ScriptVm::run_core`, but only sample time every N instructions:
|
||||
|
||||
```rust
|
||||
pub struct ScriptRunBudget {
|
||||
pub soft_deadline: Instant,
|
||||
pub hard_deadline: Instant,
|
||||
pub sample_interval_instructions: u32,
|
||||
pub instructions_until_sample: u32,
|
||||
pub reason: ScriptBudgetReason,
|
||||
}
|
||||
```
|
||||
|
||||
Deadline meaning:
|
||||
|
||||
- `soft_deadline`: cooperative yield point. The current script thread stays resumable, the isolate is queued for a later signal/frame, and this is not treated as an error.
|
||||
- `hard_deadline`: runaway failure point. The current script execution is bailed, pending work for that isolate generation is cleared, and a diagnostic is reported.
|
||||
|
||||
Interpreter loop behavior:
|
||||
|
||||
```text
|
||||
each opcode/direct value
|
||||
-> instructions_until_sample -= 1
|
||||
-> if zero:
|
||||
instructions_until_sample = sample_interval_instructions
|
||||
now = monotonic_time()
|
||||
if now >= hard_deadline: hard time-budget failure
|
||||
if now >= soft_deadline: soft time-budget yield
|
||||
```
|
||||
|
||||
Do not reuse `ScriptTrapOn::Pause` for budget yield. Add a distinct outcome/trap, for example `TimeBudgetYield`, so async pauses still mean "waiting for external work".
|
||||
|
||||
Soft time yield:
|
||||
|
||||
- preserve thread state and instruction pointer;
|
||||
- queue the isolate for a later signal/frame;
|
||||
- return control to the UI loop without treating it as an error.
|
||||
|
||||
Hard time failure:
|
||||
|
||||
- bail the current thread;
|
||||
- clear pending timers/tasks/callbacks for that isolate generation;
|
||||
- keep the previous valid Splash view if possible;
|
||||
- show/log a diagnostic.
|
||||
|
||||
Native Rust calls cannot be preempted mid-call. For Splash VMs, sample immediately before and after native calls so execution stops after an overlong native call returns.
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Add `SplashVmId`, id `0` main routing, and isolated VM registry storage.
|
||||
2. Add `Cx::with_script_vm_id` and isolate initialization.
|
||||
3. Add sampled wall-clock budget support to VM entry points and `run_core`.
|
||||
4. Move `Splash::eval_body` to isolated VM eval and subtree owner registration.
|
||||
5. Make widget async requests, returns, `ui`, `View.render()`, and `TextInput` VM-id-aware.
|
||||
6. Make task hooks and task pumping per VM id and budgeted.
|
||||
7. Make script timers VM-id-aware and budgeted.
|
||||
8. Add isolate pumping to event, signal, timer, and optional network paths.
|
||||
9. Add stale subtree unregistering and isolate generation cleanup.
|
||||
|
||||
## Tests
|
||||
|
||||
- Two Splash widgets keep independent state and callbacks.
|
||||
- Splash callback updates only its own subtree.
|
||||
- `TextInput.on_return` runs in the owning Splash VM.
|
||||
- `View.render()` callback and result apply in the owning Splash VM.
|
||||
- Infinite loop yields or is killed by wall-clock policy while the app UI remains responsive.
|
||||
- Requeueing task is limited by per-isolate pump budget.
|
||||
- Interval callback cannot run in app VM and is stopped after repeated overruns.
|
||||
- `stop_timer` from one VM cannot cancel another VM's timer.
|
||||
- Replacing Splash body invalidates stale callbacks from the previous generation.
|
||||
259
layers.md
Normal file
259
layers.md
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
# Overlay data layers — work log & status
|
||||
|
||||
Status 2026-07-28. This documents the overlay-layer track: the `libs/geodata`
|
||||
crate, the nine built layer databases, the live rain-radar sync, and the LLM
|
||||
query surface. Companions: `datasources.md` (source survey + licenses, the
|
||||
research this implements), `gps.md` (interaction layer), `libs/geodata/README.md`
|
||||
(the crate's own contract-level docs). The map renderer is a separate track —
|
||||
nothing in here touches `widgets/src/map`.
|
||||
|
||||
## TL;DR
|
||||
|
||||
All planned NL layers are **built, integrity-checked, and query-verified** in
|
||||
`local/overlays/nl-<layer>.mbtiles` (one file per layer, never merged into the
|
||||
base Europe archive). Every vector layer is simultaneously:
|
||||
1. **renderable** — standard gzipped MVT 2.1 tiles the renderer's existing
|
||||
decoder already parses, and
|
||||
2. **LLM-queryable** — a grid-indexed `features` sidecar table + `query::LayerDb`
|
||||
(point / radius / bbox → structured JSON) for "reason with the map" tool calls.
|
||||
|
||||
Raster layers use terrarium RGB (elevation) or gray8 class-index encoding with
|
||||
a `geodata_classmap` metadata table (class → label + suggested color), so the
|
||||
shader colormaps and the query side can *name* values.
|
||||
|
||||
| layer | content | zooms | size | build | verified by |
|
||||
|---|---|---|---|---|---|
|
||||
| `nature` | Natura 2000 + Ramsar wetland polygons | 6–12 | 6.2 MB | 0.6 s | polygons + rings in sidecar |
|
||||
| `chargers` | 66,583 EV charging locations (operator, kW) | 8–14 | 26 MB | 2.9 s | Krasnapolsky charger @128 m from Dam |
|
||||
| `demographics` | CBS 500 m + 100 m grid stats (442k cells) | 8–13 | 275 MB | 9.4 s | population at point |
|
||||
| `wijkbuurt` | gemeente/wijk/buurt polygons + kerncijfers | 6–13 | 91 MB | 4.8 s | Dam → Amsterdam → Burgwallen-NZ → Nieuwe Kerk e.o. |
|
||||
| `transit` | all NL stops + 1,457 non-bus route shapes | 7–14 | 14 MB | 6.1 s | streamed from 234 MB GTFS |
|
||||
| `buildings-age` | **all 11,407,303 BAG buildings**, bouwjaar+status | 13–14 | 1.6 GB | 91 s | Royal Palace → bouwjaar 1655 |
|
||||
| `terrain` | GLO-30 elevation, terrarium, NL bbox | 6–12 | 330 MB | 57 s | 18/20 cells; 2 sea-only 404s by design |
|
||||
| `noise` | RIVM 10 m Lden → 5 dB classes | 6–13 | 34 MB | 23 min | A10 tile: 65–75 dB spine over 50–55 dB city |
|
||||
| `flood` | JRC 1-in-100y river flood depth classes | 6–11 | 2.7 MB | 3.6 min | class histogram plausible |
|
||||
|
||||
Plus the live source: **rain radar** (`RadarSync`) — pulled a real KNMI +2h
|
||||
nowcast minutes after publication; poll-gated, cache-pruning, app-embeddable.
|
||||
|
||||
CLI quick reference (`cargo run -p makepad-geodata --bin geodata` or the
|
||||
release binary):
|
||||
|
||||
```
|
||||
geodata list | fetch <layer|all> | build <layer|all> | status
|
||||
geodata query <layer> <lon> <lat> [--radius m] [--limit n]
|
||||
geodata radar-sync [forecast|reflectivity]
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
`libs/geodata` is a **library first** (the map app will embed it for radar
|
||||
sync, periodic source refresh, and the query surface), with a thin `geodata`
|
||||
CLI. Workspace member; depends only on `makepad-mbtile-reader`,
|
||||
`makepad-map-nav`, `flate2`, `serde_json`.
|
||||
|
||||
Data flow per layer:
|
||||
|
||||
```
|
||||
bulk source file ──fetch.rs──▶ local/overlays/cache/ (+ .meta.json)
|
||||
(GPKG / zip / json.gz / GeoTIFF, verified URLs, polite)
|
||||
──layers/<id>.rs──▶ parse + reproject to WGS84
|
||||
──tiler.rs / spool.rs──▶ clipped MVT features per (z,x,y)
|
||||
└──▶ sidecar.rs features records
|
||||
──MbtilesWriter──▶ local/overlays/nl-<id>.mbtiles
|
||||
├─ tiles (gzip MVT | PNG)
|
||||
├─ features (query sidecar)
|
||||
└─ metadata (license, classmap, TileJSON)
|
||||
```
|
||||
|
||||
Key decisions and why:
|
||||
|
||||
- **One .mbtiles per layer.** Independently rebuildable/shippable/deletable;
|
||||
the base-map archive (owned by the import-pipeline track) is never touched.
|
||||
- **Bulk downloads only.** No API paging/spidering — search-shaped APIs
|
||||
(Wikipedia, Overpass, NDW live traffic, GTFS-RT) will be integrated in the
|
||||
map app, queried by viewport context at runtime. Radar is the one live
|
||||
source here and polls its official file API at most once per data refresh.
|
||||
- **Reuse over reimplementation** (per the "don't duplicate the map engine's
|
||||
math" rule): mercator projection comes from `makepad_map_nav::geo`; SQLite
|
||||
read *and* write go through `makepad-mbtile-reader` — GeoPackages are just
|
||||
SQLite files, so the reader ingests PDOK/CBS/BAG data directly. Genuinely
|
||||
new code only where nothing existed: RD New ↔ WGS84 polynomials, WKB
|
||||
parsing, MVT encoding, a GeoTIFF subset reader, a PNG codec, clipping/tiling.
|
||||
|
||||
## Politeness (the "don't get banned" layer)
|
||||
|
||||
Enforced inside `fetch.rs`/`radar.rs` so no layer module can violate it:
|
||||
|
||||
- descriptive User-Agent with contact address; one transfer at a time; 1 s
|
||||
pause after every network hit
|
||||
- a cached file is not even *revalidated* before its `recheck_days` age
|
||||
(BAG 45 d, CBS 90 d, chargers 2 d, DEM ~forever); afterwards
|
||||
If-Modified-Since revalidation makes an unchanged file cost one 304, zero bytes
|
||||
- interrupted downloads resume (`.part` + `-C -`); optional `--limit-rate`
|
||||
for small government origin servers
|
||||
- radar: the poll gate is armed *before* the request (a failing API cannot be
|
||||
hammered — this was found and fixed when the shared KNMI anonymous key
|
||||
429'd), plus request pacing and a single 429 backoff-retry
|
||||
- every cached file carries a `.meta.json` (url, license, fetch time)
|
||||
|
||||
## Database contract (what the renderer AI and app consume)
|
||||
|
||||
Written by `MbtilesWriter`: 64 KB pages, deterministic block-major rowids
|
||||
(direct-seek tile lookup), passes `PRAGMA integrity_check` — verified on all
|
||||
nine outputs. Standard SQLite tooling reads everything.
|
||||
|
||||
- `tiles` — gzip MVT 2.1, extent 4096 (vector; `format=pbf`) or PNG 256 px
|
||||
(raster; `format=png`). The renderer's gzip sniff + MVT decode work as-is;
|
||||
MVT winding follows the spec (exterior CW in y-down tile coords).
|
||||
- `metadata` — mbtiles standard keys, `attribution` + `license` (for the
|
||||
attribution screen), TileJSON-style `json.vector_layers` (field names and
|
||||
types per MVT layer — the renderer can style data-driven without guessing),
|
||||
`geodata_encoding` + `geodata_classmap` for rasters, `geodata_built_unix`.
|
||||
- `features` — the query sidecar (vector layers): `cell, layer, name,
|
||||
min_lon, min_lat, max_lon, max_lat, attrs (JSON), ring (JSON|null)` with
|
||||
rowid = `(z12 grid cell << 24) | seq`. Bbox queries become pruned b-tree
|
||||
range scans — no SQL engine, no extra index. Polygon layers opt into
|
||||
storing a simplified exterior ring (≤96 pts, ~10 m tolerance) for exact
|
||||
point-in-polygon.
|
||||
|
||||
## Per-layer notes
|
||||
|
||||
**nature** — PDOK Natura 2000 + wetlands GPKGs (CC0, ~15 MB). MVT layers
|
||||
`natura2000` (naam_n2k, sitecodes, status…) and `wetlands`; every feature also
|
||||
tagged `kind`. Rings stored.
|
||||
|
||||
**chargers** — NDW national OCPI aggregate (`charging_point_locations_ocpi.json.gz`,
|
||||
open data, refreshed ~daily; recheck 2 d makes this the natural app-side
|
||||
periodic re-sync candidate). Attrs: name, operator, city, evses, connectors,
|
||||
max_kw (from `max_electric_power` or volts×amps fallback).
|
||||
|
||||
**demographics** — CBS Vierkantstatistieken 500 m (z8–z11) + 100 m (z12–z13)
|
||||
grids; 139/134 columns of per-cell stats carried through with CBS suppression
|
||||
sentinels (negatives) dropped. Square cells mean bbox containment is exact —
|
||||
no rings needed.
|
||||
|
||||
**wijkbuurt** — CBS Wijk- en Buurtkaart 2025: `gemeenten` (z6–8), `wijken`
|
||||
(z9–10), `buurten` (z11–13) with kerncijfers. Rings stored → exact
|
||||
administrative lookup for any coordinate.
|
||||
|
||||
**transit** — OVapi static GTFS (CC0, no key). CSVs are streamed out of the
|
||||
234 MB zip (`unzip -p`, never extracted). `stops` points (stations from z8,
|
||||
stops from z10) + `routes` lines for rail/tram/metro/ferry (bus shapes
|
||||
excluded deliberately: they follow roads already on the map and triple the
|
||||
size). Live vehicle positions are explicitly app-side (GTFS-RT), not here.
|
||||
|
||||
**buildings-age** — PDOK `bag-light.gpkg` (CC0, 7.8 GB, monthly). The one
|
||||
layer that can't fit the in-memory tiler: `SpoolTiler` clips features in one
|
||||
pass and spools compact records to per-(zoom, 256×256-block) disk files — NL
|
||||
is only 5 blocks at z13/z14 — then loads one block at a time in writer rowid
|
||||
order. 11.4M polygons → 20,229 tiles + an 11.4M-row sidecar in 91 s, peak
|
||||
memory one block. MVT layer `bag`: bouwjaar, status.
|
||||
|
||||
**terrain** — Copernicus GLO-30 DSM COGs from anonymous AWS S3, 20 one-degree
|
||||
cells for NL; all-ocean cells 404 upstream and are treated as sea level (2 of
|
||||
20 did — as expected). Terrarium encoding is the shared contract: renderer
|
||||
hillshade/3D terrain later, and `map_nav` samples the same file to bake
|
||||
per-edge climb/descent for EV routing (the elevation thread from
|
||||
`datasources.md`).
|
||||
|
||||
**noise** — RIVM "Geluid in Nederland" Lden all-sources 10 m GeoTIFF (CC0),
|
||||
EPSG:28992 — sampled through the WGS84→RD inverse polynomial. dB values are
|
||||
binned into 5 dB classes (1: <45 … 8: ≥75) at encode time; the classmap
|
||||
metadata carries labels + colors. 23-minute build (≈800M bilinear samples of
|
||||
a deflate-tiled national raster through the pure-Rust TIFF reader) — fine for
|
||||
a yearly-refresh batch job.
|
||||
|
||||
**flood** — JRC Europe river flood hazard RP100 depth GeoTIFF (CC BY, WGS84
|
||||
~90 m), binned to depth classes (<0.5 m … ≥4 m). Small and cheap. Follow-ups
|
||||
noted: JRC's `spurious_depth_areas` mask, and PDOK's official ROR zone
|
||||
polygons (CC0) as a vector companion.
|
||||
|
||||
**radar (live)** — `radar.rs`: KNMI Data Platform datasets `radar_forecast`
|
||||
(+2 h nowcast, whole animation in one ~0.5 MB HDF5 file every 5 min; the
|
||||
map-app default) and `radar_reflectivity_composites` (5-min frames, keeps
|
||||
~1 h). `RadarSync::sync()` is safe to call arbitrarily often — network at
|
||||
most once per `min_poll_secs` (240 s default), downloads only missing files,
|
||||
prunes old ones, `state()` never touches the network. API key: config →
|
||||
`KNMI_API_KEY` env → the documented shared anonymous key (dev-only; its
|
||||
quota is shared — register a free personal key for real use).
|
||||
|
||||
## The query surface (LLM goal)
|
||||
|
||||
```rust
|
||||
let mut db = query::LayerDb::open("local/overlays/nl-wijkbuurt.mbtiles")?;
|
||||
db.query_point(4.8926, 52.3731, 10)?; // exact point-in-polygon
|
||||
db.query_radius(4.8926, 52.3731, 400., 5)?; // nearest-first + distances
|
||||
db.query_bbox(min_lon, min_lat, max_lon, max_lat, 100)?;
|
||||
```
|
||||
|
||||
Live-verified answers (via `geodata query`):
|
||||
- Dam Square → gemeente **Amsterdam** (pop 934,526) → wijk
|
||||
**Burgwallen-Nieuwe Zijde** (4,345) → buurt **Nieuwe Kerk e.o.** (830,
|
||||
9,827 /km²) — exact containment via stored rings.
|
||||
- Chargers near Dam: **Krasnapolsky at 128 m** (Eneco, 22 kW), with distances.
|
||||
- Royal Palace → **bouwjaar 1655**, "Pand in gebruik" — out of 11.4M buildings.
|
||||
- Population grid cell at Dam: 1,735 inhabitants (vk500).
|
||||
|
||||
The intended wiring: the map app exposes these as LLM tool calls (and as
|
||||
tap-to-inspect UI). Every layer answers from the same three query shapes, and
|
||||
attrs come back as JSON with the source's own column names.
|
||||
|
||||
## Infrastructure added to shared crates (all additive)
|
||||
|
||||
`libs/mbtile_reader`:
|
||||
- `open_sqlite` / `schema_entries` / `for_each_row` — generic SQLite table
|
||||
reading; what lets GeoPackages be ingested with zero new dependencies.
|
||||
- `for_each_row_in_range` — pruned b-tree descent for rowid ranges; the
|
||||
engine under the sidecar's spatial queries.
|
||||
- Writer: `begin_extra_table` / `write_extra_row` (+ `WriterValue`, float and
|
||||
NULL record support) — arbitrary extra tables alongside `tiles`/`metadata`.
|
||||
- All pre-existing writer/reader tests still pass; new outputs pass
|
||||
`PRAGMA integrity_check`.
|
||||
|
||||
`libs/geodata` internals worth knowing:
|
||||
- `geo.rs` — RD New ↔ WGS84 both directions (Schreutelkamp/Strang van Hees
|
||||
polynomials), round-trip tested < 1 m at Amsterdam/Rotterdam/Maastricht/
|
||||
Groningen; `tile_order_key` replicating the writer's rowid order.
|
||||
- `tiff.rs` — GeoTIFF subset: tiled + striped, deflate/LZW/uncompressed,
|
||||
predictors 1/2/3 (incl. the floating-point predictor), int/float samples,
|
||||
ModelPixelScale/Tiepoint georeferencing, GDAL nodata, block cache. Proven
|
||||
against Copernicus COGs, the JRC Europe raster, and RIVM's RD raster.
|
||||
- `png.rs` — encoder + decoder for gray8/RGB8 (hand-rolled CRC32, round-trip
|
||||
tested). `raster.rs` — sampler → 256 px tile pyramid.
|
||||
- `mvt.rs` — spec-correct MVT 2.1 encoder with key/value dedup.
|
||||
- `spool.rs` — the country-scale streaming tiler described above.
|
||||
|
||||
Test suite: 7 unit tests in geodata (projections, round-trips, ordering, PNG,
|
||||
ISO-8601), 9 in mbtile_reader. Byte-level MVT/gzip verification and SQLite
|
||||
integrity checks run against every produced artifact.
|
||||
|
||||
## Open follow-ups (in rough priority order)
|
||||
|
||||
1. **Radar HDF5 → raster decode** so frames render (and a small
|
||||
`RadarFrame::to_grid()` for querying "rain at my location in 30 min");
|
||||
OPERA/MeteoGate (CC-BY COGs) after that for Europe-wide radar.
|
||||
2. **Raster point-query helper** in `query.rs` (PNG-decode a tile, return
|
||||
elevation / class + classmap label) — completes the LLM surface for
|
||||
terrain/noise/flood ("how high / how loud / what flood depth here").
|
||||
3. **EV grade baking**: map_nav's `nav-build` samples `nl-terrain.mbtiles`
|
||||
per edge (the datasources.md EV thread).
|
||||
4. Flood vector companion (PDOK ROR, CC0) + JRC spurious-areas mask.
|
||||
5. Scale-out to Europe per country as sources allow (the machinery is
|
||||
region-agnostic; NL-specific parts are just the RD transform and source
|
||||
URLs).
|
||||
6. Minor: neighbor-tile point buffering, Douglas-Peucker simplification,
|
||||
BigTIFF, replacing shelled-out `unzip`.
|
||||
|
||||
## Verification ledger
|
||||
|
||||
- All 9 mbtiles: `PRAGMA integrity_check` = ok (validates the hand-rolled
|
||||
SQLite writer end-to-end, incl. extra tables and >1 GB files).
|
||||
- MVT: tile payloads gzip-wrapped (renderer sniff), protobuf structure
|
||||
byte-checked, winding per spec.
|
||||
- Geo math: RD origin exact; RD↔WGS84 round trip < 1 m ×4 cities; Amsterdam
|
||||
sanity; ISO-8601 epoch math vs hand-computed constants.
|
||||
- Content spot-checks: Royal Palace 1655; Dam admin hierarchy; charger
|
||||
distances; A10 noise spine; terrain cell coverage 18/20 with sea handling.
|
||||
- Radar: real nowcast downloaded; poll gate verified (second call → zero
|
||||
network); 429 backoff exercised against the live saturated anonymous tier.
|
||||
|
|
@ -13,6 +13,4 @@ repository = "https://github.com/makepad/makepad/"
|
|||
metadata.makepad-auto-version = "Xn0xvOdZChZbltM8tMBk0zLdOBc="
|
||||
|
||||
[dependencies]
|
||||
## Note: we must not use local 'path' dependencies on `makepad-jni-sys`
|
||||
## in order to guarantee that only one instance of each crate exists in the app binary.
|
||||
makepad-jni-sys = {version = "0.4.0"}
|
||||
makepad-jni-sys = {path = "../jni-sys"}
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ pub unsafe extern "C" fn JNI_OnLoad(
|
|||
}
|
||||
|
||||
#[no_mangle]
|
||||
extern "C" fn jni_on_load(vm: *mut std::ffi::c_void) {
|
||||
pub extern "C" fn jni_on_load(vm: *mut std::ffi::c_void) {
|
||||
unsafe {
|
||||
VM = vm as _;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -349,13 +349,6 @@ pub const kCMTimeInvalid: CMTime = CMTime {
|
|||
epoch: 0,
|
||||
};
|
||||
|
||||
pub const kCMTimePositiveInfinity: CMTime = CMTime {
|
||||
value: 0,
|
||||
timescale: 0,
|
||||
flags: kCMTimeFlags_Valid | kCMTimeFlags_PositiveInfinity,
|
||||
epoch: 0,
|
||||
};
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct CMSampleTimingInfo {
|
||||
|
|
@ -1019,10 +1012,10 @@ pub struct MTLClearColor {
|
|||
#[allow(non_camel_case_types)]
|
||||
#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq)]
|
||||
pub enum MTLPixelFormat {
|
||||
//RGBA8Unorm = 70,
|
||||
R8Unorm = 10,
|
||||
RG8Unorm = 30,
|
||||
R32Float = 55,
|
||||
RGBA8Unorm = 70,
|
||||
BGRA8Unorm = 80,
|
||||
RGBA16Float = 115,
|
||||
RGBA32Float = 125,
|
||||
|
|
|
|||
|
|
@ -6,20 +6,13 @@ description = "Conversational voice pipeline for Makepad apps: streamed agent re
|
|||
license = "MIT OR Apache-2.0"
|
||||
|
||||
[features]
|
||||
# Speech synthesis (Kokoro). Default ON so existing consumers are unchanged,
|
||||
# but separable: the model plus its embedded pronunciation lexicon is ~10 MB of
|
||||
# binary and ~327 MB resident, which a build that can never speak should not
|
||||
# carry. With it off, SpeechOutput still exists and every non-audio path
|
||||
# behaves identically — the worker just drains its queue in silence.
|
||||
default = ["tts"]
|
||||
tts = ["dep:makepad-tts"]
|
||||
# The local filtering LLM (QwenFilter) on makepad-llama; heavy, so opt-in.
|
||||
local-llm = ["dep:makepad-llama"]
|
||||
|
||||
[dependencies]
|
||||
makepad-widgets = { path = "../../widgets" }
|
||||
makepad-ai = { path = "../makepad_ai" }
|
||||
makepad-tts = { path = "../tts", optional = true }
|
||||
makepad-tts = { path = "../tts" }
|
||||
makepad-llama = { path = "../llama", optional = true }
|
||||
|
||||
[[bin]]
|
||||
|
|
|
|||
|
|
@ -9,10 +9,8 @@
|
|||
//! being generated. Lifted out of the gamemaker example so any app can bolt a
|
||||
//! voice onto an agent.
|
||||
|
||||
#[cfg(feature = "tts")]
|
||||
use makepad_tts::Speaker;
|
||||
use makepad_widgets::makepad_draw::audio::AudioBuffer;
|
||||
#[cfg(feature = "tts")]
|
||||
use makepad_widgets::log;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{mpsc, Arc, Mutex};
|
||||
|
|
@ -92,45 +90,17 @@ impl SpeechOutput {
|
|||
let worker_generation = generation.clone();
|
||||
let voice = voice.to_string();
|
||||
std::thread::spawn(move || {
|
||||
// Built without `tts`: the synthesis stack (Kokoro plus its
|
||||
// embedded pronunciation lexicon) is ~10 MB of binary, so a build
|
||||
// that can never speak does not link it. Drain the queue so senders
|
||||
// never block — every other path (text, muting, generation
|
||||
// cancellation) behaves exactly as it does with speech on.
|
||||
#[cfg(not(feature = "tts"))]
|
||||
{
|
||||
let _ = (&voice, &worker_playback);
|
||||
while let Ok((generation, _text)) = requests.recv() {
|
||||
let _ = generation.min(worker_generation.load(Ordering::Relaxed));
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Off the main thread on purpose: synthesis blocks until the whole
|
||||
// utterance is rendered.
|
||||
//
|
||||
// The speaker is built on the FIRST request, not here: the Kokoro
|
||||
// model is ~327 MB resident, and an app that never speaks (text
|
||||
// tier, muted, or a session where nobody triggers a reply) should
|
||||
// not pay for it. Construction is still off the main thread, so
|
||||
// the load cost lands on the worker either way.
|
||||
#[cfg(feature = "tts")]
|
||||
let mut speaker: Option<Speaker> = None;
|
||||
#[cfg(feature = "tts")]
|
||||
let mut speaker = Speaker::from_makepad_env_with_voice(&voice);
|
||||
log!("tts: backend {:?}", speaker.kind());
|
||||
// Discarded warm-up: Kokoro's first synthesis initializes the Metal
|
||||
// context on this thread; better now than on the first reply.
|
||||
let _ = speaker.synthesize("Hi.");
|
||||
while let Ok((generation, text)) = requests.recv() {
|
||||
if generation != worker_generation.load(Ordering::Relaxed) {
|
||||
continue;
|
||||
}
|
||||
let speaker = match speaker {
|
||||
Some(ref mut speaker) => speaker,
|
||||
ref mut none => {
|
||||
let mut fresh = Speaker::from_makepad_env_with_voice(&voice);
|
||||
log!("tts: backend {:?}", fresh.kind());
|
||||
// Discarded warm-up: Kokoro's first synthesis
|
||||
// initializes the Metal context on this thread.
|
||||
let _ = fresh.synthesize("Hi.");
|
||||
none.insert(fresh)
|
||||
}
|
||||
};
|
||||
match speaker.synthesize(&text) {
|
||||
Ok(audio) if !audio.is_empty() => {
|
||||
// Re-check: synthesis is slow enough that a cancel can
|
||||
|
|
@ -301,57 +271,3 @@ pub fn spoken_text(markdown: &str) -> String {
|
|||
}
|
||||
spoken.trim().to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Constructing the output must NOT load the synthesis model: Kokoro is
|
||||
/// ~327 MB resident, and an app that never speaks should not pay for it.
|
||||
/// Model load happens on the first enqueued utterance instead.
|
||||
///
|
||||
/// Proxy for "did not load": construction returns promptly. A real load
|
||||
/// reads hundreds of MB off disk and warms a Metal context, which cannot
|
||||
/// happen in this budget on any machine we build on.
|
||||
#[test]
|
||||
fn constructing_speech_output_does_not_load_the_model() {
|
||||
let start = std::time::Instant::now();
|
||||
let speech = SpeechOutput::new("bm_fable.mkvoice");
|
||||
let elapsed = start.elapsed();
|
||||
assert!(
|
||||
speech.playback().lock().unwrap().samples.is_empty(),
|
||||
"nothing should be synthesized before anything is said"
|
||||
);
|
||||
assert!(
|
||||
elapsed < std::time::Duration::from_millis(250),
|
||||
"construction took {elapsed:?} — the model is being loaded eagerly again"
|
||||
);
|
||||
}
|
||||
|
||||
/// The lazy path must still speak. Ignored by default: it loads the real
|
||||
/// ~327 MB model and takes seconds.
|
||||
///
|
||||
/// Model paths resolve relative to the CWD, which under `cargo test` is
|
||||
/// the crate dir, not the repo root — so point them at the real files or
|
||||
/// this silently falls back to a different backend and proves nothing:
|
||||
///
|
||||
/// ```text
|
||||
/// MAKEPAD_TTS_MODEL=$REPO/kokoro-v1_0.mktts \
|
||||
/// MAKEPAD_TTS_VOICE=$REPO/bm_fable.mkvoice \
|
||||
/// cargo test -p makepad-converse --release lazily_loaded -- --ignored
|
||||
/// ```
|
||||
#[test]
|
||||
#[ignore = "loads the real Kokoro model (~327 MB); needs MAKEPAD_TTS_* paths"]
|
||||
fn lazily_loaded_speaker_still_produces_audio() {
|
||||
let mut speech = SpeechOutput::new("bm_fable.mkvoice");
|
||||
speech.enqueue("Testing the lazy speech path.");
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(120);
|
||||
while std::time::Instant::now() < deadline {
|
||||
if !speech.playback().lock().unwrap().samples.is_empty() {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
}
|
||||
panic!("no audio produced within the timeout — the lazy path is broken");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -1,15 +0,0 @@
|
|||
[package]
|
||||
name = "makepad-game-assets"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "AI-queryable index over Makepad Arcade's stock CC0 model library (see game.md)"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
[dependencies]
|
||||
|
||||
# Test-only: the real skin loader, so "this character is rigged with N joints
|
||||
# and M clips" is proven by the code that will actually load it rather than by
|
||||
# this crate's own lightweight probe agreeing with itself. Kept a dev-dependency
|
||||
# so the shipped index stays dependency-free.
|
||||
[dev-dependencies]
|
||||
makepad-game-render = { path = "../render" }
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
//! What rigged characters the library holds, and what the AI is told about them.
|
||||
fn main() {
|
||||
use makepad_game_assets::agent;
|
||||
let idx = makepad_game_assets::AssetIndex::build(std::path::Path::new(
|
||||
"apps/arcade/resources",
|
||||
));
|
||||
for c in idx.casts() {
|
||||
println!(
|
||||
"rig {:>2} joints — {} members, up to {} clips",
|
||||
c.joints, c.members.len(), c.max_clips
|
||||
);
|
||||
println!(" states: {}", c.shared_states.join(", "));
|
||||
println!(" members: {}", c.members.join(", "));
|
||||
if c.richest.len() < c.members.len() {
|
||||
println!(" richest ({} clips): {}", c.max_clips, c.richest.join(", "));
|
||||
}
|
||||
println!();
|
||||
}
|
||||
let json = agent::casts_to_json(&agent::execute_cast(&idx, None), 6);
|
||||
println!("find_cast JSON ({} bytes):\n{}", json.len(), json);
|
||||
println!();
|
||||
for q in ["a character that can attack", "a skeleton enemy", "someone who can run and jump"] {
|
||||
let hits = idx.find(q);
|
||||
let top: Vec<&str> = hits.iter().filter(|h| h.entry.rigged).take(3)
|
||||
.map(|h| h.entry.id.as_str()).collect();
|
||||
println!("{q:<34} -> {}", top.join(", "));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
use makepad_game_assets::{agent, AssetIndex};
|
||||
fn main() {
|
||||
let idx = AssetIndex::build(std::path::Path::new("apps/arcade/resources"));
|
||||
println!("index: {} entries ({} models, {} sounds, {} music)\n",
|
||||
idx.len(),
|
||||
idx.count_of(makepad_game_assets::AssetKind::Model),
|
||||
idx.count_of(makepad_game_assets::AssetKind::Sound),
|
||||
idx.count_of(makepad_game_assets::AssetKind::Music));
|
||||
println!("--- prompt summary ({} chars) ---\n{}\n", agent::library_summary(&idx).len(), agent::library_summary(&idx));
|
||||
for q in ["i want a lorry", "a big scary monster", "something to hide behind",
|
||||
"sound when you crash into a wall", "happy win music", "a digger like at the roadworks"] {
|
||||
let r = agent::execute(&idx, &agent::FindParams::new(q));
|
||||
let top: Vec<String> = r.iter().take(3).map(|x| format!("{} ({})", x.id, x.name)).collect();
|
||||
println!("{:38} -> {}", format!("\"{q}\""), if top.is_empty() { "(no hits)".into() } else { top.join("\n ") });
|
||||
}
|
||||
println!("\n--- compact JSON handed to the model ---\n{}", agent::results_to_json(&agent::execute(&idx, &agent::FindParams::new("red truck"))));
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
fn main() {
|
||||
let root = std::path::PathBuf::from("apps/arcade/resources");
|
||||
let idx = makepad_game_assets::AssetIndex::build(&root);
|
||||
let kits = idx.kits();
|
||||
println!("{} kits, {} entries total", kits.len(), idx.len());
|
||||
println!("{:<24} {:>6} {:>8} roles", "kit", "tiles", "size");
|
||||
for k in &kits {
|
||||
let roles: Vec<String> = k.roles.iter().map(|(r, n)| format!("{r}:{n}")).collect();
|
||||
let unclassified = k.tiles - k.roles.iter().map(|(_, n)| n).sum::<u32>();
|
||||
println!("{:<24} {:>6} {:>8} {} (unclassified {})",
|
||||
k.pack, k.tiles,
|
||||
k.tile_size.map(|s| format!("{s:.2}")).unwrap_or("-".into()),
|
||||
roles.join(" "), unclassified);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
//! Ad-hoc query probe: prints the top hits and their kind for a set of
|
||||
//! queries, so a ranking change can be judged against the queries it might
|
||||
//! regress rather than only the ones it fixes.
|
||||
|
||||
use makepad_game_assets::AssetIndex;
|
||||
|
||||
fn main() {
|
||||
let idx = AssetIndex::build(std::path::Path::new("apps/arcade/resources"));
|
||||
let queries: Vec<String> = std::env::args().skip(1).collect();
|
||||
let queries: Vec<&str> = if queries.is_empty() {
|
||||
vec![
|
||||
"spaceship",
|
||||
"glass smashing",
|
||||
"metal clang",
|
||||
"laser gun",
|
||||
"explosion",
|
||||
"footsteps on wood",
|
||||
"coins",
|
||||
"somewhere for my guy to stand",
|
||||
"spaceship engine",
|
||||
"a boat",
|
||||
"police car",
|
||||
]
|
||||
} else {
|
||||
queries.iter().map(|s| s.as_str()).collect()
|
||||
};
|
||||
for q in queries {
|
||||
println!("\"{q}\"");
|
||||
for h in idx.find(q).iter().take(4) {
|
||||
println!(
|
||||
" {:6} {:5} {}",
|
||||
h.score,
|
||||
format!("{:?}", h.entry.kind),
|
||||
h.entry.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
use makepad_game_assets::{agent, AssetIndex, AssetKind};
|
||||
use std::time::Instant;
|
||||
fn main() {
|
||||
let t = Instant::now();
|
||||
let idx = AssetIndex::build(std::path::Path::new("apps/arcade/resources"));
|
||||
let build_ms = t.elapsed().as_millis();
|
||||
let kw: usize = idx.entries().iter().map(|e| e.keywords.len()).sum();
|
||||
let bytes: usize = idx.entries().iter().map(|e|
|
||||
e.id.len()+e.name.len()+e.path.as_os_str().len()+e.pack.len()
|
||||
+ e.keywords.iter().map(|k| k.len()+24).sum::<usize>()
|
||||
+ e.categories.iter().map(|c| c.len()+24).sum::<usize>() + 160).sum();
|
||||
println!("build: {build_ms} ms | entries {} ({} models, {} sounds, {} music)",
|
||||
idx.len(), idx.count_of(AssetKind::Model), idx.count_of(AssetKind::Sound), idx.count_of(AssetKind::Music));
|
||||
println!("keywords total {kw} (avg {:.1}/entry) | approx heap {:.1} MB", kw as f32/idx.len() as f32, bytes as f32/1048576.0);
|
||||
let s = agent::library_summary(&idx);
|
||||
println!("summary {} chars:\n{s}\n", s.len());
|
||||
let t2 = Instant::now();
|
||||
for q in ["truck","tree","something to drive"] { let _ = idx.find(q); }
|
||||
println!("3 queries: {} us", t2.elapsed().as_micros());
|
||||
for q in std::env::args().skip(1) {
|
||||
let (q, kind) = match q.split_once('#') { Some((a,b))=>(a.to_string(),Some(b.to_string())), None=>(q,None) };
|
||||
let mut p = agent::FindParams::new(&q);
|
||||
if let Some(k)=&kind { p = p.with_kind_str(k); }
|
||||
let r = agent::execute(&idx, &p);
|
||||
let top: Vec<String> = r.iter().take(3).map(|x| x.id.clone()).collect();
|
||||
println!("{:34} -> {}", q, if top.is_empty(){"(none)".into()}else{top.join(" | ")});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,579 +0,0 @@
|
|||
//! Agent-facing surface: a backend-agnostic tool the model calls to SEARCH the
|
||||
//! library, plus a tiny prompt blurb telling it the library exists.
|
||||
//!
|
||||
//! The agent never receives the catalogue. Hundreds of entries do not fit in a
|
||||
//! prompt, every result token is paid for, and the library grows with every
|
||||
//! pack — so the contract is "call `find_model`, never guess an id".
|
||||
//!
|
||||
//! Everything here is plain data so any backend (Claude Code, direct API,
|
||||
//! OpenAI-compatible) can expose it; this module deliberately does not depend
|
||||
//! on `makepad_ai`.
|
||||
|
||||
use crate::{AssetIndex, AssetKind, Filters, Palette, Source, Spread, VarietyParams};
|
||||
|
||||
/// A tool parameter, in the subset of JSON Schema every provider understands.
|
||||
pub struct ToolParam {
|
||||
pub name: &'static str,
|
||||
pub ty: &'static str,
|
||||
pub description: &'static str,
|
||||
pub required: bool,
|
||||
}
|
||||
|
||||
/// A provider-neutral tool description. Render it into whatever shape the
|
||||
/// backend wants.
|
||||
pub struct ToolDescriptor {
|
||||
pub name: &'static str,
|
||||
pub description: &'static str,
|
||||
pub params: &'static [ToolParam],
|
||||
}
|
||||
|
||||
pub const FIND_MODEL: ToolDescriptor = ToolDescriptor {
|
||||
name: "find_model",
|
||||
description: "Search the stock asset library — 3D models, sound effects and music — by \
|
||||
description, and get back ids you can use. Use plain language: what the thing \
|
||||
is, or what it is FOR (\"red truck\", \"something to hide behind\", \"trees \
|
||||
for a forest\", \"sound when you crash\", \"happy win music\"). Always call \
|
||||
this before using an asset id; never invent one. \
|
||||
RESULTS ARE DISTINCT MODELS, NOT RANKED DUPLICATES: if you need several of \
|
||||
something — houses in a village, trees in a wood, rocks on a hill — ask for \
|
||||
several here and PLACE A DIFFERENT ONE EACH TIME. Placing result #1 five \
|
||||
times is the single most common way to make a scene look cheap.",
|
||||
params: &[
|
||||
ToolParam {
|
||||
name: "query",
|
||||
ty: "string",
|
||||
description: "What you are looking for, in plain language.",
|
||||
required: true,
|
||||
},
|
||||
ToolParam {
|
||||
name: "kind",
|
||||
ty: "string",
|
||||
description: "Optional: \"model\", \"sound\" or \"music\".",
|
||||
required: false,
|
||||
},
|
||||
ToolParam {
|
||||
name: "category",
|
||||
ty: "string",
|
||||
description: "Optional category filter, e.g. \"vehicle\", \"nature/tree\", \
|
||||
\"character/enemy\", \"sound/impact\".",
|
||||
required: false,
|
||||
},
|
||||
ToolParam {
|
||||
name: "rigged_only",
|
||||
ty: "boolean",
|
||||
description: "Models only: require a skeleton (can be animated).",
|
||||
required: false,
|
||||
},
|
||||
ToolParam {
|
||||
name: "max_results",
|
||||
ty: "integer",
|
||||
description: "How many DISTINCT models to return (default 5, max 20). Ask for as \
|
||||
many as you intend to place — 6 houses, 8 trees — and use them all.",
|
||||
required: false,
|
||||
},
|
||||
ToolParam {
|
||||
name: "spread",
|
||||
ty: "string",
|
||||
description: "How different the results should be. \"mixed\" (default) spreads \
|
||||
across kinds before repeating a shape — right for a forest or a \
|
||||
street. \"kinds\" returns one of each kind only, for maximum \
|
||||
variety. \"variants\" returns members of one family, e.g. the same \
|
||||
house in several designs, for a row that should look related.",
|
||||
required: false,
|
||||
},
|
||||
ToolParam {
|
||||
name: "seed",
|
||||
ty: "integer",
|
||||
description: "Optional: changes which models are picked while keeping the same \
|
||||
picks on every re-run. Vary it to reroll a scene's look.",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/// Kenney authors each pack as a matched set, so drawing a whole scene from one
|
||||
/// pack is what makes it look authored. A palette hands the model that set in
|
||||
/// one call, instead of it running five unrelated searches and mixing five art
|
||||
/// styles into one village.
|
||||
pub const FIND_PALETTE: ToolDescriptor = ToolDescriptor {
|
||||
name: "find_palette",
|
||||
description: "Get a COHERENT SET of models that visually belong together — all from one \
|
||||
art pack — grouped by what each is for. Use this when building a whole \
|
||||
scene (\"a village\", \"a city street\", \"a spooky graveyard\", \"a space \
|
||||
station\") instead of searching separately for houses, then trees, then \
|
||||
fences: those searches can land in different art styles and the result \
|
||||
looks like a junk drawer. Returns the pack name and its groups with several \
|
||||
ids each, so you can place a DIFFERENT model from a group every time.",
|
||||
params: &[
|
||||
ToolParam {
|
||||
name: "query",
|
||||
ty: "string",
|
||||
description: "The kind of place you are building, e.g. \"village\", \"race track\", \
|
||||
\"dungeon\", \"suburb\", \"pirate island\".",
|
||||
required: true,
|
||||
},
|
||||
ToolParam {
|
||||
name: "seed",
|
||||
ty: "integer",
|
||||
description: "Optional: reroll which members of each group come first.",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/// Parameters for one `find_model` call. The app fills these from whatever the
|
||||
/// backend handed back; all optional except `query`.
|
||||
#[derive(Default, Debug)]
|
||||
pub struct FindParams {
|
||||
pub query: String,
|
||||
pub kind: Option<AssetKind>,
|
||||
pub category: Option<String>,
|
||||
pub rigged_only: bool,
|
||||
pub max_results: Option<usize>,
|
||||
/// How different the results should be from each other. `None` keeps the
|
||||
/// old plain-ranked behaviour for callers that genuinely want a ranking
|
||||
/// (`best`, `resolve_or_explain`); the agent path always sets it.
|
||||
pub spread: Option<Spread>,
|
||||
pub seed: u64,
|
||||
}
|
||||
|
||||
impl FindParams {
|
||||
pub fn new(query: &str) -> Self {
|
||||
FindParams { query: query.to_string(), ..Default::default() }
|
||||
}
|
||||
/// Parse the `spread` argument. An unknown value means the default rather
|
||||
/// than an error — a stray word should not fail the call.
|
||||
pub fn with_spread_str(mut self, spread: &str) -> Self {
|
||||
self.spread = Some(match spread.to_lowercase().as_str() {
|
||||
"kinds" | "kind" | "distinct" => Spread::Kinds,
|
||||
"variants" | "variant" | "family" => Spread::Variants,
|
||||
_ => Spread::Mixed,
|
||||
});
|
||||
self
|
||||
}
|
||||
pub fn with_seed(mut self, seed: u64) -> Self {
|
||||
self.seed = seed;
|
||||
self
|
||||
}
|
||||
/// Parse the `kind` argument a model would send. Unknown values mean "no
|
||||
/// filter" rather than an error — a stray word should not fail the call.
|
||||
pub fn with_kind_str(mut self, kind: &str) -> Self {
|
||||
self.kind = match kind.to_lowercase().as_str() {
|
||||
"model" => Some(AssetKind::Model),
|
||||
"sound" | "sfx" => Some(AssetKind::Sound),
|
||||
"music" => Some(AssetKind::Music),
|
||||
_ => None,
|
||||
};
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// One compact result row. Deliberately narrow: id, name, category, rigged and
|
||||
/// size are all the model needs to choose. Paths and index internals are not
|
||||
/// the agent's business and would cost tokens for nothing.
|
||||
pub struct FindResult {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub kind: AssetKind,
|
||||
pub category: String,
|
||||
pub rigged: bool,
|
||||
pub size: Option<[f32; 3]>,
|
||||
/// Audio only: a looping sound is held, not fired.
|
||||
pub loops: bool,
|
||||
/// False when the repo cannot decode this asset yet (Kenney audio is Ogg
|
||||
/// Vorbis and there is no vorbis decoder in the tree).
|
||||
pub playable: bool,
|
||||
}
|
||||
|
||||
/// Run a `find_model` call.
|
||||
///
|
||||
/// Results are DISTINCT models by default. The old behaviour — a plain ranked
|
||||
/// list whose top entries are often near-identical siblings — is what made a
|
||||
/// caller place the same house five times, so the variety pass is the default
|
||||
/// and a plain ranking has to be asked for explicitly (`spread: None`).
|
||||
pub fn execute(index: &AssetIndex, params: &FindParams) -> Vec<FindResult> {
|
||||
let limit = params.max_results.unwrap_or(5).clamp(1, 20);
|
||||
let filters = Filters {
|
||||
rigged_only: params.rigged_only,
|
||||
category: params.category.clone(),
|
||||
source: None,
|
||||
max_extent: None,
|
||||
kind: params.kind,
|
||||
};
|
||||
let entries: Vec<&crate::AssetEntry> = match params.spread {
|
||||
Some(spread) => index.find_many(
|
||||
¶ms.query,
|
||||
&VarietyParams {
|
||||
count: limit,
|
||||
spread,
|
||||
seed: params.seed,
|
||||
filters: filters.clone(),
|
||||
},
|
||||
),
|
||||
None => index
|
||||
.find_filtered(¶ms.query, &filters)
|
||||
.into_iter()
|
||||
.take(limit)
|
||||
.map(|h| h.entry)
|
||||
.collect(),
|
||||
};
|
||||
entries
|
||||
.into_iter()
|
||||
.map(|e| FindResult {
|
||||
id: e.id.clone(),
|
||||
name: e.name.clone(),
|
||||
kind: e.kind,
|
||||
category: e.categories.first().cloned().unwrap_or_default(),
|
||||
rigged: e.rigged,
|
||||
size: e.size,
|
||||
loops: e.loops,
|
||||
playable: e.decodable,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Run a `find_palette` call.
|
||||
pub fn execute_palette(index: &AssetIndex, query: &str, seed: u64) -> Option<Palette> {
|
||||
index.palette(query, seed)
|
||||
}
|
||||
|
||||
/// Render a palette compactly. Groups are capped because a pack can hold
|
||||
/// hundreds of models and the model only needs enough of each to avoid
|
||||
/// repeating itself — every token here is paid for on every turn.
|
||||
pub fn palette_to_json(palette: &Palette, per_group: usize) -> String {
|
||||
let mut s = format!("{{\"pack\":\"{}\",\"groups\":{{", palette.pack);
|
||||
let mut first = true;
|
||||
for (group, ids) in palette.groups.iter().take(12) {
|
||||
if ids.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !first {
|
||||
s.push(',');
|
||||
}
|
||||
first = false;
|
||||
s.push_str(&format!("\"{group}\":["));
|
||||
for (i, id) in ids.iter().take(per_group).enumerate() {
|
||||
if i > 0 {
|
||||
s.push(',');
|
||||
}
|
||||
// Ids share the pack prefix, so send only the tail and state the
|
||||
// prefix once — a village palette is ~40 ids and the repetition
|
||||
// would be most of the payload.
|
||||
let tail = id.rsplit('/').next().unwrap_or(id);
|
||||
s.push_str(&format!("\"{tail}\""));
|
||||
}
|
||||
s.push(']');
|
||||
if ids.len() > per_group {
|
||||
// Tell the model more exist, so it knows it can ask for a bigger
|
||||
// slice rather than assuming this is the whole group.
|
||||
s.push_str("");
|
||||
}
|
||||
}
|
||||
s.push_str("}}");
|
||||
s
|
||||
}
|
||||
|
||||
/// Render results as compact JSON for handing back to a model. One line per
|
||||
/// result, no pretty-printing — this is paid-for context.
|
||||
pub fn results_to_json(results: &[FindResult]) -> String {
|
||||
let mut s = String::from("[");
|
||||
for (i, r) in results.iter().enumerate() {
|
||||
if i > 0 {
|
||||
s.push(',');
|
||||
}
|
||||
s.push_str(&format!(
|
||||
"{{\"id\":\"{}\",\"name\":\"{}\",\"kind\":\"{}\",\"category\":\"{}\"",
|
||||
r.id,
|
||||
r.name,
|
||||
r.kind.as_str(),
|
||||
r.category
|
||||
));
|
||||
// Only emit flags that are true or that the model must act on — every
|
||||
// token here is paid for.
|
||||
if r.kind == AssetKind::Model && r.rigged {
|
||||
s.push_str(",\"rigged\":true");
|
||||
}
|
||||
if r.loops {
|
||||
s.push_str(",\"loops\":true");
|
||||
}
|
||||
if !r.playable {
|
||||
s.push_str(",\"playable\":false");
|
||||
}
|
||||
if let Some(sz) = r.size {
|
||||
s.push_str(&format!(
|
||||
",\"size\":[{:.2},{:.2},{:.2}]",
|
||||
sz[0], sz[1], sz[2]
|
||||
));
|
||||
}
|
||||
s.push('}');
|
||||
}
|
||||
s.push(']');
|
||||
s
|
||||
}
|
||||
|
||||
/// A short blurb for the system prompt. Must stay tiny however large the
|
||||
/// library grows, so it summarises by top-level category with counts and names
|
||||
/// only a handful of examples — never the catalogue.
|
||||
pub fn library_summary(index: &AssetIndex) -> String {
|
||||
if index.is_empty() {
|
||||
return "No stock assets are installed (run apps/arcade/download_assets.sh). \
|
||||
Build scenes from primitive shapes and the built-in synth."
|
||||
.to_string();
|
||||
}
|
||||
// Roll categories up to their top level so the list stays a sentence.
|
||||
let mut tops: Vec<(String, usize)> = Vec::new();
|
||||
for (cat, n) in index.category_counts() {
|
||||
let top = cat.split('/').next().unwrap_or(&cat).to_string();
|
||||
match tops.iter_mut().find(|(t, _)| *t == top) {
|
||||
Some((_, c)) => *c += n,
|
||||
None => tops.push((top, n)),
|
||||
}
|
||||
}
|
||||
tops.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
|
||||
let cats: Vec<String> = tops.iter().map(|(t, n)| format!("{t} ({n})")).collect();
|
||||
|
||||
// A few concrete examples make the id shape obvious without listing much.
|
||||
let examples: Vec<&str> = index
|
||||
.entries()
|
||||
.iter()
|
||||
.filter(|e| !e.categories.is_empty())
|
||||
.step_by((index.len() / 3).max(1))
|
||||
.take(3)
|
||||
.map(|e| e.id.as_str())
|
||||
.collect();
|
||||
|
||||
let models = index.count_of(AssetKind::Model);
|
||||
let sounds = index.count_of(AssetKind::Sound);
|
||||
let music = index.count_of(AssetKind::Music);
|
||||
format!(
|
||||
"A stock CC0 asset library is available: {models} models, {sounds} sounds, {music} music \
|
||||
jingles — {}. Call find_model(query, max_results) to search in plain language — never \
|
||||
guess an asset id. Ids look like {}. Results are DISTINCT models: ask for as many as \
|
||||
you will place and use a different one each time, because repeating one model is what \
|
||||
makes a scene look cheap. For a whole scene call find_palette(query) instead — it \
|
||||
returns a matched set from one art pack, so the parts look like one game.",
|
||||
cats.join(", "),
|
||||
examples.join(", ")
|
||||
)
|
||||
}
|
||||
|
||||
/// What the local librarian tier produces: a resolved model plus a structured
|
||||
/// spawn action, so a small model can answer "put a truck in the game" without
|
||||
/// writing any script.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct SpawnAction {
|
||||
pub model_id: String,
|
||||
pub name: String,
|
||||
/// Confidence in 0.0..=1.0 that this is what was asked for. The app
|
||||
/// decides the threshold: act locally when high, escalate when low.
|
||||
pub confidence: f32,
|
||||
}
|
||||
|
||||
/// Resolve a plain-language request to a spawn action, or `None` when the
|
||||
/// library has nothing plausible — in which case the caller should escalate to
|
||||
/// the cloud agent rather than spawn something wrong.
|
||||
pub fn local_spawn(index: &AssetIndex, request: &str) -> Option<SpawnAction> {
|
||||
let (entry, confidence) = index.best(request)?;
|
||||
Some(SpawnAction {
|
||||
model_id: entry.id.clone(),
|
||||
name: entry.name.clone(),
|
||||
confidence,
|
||||
})
|
||||
}
|
||||
|
||||
/// Attribution lines for every source present in the library, for a published
|
||||
/// game package to carry.
|
||||
pub fn credits(index: &AssetIndex) -> Vec<&'static str> {
|
||||
let mut out: Vec<&'static str> = Vec::new();
|
||||
for src in [Source::Kenney, Source::KayKit] {
|
||||
if index.entries().iter().any(|e| e.source == src) && !out.contains(&src.credit()) {
|
||||
out.push(src.credit());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- kit lookup
|
||||
|
||||
/// A kit is a coherent, visually-matching SET of tiles. A level assembled from
|
||||
/// one tile of each of five kits looks like a junk drawer; a level built from
|
||||
/// one kit looks designed. So the AI needs to discover that a set exists —
|
||||
/// and its grid pitch — before it can lay anything out.
|
||||
pub const FIND_KIT: ToolDescriptor = ToolDescriptor {
|
||||
name: "find_kit",
|
||||
description: "List modular building kits — sets of tiles designed to snap together on a \
|
||||
grid (roads, dungeons, caves, buildings, race tracks, platformer blocks). \
|
||||
Returns each kit's id, its tile size (the grid pitch to place tiles on) and \
|
||||
which roles it provides (straight, corner, junction, end, ramp, wall, floor, \
|
||||
door, ...). Build a level from ONE kit so the pieces match visually, then \
|
||||
call find_model with that kit's name to get specific tile ids.",
|
||||
params: &[
|
||||
ToolParam {
|
||||
name: "query",
|
||||
ty: "string",
|
||||
description: "Optional: what kind of level, e.g. \"road\", \"dungeon\", \"city\", \
|
||||
\"race track\", \"cave\", \"platformer\".",
|
||||
required: false,
|
||||
},
|
||||
ToolParam {
|
||||
name: "role",
|
||||
ty: "string",
|
||||
description: "Optional: only kits providing this piece, e.g. \"junction\".",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/// Execute `find_kit`. Compact by design: kit id, tile size, and role counts —
|
||||
/// enough to plan a layout, without spending tokens on individual tile ids
|
||||
/// (those come from a follow-up `find_model` scoped to the kit).
|
||||
pub fn execute_kit(index: &AssetIndex, query: Option<&str>, role: Option<&str>) -> Vec<KitResult> {
|
||||
let q = query.unwrap_or("").to_ascii_lowercase();
|
||||
let mut out: Vec<KitResult> = index
|
||||
.kits()
|
||||
.into_iter()
|
||||
.filter(|k| {
|
||||
role.is_none_or(|r| k.roles.iter().any(|(kr, _)| kr == r || kr.starts_with(r)))
|
||||
})
|
||||
.filter(|k| {
|
||||
q.is_empty()
|
||||
|| q.split_whitespace().any(|w| {
|
||||
k.pack.contains(w) || k.name.to_ascii_lowercase().contains(w)
|
||||
})
|
||||
})
|
||||
.map(|k| KitResult {
|
||||
id: k.pack,
|
||||
name: k.name,
|
||||
tiles: k.tiles,
|
||||
tile_size: k.tile_size,
|
||||
roles: k.roles,
|
||||
})
|
||||
.collect();
|
||||
// Most roles first: a kit with junctions and ramps composes into more
|
||||
// interesting levels than one with a single straight piece.
|
||||
out.sort_by(|a, b| b.roles.len().cmp(&a.roles.len()).then(a.id.cmp(&b.id)));
|
||||
out
|
||||
}
|
||||
|
||||
pub struct KitResult {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub tiles: u32,
|
||||
pub tile_size: Option<f32>,
|
||||
pub roles: Vec<(String, u32)>,
|
||||
}
|
||||
|
||||
pub fn kits_to_json(kits: &[KitResult]) -> String {
|
||||
let mut s = String::from("[");
|
||||
for (i, k) in kits.iter().enumerate() {
|
||||
if i > 0 {
|
||||
s.push(',');
|
||||
}
|
||||
s.push_str(&format!("{{\"id\":\"{}\",\"tiles\":{}", k.id, k.tiles));
|
||||
if let Some(t) = k.tile_size {
|
||||
s.push_str(&format!(",\"tile_size\":{t:.2}"));
|
||||
}
|
||||
s.push_str(",\"roles\":{");
|
||||
for (j, (r, n)) in k.roles.iter().enumerate() {
|
||||
if j > 0 {
|
||||
s.push(',');
|
||||
}
|
||||
s.push_str(&format!("\"{r}\":{n}"));
|
||||
}
|
||||
s.push_str("}}");
|
||||
}
|
||||
s.push(']');
|
||||
s
|
||||
}
|
||||
|
||||
/// Discover the rigged casts — characters that share a skeleton.
|
||||
///
|
||||
/// Separate from `find_model` because the useful unit here is the SET, not the
|
||||
/// individual: an AI casting a village wants to know that twelve civilians
|
||||
/// share one rig (so one animation path drives them all, and swapping a
|
||||
/// villager for an orc costs nothing), and that a different, richer rig holds
|
||||
/// the heroes and undead. Choosing two characters from two rigs is the
|
||||
/// character-level version of building a level from five different tile kits.
|
||||
pub const FIND_CAST: ToolDescriptor = ToolDescriptor {
|
||||
name: "find_cast",
|
||||
description: "List the rigged, animated character casts. Each cast is a set of characters \
|
||||
sharing one skeleton, so ANY animation state works on EVERY member and you \
|
||||
can recast a part without changing animation code. Returns the cast's joint \
|
||||
count (its identity), its members, and the states they can all perform \
|
||||
(idle, walk, run, jump, attack, block, dodge, hurt, die, sit, dance, spawn, \
|
||||
resurrect, taunt, ...). Pick characters from ONE cast for a scene, and use \
|
||||
DIFFERENT members so your villagers are not clones.",
|
||||
params: &[ToolParam {
|
||||
name: "state",
|
||||
ty: "string",
|
||||
description: "Optional: only casts whose members can all do this, e.g. \"attack\", \
|
||||
\"resurrect\", \"sit\".",
|
||||
required: false,
|
||||
}],
|
||||
};
|
||||
|
||||
pub struct CastResult {
|
||||
pub joints: u32,
|
||||
pub members: Vec<String>,
|
||||
pub states: Vec<String>,
|
||||
pub max_clips: u32,
|
||||
/// Members carrying the largest clip set — a superset worth knowing about
|
||||
/// when one part needs a state the rest of the cast lacks.
|
||||
pub richest: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn execute_cast(index: &AssetIndex, state: Option<&str>) -> Vec<CastResult> {
|
||||
let want = state.map(|s| s.to_ascii_lowercase());
|
||||
index
|
||||
.casts()
|
||||
.into_iter()
|
||||
.filter(|c| {
|
||||
want.as_ref()
|
||||
.is_none_or(|w| c.shared_states.iter().any(|s| s == w))
|
||||
})
|
||||
.map(|c| CastResult {
|
||||
joints: c.joints,
|
||||
members: c.members,
|
||||
states: c.shared_states,
|
||||
max_clips: c.max_clips,
|
||||
richest: c.richest,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Compact JSON: the AI needs the member ids and what they can do, not every
|
||||
/// clip name — a 95-clip list per character would dwarf the rest of the turn.
|
||||
pub fn casts_to_json(casts: &[CastResult], max_members: usize) -> String {
|
||||
let mut s = String::from("[");
|
||||
for (i, c) in casts.iter().enumerate() {
|
||||
if i > 0 {
|
||||
s.push(',');
|
||||
}
|
||||
s.push_str(&format!(
|
||||
"{{\"rig\":{},\"count\":{},\"members\":[",
|
||||
c.joints,
|
||||
c.members.len()
|
||||
));
|
||||
for (j, m) in c.members.iter().take(max_members).enumerate() {
|
||||
if j > 0 {
|
||||
s.push(',');
|
||||
}
|
||||
s.push_str(&format!("\"{m}\""));
|
||||
}
|
||||
s.push_str("],\"states\":[");
|
||||
for (j, st) in c.states.iter().enumerate() {
|
||||
if j > 0 {
|
||||
s.push(',');
|
||||
}
|
||||
s.push_str(&format!("\"{st}\""));
|
||||
}
|
||||
s.push(']');
|
||||
if c.richest.len() < c.members.len() {
|
||||
s.push_str(&format!(",\"most_clips\":{}", c.max_clips));
|
||||
}
|
||||
// One brace: the object opened by the `{{` in the format! above.
|
||||
s.push('}');
|
||||
}
|
||||
s.push(']');
|
||||
s
|
||||
}
|
||||
|
|
@ -1,503 +0,0 @@
|
|||
//! The hand-curated alias table — the heart of the asset library.
|
||||
//!
|
||||
//! A model nobody can name is a model that does not exist. Filename tokens
|
||||
//! are the *floor* (the indexer always folds them in); everything here is the
|
||||
//! curation on top, written along six axes:
|
||||
//!
|
||||
//! 1. synonyms — truck/lorry/van/pickup
|
||||
//! 2. kid vocabulary and misspellings — "digger", "baddie", "vehical"
|
||||
//! 3. function and use — "something to drive", "somewhere to hide"
|
||||
//! 4. visual qualities — colour, size class, material
|
||||
//! 5. setting and theme — city, medieval, racing, sci-fi
|
||||
//! 6. phrasing a 7-year-old or an LLM would actually type
|
||||
//!
|
||||
//! Axis 3 earns its keep the most: a kid says what they want to DO, and an
|
||||
//! agent says what it needs the model FOR. Neither says "vehicle-truck-yellow".
|
||||
|
||||
/// One curated row. `key` is `<pack>/<file-stem>`, which is anchored to where
|
||||
/// the file lives — so ids stay stable when categories are retuned.
|
||||
pub struct Alias {
|
||||
pub key: &'static str,
|
||||
pub name: &'static str,
|
||||
pub categories: &'static [&'static str],
|
||||
pub aliases: &'static [&'static str],
|
||||
}
|
||||
|
||||
/// Category hierarchy, stated in one sentence: things you drive, characters,
|
||||
/// nature, buildings, roads you drive on, ground you stand on, props you use,
|
||||
/// and effects. Items may sit in more than one.
|
||||
pub const CATEGORIES: &[&str] = &[
|
||||
"vehicle/ground",
|
||||
"character/player",
|
||||
"character/enemy",
|
||||
"nature/tree",
|
||||
"nature/plant",
|
||||
"nature/sky",
|
||||
"building/residential",
|
||||
"building/structure",
|
||||
"road/street",
|
||||
"road/track",
|
||||
"terrain/platform",
|
||||
"terrain/floor",
|
||||
"terrain/wall",
|
||||
"terrain/stairs",
|
||||
"prop/decoration",
|
||||
"prop/weapon",
|
||||
"prop/pickup",
|
||||
"effect/particle",
|
||||
];
|
||||
|
||||
/// Query-time expansion: a term the user typed -> terms we search for.
|
||||
/// Includes British/American pairs and the misspellings a child actually
|
||||
/// produces, because a 7-year-old's spelling should still hit.
|
||||
pub const SYNONYMS: &[(&str, &[&str])] = &[
|
||||
// vehicles
|
||||
("lorry", &["truck"]),
|
||||
("van", &["truck"]),
|
||||
("pickup", &["truck"]),
|
||||
("hauler", &["truck"]),
|
||||
("semi", &["truck"]),
|
||||
("auto", &["car", "vehicle"]),
|
||||
("automobile", &["car", "vehicle"]),
|
||||
("motor", &["car", "vehicle"]),
|
||||
("racecar", &["car", "racer"]),
|
||||
("motorbike", &["motorcycle"]),
|
||||
("moto", &["motorcycle"]),
|
||||
("bike", &["motorcycle"]),
|
||||
("scooter", &["motorcycle"]),
|
||||
("motercycle", &["motorcycle"]), // misspelling
|
||||
("motorcyle", &["motorcycle"]), // misspelling
|
||||
("vehical", &["vehicle"]), // misspelling
|
||||
("vehicel", &["vehicle"]), // misspelling
|
||||
("bycicle", &["bicycle", "motorcycle"]),
|
||||
("truk", &["truck"]), // misspelling
|
||||
// aircraft (no models yet — expansions ready for the next pack)
|
||||
("aeroplane", &["plane", "aircraft"]),
|
||||
("airplane", &["plane", "aircraft"]),
|
||||
("jet", &["plane", "aircraft"]),
|
||||
("aircraft", &["plane"]),
|
||||
("chopper", &["helicopter"]),
|
||||
("heli", &["helicopter"]),
|
||||
("hilicopter", &["helicopter"]), // misspelling
|
||||
// construction
|
||||
("digger", &["excavator", "construction"]),
|
||||
("jcb", &["excavator", "construction"]),
|
||||
("bulldozer", &["excavator", "construction"]),
|
||||
// characters
|
||||
("baddie", &["enemy"]),
|
||||
("baddy", &["enemy"]),
|
||||
("villain", &["enemy"]),
|
||||
("monster", &["enemy"]),
|
||||
("mob", &["enemy"]),
|
||||
("foe", &["enemy"]),
|
||||
("alien", &["enemy"]),
|
||||
("dude", &["character"]),
|
||||
("guy", &["character"]),
|
||||
("man", &["character"]),
|
||||
("person", &["character"]),
|
||||
("avatar", &["character"]),
|
||||
("hero", &["character", "player"]),
|
||||
("charecter", &["character"]), // misspelling
|
||||
("caracter", &["character"]), // misspelling
|
||||
("knight", &["character", "medieval", "warrior"]),
|
||||
("warrior", &["character", "soldier"]),
|
||||
("army", &["soldier", "military"]),
|
||||
// nature
|
||||
("boulder", &["rock"]),
|
||||
("stone", &["rock"]),
|
||||
("pine", &["tree", "conifer"]),
|
||||
("oak", &["tree"]),
|
||||
("fir", &["tree", "conifer"]),
|
||||
("conifer", &["tree"]),
|
||||
("woods", &["forest", "tree"]),
|
||||
("wood", &["forest", "tree"]),
|
||||
("bush", &["grass", "plant", "shrub"]),
|
||||
("shrub", &["grass", "plant"]),
|
||||
("plant", &["grass"]),
|
||||
("greenery", &["grass", "plant"]),
|
||||
("lawn", &["grass"]),
|
||||
("meadow", &["grass", "field"]),
|
||||
("tre", &["tree"]), // misspelling
|
||||
("treee", &["tree"]), // misspelling
|
||||
// buildings
|
||||
("home", &["house"]),
|
||||
("cottage", &["house"]),
|
||||
("hut", &["house"]),
|
||||
("cabin", &["house"]),
|
||||
("shack", &["house"]),
|
||||
("shop", &["building", "house"]),
|
||||
("store", &["building", "house"]),
|
||||
("skyscraper", &["building"]),
|
||||
("tower", &["building"]),
|
||||
("hows", &["house"]), // misspelling
|
||||
("hosue", &["house"]), // misspelling
|
||||
// roads and ground
|
||||
("tarmac", &["road", "asphalt"]),
|
||||
("asphalt", &["road"]),
|
||||
("highway", &["road"]),
|
||||
("motorway", &["road"]),
|
||||
("street", &["road"]),
|
||||
("sidewalk", &["pavement"]),
|
||||
("footpath", &["pavement", "path"]),
|
||||
("walkway", &["pavement", "path"]),
|
||||
("kerb", &["border", "curb"]),
|
||||
("curb", &["border"]),
|
||||
("crossroads", &["intersection"]),
|
||||
("junction", &["intersection"]),
|
||||
("roundabout", &["intersection"]),
|
||||
("bend", &["corner"]),
|
||||
("turn", &["corner"]),
|
||||
("curve", &["corner"]),
|
||||
// structures
|
||||
("staircase", &["stairs"]),
|
||||
("steps", &["stairs"]),
|
||||
("ladder", &["stairs"]),
|
||||
("fence", &["wall", "barrier"]),
|
||||
("barrier", &["wall"]),
|
||||
("gateway", &["gate"]),
|
||||
("doorway", &["gate", "door"]),
|
||||
("entrance", &["gate"]),
|
||||
("arch", &["gate"]),
|
||||
("pillar", &["column"]),
|
||||
("post", &["column"]),
|
||||
("ledge", &["platform"]),
|
||||
("stairs", &["steps"]),
|
||||
// props
|
||||
("blade", &["sword"]),
|
||||
("sworde", &["sword"]), // misspelling
|
||||
("lance", &["spear"]),
|
||||
("pike", &["spear"]),
|
||||
("gun", &["blaster", "weapon"]),
|
||||
("pistol", &["blaster", "weapon"]),
|
||||
("rifle", &["blaster", "weapon"]),
|
||||
("laser", &["blaster"]),
|
||||
("cup", &["trophy"]),
|
||||
("prize", &["trophy"]),
|
||||
("award", &["trophy"]),
|
||||
("money", &["coin"]),
|
||||
("gold", &["coin"]),
|
||||
("cash", &["coin"]),
|
||||
("collectible", &["coin", "pickup"]),
|
||||
("powerup", &["coin", "pickup", "block-coin"]),
|
||||
("treasure", &["coin", "trophy"]),
|
||||
("crate", &["block", "box"]),
|
||||
("box", &["block", "crate"]),
|
||||
("cube", &["block"]),
|
||||
("banner", &["flag"]),
|
||||
("pennant", &["flag"]),
|
||||
("chequered", &["finish", "checkered"]),
|
||||
("checkered", &["finish"]),
|
||||
("fountain", &["water"]),
|
||||
("statue", &["sculpture", "monument"]),
|
||||
("sculpture", &["statue"]),
|
||||
// effects and weather
|
||||
("smoke", &["dust", "puff"]),
|
||||
("puff", &["dust"]),
|
||||
("sky", &["cloud"]),
|
||||
("weather", &["cloud"]),
|
||||
// function phrases — the axis that matters most
|
||||
("drive", &["vehicle", "car", "truck", "road", "track"]),
|
||||
("driving", &["vehicle", "car", "truck", "road"]),
|
||||
("ride", &["motorcycle", "vehicle"]),
|
||||
("riding", &["motorcycle", "vehicle"]),
|
||||
("race", &["racing", "track", "vehicle"]),
|
||||
("racing", &["track", "vehicle"]),
|
||||
("shoot", &["enemy", "blaster", "target"]),
|
||||
("shooting", &["enemy", "blaster"]),
|
||||
("hide", &["wall", "cover", "obstacle"]),
|
||||
("hiding", &["wall", "cover"]),
|
||||
("cover", &["wall", "obstacle"]),
|
||||
("jump", &["platform"]),
|
||||
("jumping", &["platform"]),
|
||||
("stand", &["platform", "floor"]),
|
||||
("standing", &["platform", "floor"]),
|
||||
("climb", &["stairs", "ladder"]),
|
||||
("climbing", &["stairs"]),
|
||||
("collect", &["coin", "pickup"]),
|
||||
("collecting", &["coin", "pickup"]),
|
||||
("decorate", &["decoration"]),
|
||||
("decor", &["decoration"]),
|
||||
("scenery", &["decoration"]),
|
||||
("obstacle", &["block", "wall"]),
|
||||
("play", &["character", "player"]),
|
||||
// size and quality
|
||||
("big", &["large"]),
|
||||
("huge", &["large"]),
|
||||
("giant", &["large"]),
|
||||
("massive", &["large"]),
|
||||
("tiny", &["small"]),
|
||||
("little", &["small"]),
|
||||
("lowpoly", &["blocky"]),
|
||||
("blocky", &["lowpoly"]),
|
||||
// --- catalogue-aware expansions -------------------------------------
|
||||
// Added after measuring real misses against the full 4400-model library:
|
||||
// Kenney's space pack names everything "craft_*", its pets are "animal-*",
|
||||
// and kids do not use either word.
|
||||
("spaceship", &["craft", "speeder", "space"]),
|
||||
("rocket", &["craft", "speeder", "space"]),
|
||||
("spacecraft", &["craft", "speeder"]),
|
||||
("ufo", &["craft", "alien", "space"]),
|
||||
("shuttle", &["craft", "space"]),
|
||||
("kitten", &["cat"]),
|
||||
("puppy", &["dog"]),
|
||||
("doggy", &["dog"]),
|
||||
("rabbit", &["bunny"]),
|
||||
("hare", &["bunny"]),
|
||||
("bird", &["parrot", "chick"]),
|
||||
("birdie", &["parrot", "chick"]),
|
||||
("piggy", &["pig", "hog"]),
|
||||
("bear", &["polar", "koala"]),
|
||||
("teddy", &["bear", "koala"]),
|
||||
("dino", &["dinosaur"]),
|
||||
("couch", &["sofa"]),
|
||||
("settee", &["sofa"]),
|
||||
("armchair", &["chair"]),
|
||||
("telly", &["television", "tv"]),
|
||||
("fridge", &["refrigerator", "kitchen"]),
|
||||
("cooker", &["stove", "oven", "kitchen"]),
|
||||
("burger", &["hamburger", "cheeseburger"]),
|
||||
("fries", &["chips", "fry"]),
|
||||
("sweets", &["candy"]),
|
||||
("lolly", &["candy", "lollipop"]),
|
||||
("veg", &["vegetable", "carrot", "broccoli"]),
|
||||
("skyscraper", &["building", "tower", "commercial"]),
|
||||
("cop", &["police"]),
|
||||
("ambulance", &["emergency", "hospital"]),
|
||||
("firetruck", &["fire", "emergency"]),
|
||||
("digger", &["excavator", "tractor", "construction"]),
|
||||
("tractor", &["farm"]),
|
||||
("choo", &["train", "locomotive"]),
|
||||
("carriage", &["wagon", "train"]),
|
||||
("yacht", &["boat", "sail"]),
|
||||
("canoe", &["boat", "row"]),
|
||||
("submarine", &["boat", "ship"]),
|
||||
("zombie", &["monster", "enemy", "spooky"]),
|
||||
("skeleton", &["bones", "spooky", "graveyard"]),
|
||||
("pumpkin", &["halloween", "graveyard"]),
|
||||
("gravestone", &["grave", "tombstone", "graveyard"]),
|
||||
("tombstone", &["grave", "graveyard"]),
|
||||
("xmas", &["christmas", "holiday"]),
|
||||
("santa", &["christmas", "holiday"]),
|
||||
("snowman", &["snow", "winter", "holiday"]),
|
||||
("conveyor", &["belt", "factory"]),
|
||||
("crane", &["construction", "factory"]),
|
||||
("hoop", &["ring", "arch"]),
|
||||
("ramp", &["jump", "slope"]),
|
||||
// theme
|
||||
("town", &["city", "urban"]),
|
||||
("urban", &["city"]),
|
||||
("outdoors", &["nature"]),
|
||||
("outdoor", &["nature"]),
|
||||
("scifi", &["space", "sci-fi"]),
|
||||
("space", &["sci-fi"]),
|
||||
("fantasy", &["medieval"]),
|
||||
("castle", &["medieval", "fortress"]),
|
||||
("fortress", &["medieval", "castle"]),
|
||||
("roman", &["ancient"]),
|
||||
("greek", &["ancient"]),
|
||||
];
|
||||
|
||||
/// The curated catalogue. Keyed by `<pack>/<stem>`.
|
||||
pub const ALIASES: &[Alias] = &[
|
||||
// ------------------------------------------------------- nature: rocks
|
||||
// Landscape rocks lost "boulder" to `tower-defense-kit/weapon-ammo-boulder`
|
||||
// — catapult ammo — purely because that filename says the word and these
|
||||
// reach it only through the boulder->rock synonym. A confidently wrong top
|
||||
// hit is worse than a miss here, because a composer places it five times.
|
||||
Alias { key: "nature-kit/rock_largeA", name: "Boulder", categories: &["nature/rock"],
|
||||
aliases: &["boulder", "rock", "stone", "big rock", "large rock", "landscape", "scenery", "nature", "outcrop"] },
|
||||
Alias { key: "nature-kit/rock_largeB", name: "Boulder", categories: &["nature/rock"],
|
||||
aliases: &["boulder", "rock", "stone", "big rock", "large rock", "landscape", "scenery", "nature", "outcrop"] },
|
||||
Alias { key: "nature-kit/rock_largeC", name: "Boulder", categories: &["nature/rock"],
|
||||
aliases: &["boulder", "rock", "stone", "big rock", "large rock", "landscape", "scenery", "nature", "outcrop"] },
|
||||
Alias { key: "nature-kit/rock_largeD", name: "Boulder", categories: &["nature/rock"],
|
||||
aliases: &["boulder", "rock", "stone", "big rock", "large rock", "landscape", "scenery", "nature", "outcrop"] },
|
||||
Alias { key: "nature-kit/rock_largeE", name: "Boulder", categories: &["nature/rock"],
|
||||
aliases: &["boulder", "rock", "stone", "big rock", "large rock", "landscape", "scenery", "nature", "outcrop"] },
|
||||
Alias { key: "nature-kit/rock_largeF", name: "Boulder", categories: &["nature/rock"],
|
||||
aliases: &["boulder", "rock", "stone", "big rock", "large rock", "landscape", "scenery", "nature", "outcrop"] },
|
||||
Alias { key: "nature-kit/rock_smallA", name: "Rock", categories: &["nature/rock"],
|
||||
aliases: &["rock", "stone", "boulder", "small rock", "pebble", "landscape", "scenery", "nature"] },
|
||||
Alias { key: "nature-kit/rock_smallB", name: "Rock", categories: &["nature/rock"],
|
||||
aliases: &["rock", "stone", "boulder", "small rock", "pebble", "landscape", "scenery", "nature"] },
|
||||
// ---------------------------------------------------------------- arena
|
||||
Alias { key: "arena/banner", name: "Banner", categories: &["prop/decoration"],
|
||||
aliases: &["banner", "flag", "pennant", "cloth", "hanging", "decoration", "medieval", "castle", "scenery", "wall hanging"] },
|
||||
Alias { key: "arena/block", name: "Stone Block", categories: &["prop/decoration", "terrain/platform"],
|
||||
aliases: &["block", "cube", "box", "crate", "stone block", "obstacle", "building block", "medieval", "stone", "something to jump on", "push"] },
|
||||
Alias { key: "arena/border-corner", name: "Border Corner", categories: &["terrain/floor"],
|
||||
aliases: &["border", "corner", "kerb", "curb", "edge", "trim", "boundary", "arena edge", "medieval"] },
|
||||
Alias { key: "arena/border-straight", name: "Border", categories: &["terrain/floor"],
|
||||
aliases: &["border", "straight", "kerb", "curb", "edge", "trim", "boundary", "arena edge", "medieval"] },
|
||||
Alias { key: "arena/bricks", name: "Rubble", categories: &["prop/decoration"],
|
||||
aliases: &["bricks", "rubble", "debris", "stones", "pile", "ruins", "broken", "scenery", "medieval", "wreckage"] },
|
||||
Alias { key: "arena/character-soldier", name: "Soldier", categories: &["character/player"],
|
||||
aliases: &["soldier", "character", "guy", "man", "person", "player", "fighter", "warrior", "army", "military", "someone to play as", "hero", "dude", "avatar"] },
|
||||
Alias { key: "arena/column-damaged", name: "Broken Column", categories: &["building/structure", "prop/decoration"],
|
||||
aliases: &["column", "pillar", "broken column", "ruined pillar", "ruin", "ancient", "temple", "greek", "roman", "obstacle", "cover", "medieval", "damaged"] },
|
||||
Alias { key: "arena/column", name: "Column", categories: &["building/structure"],
|
||||
aliases: &["column", "pillar", "post", "temple", "ancient", "greek", "roman", "support", "obstacle", "cover", "somewhere to hide", "medieval"] },
|
||||
Alias { key: "arena/floor-detail", name: "Detailed Floor", categories: &["terrain/floor"],
|
||||
aliases: &["floor", "ground", "tile", "paving", "surface", "somewhere to stand", "detailed floor", "arena floor", "medieval", "stone floor"] },
|
||||
Alias { key: "arena/floor", name: "Floor", categories: &["terrain/floor"],
|
||||
aliases: &["floor", "ground", "tile", "paving", "surface", "somewhere to stand", "arena floor", "medieval", "stone floor", "platform"] },
|
||||
Alias { key: "arena/stairs-corner-inner", name: "Inner Corner Stairs", categories: &["terrain/stairs"],
|
||||
aliases: &["stairs", "steps", "staircase", "corner stairs", "inner corner", "climb", "way up", "medieval"] },
|
||||
Alias { key: "arena/stairs-corner", name: "Corner Stairs", categories: &["terrain/stairs"],
|
||||
aliases: &["stairs", "steps", "staircase", "corner stairs", "climb", "way up", "medieval"] },
|
||||
Alias { key: "arena/stairs", name: "Stairs", categories: &["terrain/stairs"],
|
||||
aliases: &["stairs", "steps", "staircase", "climb", "way up", "ramp", "medieval", "somewhere to climb"] },
|
||||
Alias { key: "arena/statue", name: "Statue", categories: &["prop/decoration"],
|
||||
aliases: &["statue", "sculpture", "monument", "figure", "landmark", "decoration", "stone figure", "medieval", "ancient", "scenery"] },
|
||||
Alias { key: "arena/tree", name: "Tree", categories: &["nature/tree"],
|
||||
aliases: &["tree", "oak", "leafy tree", "plant", "nature", "forest", "wood", "shade", "scenery", "greenery", "big tree"] },
|
||||
Alias { key: "arena/trophy", name: "Trophy", categories: &["prop/pickup", "prop/decoration"],
|
||||
aliases: &["trophy", "cup", "prize", "award", "winner", "goal", "reward", "treasure", "gold cup", "champion", "something to win"] },
|
||||
Alias { key: "arena/wall-corner", name: "Wall Corner", categories: &["terrain/wall"],
|
||||
aliases: &["wall", "corner wall", "barrier", "cover", "obstacle", "fortress", "castle", "medieval", "somewhere to hide"] },
|
||||
Alias { key: "arena/wall-gate", name: "Gate", categories: &["terrain/wall", "building/structure"],
|
||||
aliases: &["gate", "gateway", "doorway", "entrance", "arch", "wall with door", "castle gate", "way in", "portal", "medieval", "door"] },
|
||||
Alias { key: "arena/wall", name: "Wall", categories: &["terrain/wall"],
|
||||
aliases: &["wall", "barrier", "block", "cover", "obstacle", "fence", "fortress", "castle", "medieval", "somewhere to hide", "something to hide behind"] },
|
||||
Alias { key: "arena/weapon-rack", name: "Weapon Rack", categories: &["prop/decoration"],
|
||||
aliases: &["weapon rack", "rack", "armoury", "armory", "stand", "weapons", "decoration", "medieval", "castle", "scenery"] },
|
||||
Alias { key: "arena/weapon-spear", name: "Spear", categories: &["prop/weapon"],
|
||||
aliases: &["spear", "lance", "pike", "polearm", "weapon", "medieval", "pointy stick", "stabby", "knight weapon"] },
|
||||
Alias { key: "arena/weapon-sword", name: "Sword", categories: &["prop/weapon"],
|
||||
aliases: &["sword", "blade", "weapon", "knight sword", "medieval", "sharp", "fighting", "something to fight with"] },
|
||||
// ----------------------------------------------------------------- city
|
||||
Alias { key: "city/building-garage", name: "Garage", categories: &["building/residential"],
|
||||
aliases: &["garage", "carport", "workshop", "shed", "building", "car park", "urban", "city", "town", "somewhere to park"] },
|
||||
Alias { key: "city/building-small-a", name: "Small House A", categories: &["building/residential"],
|
||||
aliases: &["house", "home", "building", "small house", "cottage", "hut", "shop", "urban", "city", "town", "somewhere to live", "residential"] },
|
||||
Alias { key: "city/building-small-b", name: "Small House B", categories: &["building/residential"],
|
||||
aliases: &["house", "home", "building", "small house", "cottage", "hut", "shop", "urban", "city", "town", "somewhere to live", "residential"] },
|
||||
Alias { key: "city/building-small-c", name: "Small House C", categories: &["building/residential"],
|
||||
aliases: &["house", "home", "building", "small house", "cottage", "hut", "shop", "urban", "city", "town", "somewhere to live", "residential"] },
|
||||
Alias { key: "city/building-small-d", name: "Small House D", categories: &["building/residential"],
|
||||
aliases: &["house", "home", "building", "small house", "cottage", "hut", "shop", "urban", "city", "town", "somewhere to live", "residential"] },
|
||||
Alias { key: "city/grass-trees-tall", name: "Tall Trees", categories: &["nature/tree"],
|
||||
aliases: &["trees", "tall trees", "forest", "woods", "nature", "park", "greenery", "scenery", "treeline", "trees for a forest"] },
|
||||
Alias { key: "city/grass-trees", name: "Trees", categories: &["nature/tree"],
|
||||
aliases: &["trees", "forest", "woods", "nature", "park", "greenery", "scenery", "grass with trees", "trees for a forest"] },
|
||||
Alias { key: "city/grass", name: "Grass", categories: &["nature/plant", "terrain/floor"],
|
||||
aliases: &["grass", "lawn", "field", "green", "ground", "nature", "park", "meadow", "somewhere to stand", "greenery"] },
|
||||
Alias { key: "city/pavement-fountain", name: "Fountain", categories: &["prop/decoration"],
|
||||
aliases: &["fountain", "water", "water feature", "plaza", "square", "decoration", "park", "town centre", "town center", "city", "scenery"] },
|
||||
Alias { key: "city/pavement", name: "Pavement", categories: &["terrain/floor", "road/street"],
|
||||
aliases: &["pavement", "sidewalk", "paving", "path", "walkway", "footpath", "ground", "street", "city", "somewhere to walk"] },
|
||||
Alias { key: "city/road-corner", name: "Road Corner", categories: &["road/street"],
|
||||
aliases: &["road", "street", "corner", "bend", "turn", "curve", "tarmac", "asphalt", "drive on", "city", "something to drive on"] },
|
||||
Alias { key: "city/road-intersection", name: "Crossroads", categories: &["road/street"],
|
||||
aliases: &["road", "crossroads", "intersection", "junction", "four way", "street", "drive on", "city", "traffic"] },
|
||||
Alias { key: "city/road-split", name: "T Junction", categories: &["road/street"],
|
||||
aliases: &["road", "junction", "t junction", "split", "fork", "street", "drive on", "city", "intersection"] },
|
||||
Alias { key: "city/road-straight-lightposts", name: "Road with Lights", categories: &["road/street"],
|
||||
aliases: &["road", "street", "straight road", "lamp posts", "street lights", "lighting", "drive on", "city", "lit road", "lampposts"] },
|
||||
Alias { key: "city/road-straight", name: "Straight Road", categories: &["road/street"],
|
||||
aliases: &["road", "street", "straight", "tarmac", "asphalt", "drive on", "highway", "city", "something to drive on"] },
|
||||
// ------------------------------------------------------------------ fps
|
||||
Alias { key: "fps/blaster-repeater", name: "Repeater Blaster", categories: &["prop/weapon"],
|
||||
aliases: &["blaster", "gun", "rifle", "repeater", "weapon", "laser gun", "shooter", "sci-fi", "space gun", "rapid fire", "machine gun", "something to shoot with"] },
|
||||
Alias { key: "fps/blaster", name: "Blaster", categories: &["prop/weapon"],
|
||||
aliases: &["blaster", "gun", "pistol", "weapon", "laser", "shooter", "sci-fi", "space gun", "raygun", "something to shoot with"] },
|
||||
Alias { key: "fps/cloud", name: "Cloud", categories: &["nature/sky"],
|
||||
aliases: &["cloud", "sky", "weather", "floating", "decoration", "fluffy", "white cloud", "scenery"] },
|
||||
Alias { key: "fps/enemy-flying", name: "Flying Enemy", categories: &["character/enemy"],
|
||||
aliases: &["enemy", "baddie", "bad guy", "monster", "flying enemy", "drone", "alien", "something to shoot at", "villain", "foe", "mob", "scary", "flying monster", "boss"] },
|
||||
Alias { key: "fps/grass-small", name: "Small Grass", categories: &["nature/plant"],
|
||||
aliases: &["grass", "small grass", "tuft", "plant", "bush", "nature", "ground cover", "greenery", "little grass"] },
|
||||
Alias { key: "fps/grass", name: "Grass Tuft", categories: &["nature/plant"],
|
||||
aliases: &["grass", "plant", "bush", "shrub", "nature", "greenery", "ground cover", "foliage"] },
|
||||
Alias { key: "fps/platform-large-grass", name: "Large Grass Platform", categories: &["terrain/platform"],
|
||||
aliases: &["platform", "grass platform", "ledge", "floating island", "somewhere to stand", "jump on", "large platform", "big platform", "island"] },
|
||||
Alias { key: "fps/platform", name: "Platform", categories: &["terrain/platform"],
|
||||
aliases: &["platform", "ledge", "floating platform", "somewhere to stand", "jump on", "step", "block"] },
|
||||
Alias { key: "fps/wall-high", name: "High Wall", categories: &["terrain/wall"],
|
||||
aliases: &["wall", "high wall", "tall wall", "barrier", "cover", "obstacle", "somewhere to hide", "big wall", "something to hide behind"] },
|
||||
Alias { key: "fps/wall-low", name: "Low Wall", categories: &["terrain/wall"],
|
||||
aliases: &["wall", "low wall", "barrier", "cover", "crouch behind", "obstacle", "somewhere to hide", "small wall", "something to hide behind"] },
|
||||
// ----------------------------------------------------------- platformer
|
||||
Alias { key: "platformer/block-coin", name: "Coin Block", categories: &["prop/pickup"],
|
||||
aliases: &["coin block", "question block", "mystery box", "item block", "powerup box", "hit block", "surprise", "bonus", "mario block"] },
|
||||
Alias { key: "platformer/brick-particle", name: "Brick Chunk", categories: &["effect/particle"],
|
||||
aliases: &["brick particle", "debris", "chunk", "rubble", "effect", "broken piece", "smash", "fragment"] },
|
||||
Alias { key: "platformer/brick", name: "Brick Block", categories: &["prop/decoration", "terrain/platform"],
|
||||
aliases: &["brick", "block", "breakable block", "wall block", "obstacle", "smash", "break", "something to jump on"] },
|
||||
Alias { key: "platformer/character", name: "Platformer Character", categories: &["character/player"],
|
||||
aliases: &["character", "player", "guy", "hero", "dude", "person", "someone to play as", "avatar", "mascot", "little guy", "my guy"] },
|
||||
Alias { key: "platformer/cloud", name: "Cloud", categories: &["nature/sky"],
|
||||
aliases: &["cloud", "sky", "floating", "weather", "decoration", "fluffy", "white cloud"] },
|
||||
Alias { key: "platformer/coin", name: "Coin", categories: &["prop/pickup"],
|
||||
aliases: &["coin", "money", "gold", "pickup", "collectible", "treasure", "points", "score", "collect", "something to collect", "reward"] },
|
||||
Alias { key: "platformer/dust", name: "Dust Puff", categories: &["effect/particle"],
|
||||
aliases: &["dust", "puff", "smoke", "effect", "particle", "cloud", "landing effect", "poof"] },
|
||||
Alias { key: "platformer/flag", name: "Flag", categories: &["prop/decoration"],
|
||||
aliases: &["flag", "goal", "finish", "checkpoint", "banner", "end of level", "marker", "target", "somewhere to reach"] },
|
||||
Alias { key: "platformer/grass-small", name: "Small Grass", categories: &["nature/plant"],
|
||||
aliases: &["grass", "small grass", "tuft", "plant", "nature", "greenery", "little grass"] },
|
||||
Alias { key: "platformer/grass", name: "Grass", categories: &["nature/plant"],
|
||||
aliases: &["grass", "plant", "bush", "nature", "greenery", "foliage"] },
|
||||
Alias { key: "platformer/platform-falling", name: "Falling Platform", categories: &["terrain/platform"],
|
||||
aliases: &["falling platform", "crumbling platform", "unstable", "trap", "platform", "hazard", "danger", "collapsing"] },
|
||||
Alias { key: "platformer/platform-grass-large-round", name: "Round Grass Platform", categories: &["terrain/platform"],
|
||||
aliases: &["round platform", "grass platform", "island", "large platform", "somewhere to stand", "big platform", "circle platform"] },
|
||||
Alias { key: "platformer/platform-large", name: "Large Platform", categories: &["terrain/platform"],
|
||||
aliases: &["large platform", "big platform", "ledge", "somewhere to stand", "jump on", "wide platform"] },
|
||||
Alias { key: "platformer/platform-medium", name: "Medium Platform", categories: &["terrain/platform"],
|
||||
aliases: &["medium platform", "platform", "ledge", "jump on", "somewhere to stand"] },
|
||||
Alias { key: "platformer/platform", name: "Small Platform", categories: &["terrain/platform"],
|
||||
aliases: &["platform", "ledge", "block", "somewhere to stand", "jump on", "small platform", "step"] },
|
||||
// --------------------------------------------------------------- racing
|
||||
Alias { key: "racing/decoration-empty", name: "Empty Roadside", categories: &["prop/decoration"],
|
||||
aliases: &["decoration", "empty", "plain", "scenery", "filler", "roadside", "blank", "racing"] },
|
||||
Alias { key: "racing/decoration-forest", name: "Roadside Forest", categories: &["nature/tree", "prop/decoration"],
|
||||
aliases: &["forest", "trees", "woods", "roadside trees", "scenery", "nature", "decoration", "racing", "trees for a forest", "treeline"] },
|
||||
Alias { key: "racing/decoration-tents", name: "Roadside Tents", categories: &["prop/decoration"],
|
||||
aliases: &["tents", "camp", "marquee", "spectators", "event", "scenery", "decoration", "race day", "racing", "crowd"] },
|
||||
Alias { key: "racing/track-bump", name: "Track Bump", categories: &["road/track"],
|
||||
aliases: &["track", "bump", "jump", "ramp", "hump", "race track", "obstacle", "racing", "something to jump off"] },
|
||||
Alias { key: "racing/track-corner", name: "Track Corner", categories: &["road/track"],
|
||||
aliases: &["track", "corner", "bend", "turn", "curve", "race track", "drive on", "racing", "something to drive on"] },
|
||||
Alias { key: "racing/track-finish", name: "Finish Line", categories: &["road/track"],
|
||||
aliases: &["finish line", "start line", "finish", "goal", "chequered", "checkered", "race track", "end", "racing", "winner", "start"] },
|
||||
Alias { key: "racing/track-straight", name: "Straight Track", categories: &["road/track"],
|
||||
aliases: &["track", "straight", "race track", "drive on", "road", "racing", "something to drive on"] },
|
||||
Alias { key: "racing/track-tents", name: "Track with Tents", categories: &["road/track"],
|
||||
aliases: &["track with tents", "race track", "grandstand", "spectators", "event", "racing", "crowd", "pit lane"] },
|
||||
Alias { key: "racing/vehicle-motorcycle", name: "Motorcycle", categories: &["vehicle/ground"],
|
||||
aliases: &["motorcycle", "motorbike", "bike", "moto", "motor bike", "two wheeler", "something to ride", "fast", "racer", "racing", "speed", "vehicle"] },
|
||||
Alias { key: "racing/vehicle-truck-green", name: "Green Truck", categories: &["vehicle/ground"],
|
||||
aliases: &["truck", "lorry", "van", "pickup", "hauler", "car", "green truck", "green", "something to drive", "racer", "racing", "vehicle", "fast"] },
|
||||
Alias { key: "racing/vehicle-truck-purple", name: "Purple Truck", categories: &["vehicle/ground"],
|
||||
aliases: &["truck", "lorry", "van", "pickup", "hauler", "car", "purple truck", "purple", "something to drive", "racer", "racing", "vehicle", "fast"] },
|
||||
Alias { key: "racing/vehicle-truck-red", name: "Red Truck", categories: &["vehicle/ground"],
|
||||
aliases: &["truck", "lorry", "van", "pickup", "hauler", "car", "red truck", "red", "something to drive", "racer", "racing", "vehicle", "fast"] },
|
||||
Alias { key: "racing/vehicle-truck-yellow", name: "Yellow Truck", categories: &["vehicle/ground"],
|
||||
aliases: &["truck", "lorry", "van", "pickup", "hauler", "car", "yellow truck", "yellow", "something to drive", "racer", "racing", "vehicle", "fast"] },
|
||||
// ------------------------------------------------------------- kaykit
|
||||
Alias { key: "characters/knight", name: "Knight", categories: &["character/player"],
|
||||
aliases: &["knight", "character", "player", "hero", "warrior", "soldier", "armour", "armor", "medieval", "fighter", "someone to play as", "guy", "dude", "man", "person", "avatar", "animated", "rigged", "walking"] },
|
||||
];
|
||||
|
||||
/// Look up a curated row by `<pack>/<stem>`.
|
||||
pub fn lookup(key: &str) -> Option<&'static Alias> {
|
||||
ALIASES.iter().find(|a| a.key == key)
|
||||
}
|
||||
|
||||
/// Expand one query term into the terms we should search for. Consults the
|
||||
/// model table and the audio table, so one query ranks across both libraries.
|
||||
/// Deterministic order: model table, then audio table.
|
||||
pub fn expand(term: &str) -> Vec<&'static str> {
|
||||
let mut out = Vec::new();
|
||||
for (from, to) in SYNONYMS.iter().chain(crate::audio_aliases::AUDIO_SYNONYMS) {
|
||||
if *from == term {
|
||||
for t in *to {
|
||||
if !out.contains(t) {
|
||||
out.push(*t);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
|
@ -1,433 +0,0 @@
|
|||
//! Curated aliases for the sound library, keyed by *family* rather than file.
|
||||
//!
|
||||
//! Kenney's audio ships in numbered variants (`footstep_wood_000..004`), so the
|
||||
//! curation lives on the family and every variant inherits it — 550-odd files
|
||||
//! collapse to ~116 rows a human can actually maintain.
|
||||
//!
|
||||
//! Audio needs axes the models don't:
|
||||
//!
|
||||
//! - **event/trigger**: hit, crash, jump, land, shoot, pickup, win, click
|
||||
//! - **material**: wood, metal, glass, stone, dirt — an impact is chosen by
|
||||
//! *what hit what*, so these are load-bearing for automatic emission
|
||||
//! - **mood/intensity**: soft/hard, small/big, retro/modern, happy/tense
|
||||
//! - **function**: "sound for when you get hit", "music for a race"
|
||||
//!
|
||||
//! Music is marked so a game never fires a 30-second track as a hit sound.
|
||||
|
||||
use crate::AssetKind;
|
||||
|
||||
pub struct AudioAlias {
|
||||
/// `<pack>/<family>` — family is the filename with its variant number
|
||||
/// stripped.
|
||||
pub key: &'static str,
|
||||
pub name: &'static str,
|
||||
pub kind: AssetKind,
|
||||
pub categories: &'static [&'static str],
|
||||
pub aliases: &'static [&'static str],
|
||||
}
|
||||
|
||||
/// Sound categories, kept parallel in spirit to the model tree.
|
||||
pub const AUDIO_CATEGORIES: &[&str] = &[
|
||||
"sound/footstep",
|
||||
"sound/impact",
|
||||
"sound/weapon",
|
||||
"sound/ui",
|
||||
"sound/pickup",
|
||||
"sound/engine",
|
||||
"sound/ambient",
|
||||
"sound/voice",
|
||||
"music/jingle",
|
||||
];
|
||||
|
||||
pub const AUDIO_ALIASES: &[AudioAlias] = &[
|
||||
// ------------------------------------------------------ footsteps
|
||||
AudioAlias { key: "impact-sounds/footstep_carpet", name: "Footstep (carpet)", kind: AssetKind::Sound,
|
||||
categories: &["sound/footstep"],
|
||||
aliases: &["footstep", "step", "walk", "walking", "running", "carpet", "soft", "indoor", "quiet", "sound when you walk"] },
|
||||
AudioAlias { key: "impact-sounds/footstep_concrete", name: "Footstep (concrete)", kind: AssetKind::Sound,
|
||||
categories: &["sound/footstep"],
|
||||
aliases: &["footstep", "step", "walk", "walking", "running", "concrete", "stone", "pavement", "hard", "city", "sound when you walk"] },
|
||||
AudioAlias { key: "impact-sounds/footstep_grass", name: "Footstep (grass)", kind: AssetKind::Sound,
|
||||
categories: &["sound/footstep"],
|
||||
aliases: &["footstep", "step", "walk", "walking", "running", "grass", "outdoor", "nature", "soft", "sound when you walk"] },
|
||||
AudioAlias { key: "impact-sounds/footstep_snow", name: "Footstep (snow)", kind: AssetKind::Sound,
|
||||
categories: &["sound/footstep"],
|
||||
aliases: &["footstep", "step", "walk", "walking", "snow", "ice", "winter", "crunch", "cold"] },
|
||||
AudioAlias { key: "impact-sounds/footstep_wood", name: "Footstep (wood)", kind: AssetKind::Sound,
|
||||
categories: &["sound/footstep"],
|
||||
aliases: &["footstep", "step", "walk", "walking", "running", "wood", "wooden", "plank", "floorboard", "footsteps on wood"] },
|
||||
AudioAlias { key: "rpg-audio/footstep", name: "Footstep", kind: AssetKind::Sound,
|
||||
categories: &["sound/footstep"],
|
||||
aliases: &["footstep", "step", "walk", "walking", "running", "medieval", "rpg"] },
|
||||
// --------------------------------------------------------- impacts
|
||||
AudioAlias { key: "impact-sounds/impactGeneric_light", name: "Light Impact", kind: AssetKind::Sound,
|
||||
categories: &["sound/impact"],
|
||||
aliases: &["impact", "hit", "bump", "tap", "knock", "light", "small", "soft", "gentle", "sound when you get hit", "collide"] },
|
||||
AudioAlias { key: "impact-sounds/impactBell_heavy", name: "Bell Impact", kind: AssetKind::Sound,
|
||||
categories: &["sound/impact"],
|
||||
aliases: &["impact", "bell", "clang", "ring", "chime", "metal", "heavy", "gong", "hit"] },
|
||||
AudioAlias { key: "impact-sounds/impactGlass_light", name: "Glass Impact (light)", kind: AssetKind::Sound,
|
||||
categories: &["sound/impact"],
|
||||
aliases: &["impact", "glass", "tink", "clink", "light", "small", "break", "shatter", "hit", "fragile"] },
|
||||
AudioAlias { key: "impact-sounds/impactGlass_medium", name: "Glass Impact", kind: AssetKind::Sound,
|
||||
categories: &["sound/impact"],
|
||||
aliases: &["impact", "glass", "clink", "break", "shatter", "smash", "medium", "hit", "fragile", "window"] },
|
||||
AudioAlias { key: "impact-sounds/impactGlass_heavy", name: "Glass Smash", kind: AssetKind::Sound,
|
||||
categories: &["sound/impact"],
|
||||
aliases: &["impact", "glass", "smash", "shatter", "break", "crash", "heavy", "big", "window", "hit hard"] },
|
||||
AudioAlias { key: "impact-sounds/impactMetal_light", name: "Metal Impact (light)", kind: AssetKind::Sound,
|
||||
categories: &["sound/impact"],
|
||||
aliases: &["impact", "metal", "clink", "ting", "light", "small", "hit", "metallic"] },
|
||||
AudioAlias { key: "impact-sounds/impactMetal_medium", name: "Metal Impact", kind: AssetKind::Sound,
|
||||
categories: &["sound/impact"],
|
||||
aliases: &["impact", "metal", "clang", "bang", "hit", "medium", "metallic", "car", "crash into metal"] },
|
||||
AudioAlias { key: "impact-sounds/impactMetal_heavy", name: "Metal Crash", kind: AssetKind::Sound,
|
||||
categories: &["sound/impact"],
|
||||
aliases: &["impact", "metal", "clang", "crash", "bang", "heavy", "big", "hard", "car crash", "smash", "collision"] },
|
||||
AudioAlias { key: "sci-fi-sounds/impactMetal", name: "Sci-Fi Metal Impact", kind: AssetKind::Sound,
|
||||
categories: &["sound/impact"],
|
||||
aliases: &["impact", "metal", "clang", "sci-fi", "space", "hit", "robot", "mech"] },
|
||||
AudioAlias { key: "impact-sounds/impactWood_light", name: "Wood Impact (light)", kind: AssetKind::Sound,
|
||||
categories: &["sound/impact"],
|
||||
aliases: &["impact", "wood", "wooden", "knock", "tap", "light", "small", "hit"] },
|
||||
AudioAlias { key: "impact-sounds/impactWood_medium", name: "Wood Impact", kind: AssetKind::Sound,
|
||||
categories: &["sound/impact"],
|
||||
aliases: &["impact", "wood", "wooden", "thud", "knock", "hit", "medium", "crate", "box"] },
|
||||
AudioAlias { key: "impact-sounds/impactWood_heavy", name: "Wood Crash", kind: AssetKind::Sound,
|
||||
categories: &["sound/impact"],
|
||||
aliases: &["impact", "wood", "wooden", "crash", "smash", "break", "heavy", "big", "crate", "hit hard"] },
|
||||
AudioAlias { key: "impact-sounds/impactPlank_medium", name: "Plank Impact", kind: AssetKind::Sound,
|
||||
categories: &["sound/impact"],
|
||||
aliases: &["impact", "plank", "wood", "board", "thud", "hit", "wooden"] },
|
||||
AudioAlias { key: "impact-sounds/impactPlate_light", name: "Plate Impact (light)", kind: AssetKind::Sound,
|
||||
categories: &["sound/impact"],
|
||||
aliases: &["impact", "plate", "metal", "sheet", "light", "hit", "ting"] },
|
||||
AudioAlias { key: "impact-sounds/impactPlate_medium", name: "Plate Impact", kind: AssetKind::Sound,
|
||||
categories: &["sound/impact"],
|
||||
aliases: &["impact", "plate", "metal", "sheet", "hit", "clang", "medium"] },
|
||||
AudioAlias { key: "impact-sounds/impactPlate_heavy", name: "Plate Crash", kind: AssetKind::Sound,
|
||||
categories: &["sound/impact"],
|
||||
aliases: &["impact", "plate", "metal", "sheet", "crash", "clang", "heavy", "big", "loud"] },
|
||||
AudioAlias { key: "impact-sounds/impactPunch_medium", name: "Punch", kind: AssetKind::Sound,
|
||||
categories: &["sound/impact"],
|
||||
aliases: &["punch", "hit", "impact", "fight", "thump", "melee", "attack", "sound when you get hit", "smack"] },
|
||||
AudioAlias { key: "impact-sounds/impactPunch_heavy", name: "Heavy Punch", kind: AssetKind::Sound,
|
||||
categories: &["sound/impact"],
|
||||
aliases: &["punch", "hit", "impact", "fight", "thump", "melee", "attack", "heavy", "big", "hard", "smack"] },
|
||||
AudioAlias { key: "impact-sounds/impactSoft_medium", name: "Soft Impact", kind: AssetKind::Sound,
|
||||
categories: &["sound/impact"],
|
||||
aliases: &["impact", "soft", "thud", "muffled", "land", "landing", "body", "hit", "gentle"] },
|
||||
AudioAlias { key: "impact-sounds/impactSoft_heavy", name: "Heavy Soft Impact", kind: AssetKind::Sound,
|
||||
categories: &["sound/impact"],
|
||||
aliases: &["impact", "soft", "thud", "heavy", "land", "landing", "big", "body", "fall"] },
|
||||
AudioAlias { key: "impact-sounds/impactTin_medium", name: "Tin Impact", kind: AssetKind::Sound,
|
||||
categories: &["sound/impact"],
|
||||
aliases: &["impact", "tin", "can", "metal", "rattle", "hit", "clank"] },
|
||||
AudioAlias { key: "impact-sounds/impactMining", name: "Mining Impact", kind: AssetKind::Sound,
|
||||
categories: &["sound/impact"],
|
||||
aliases: &["mining", "pickaxe", "dig", "digging", "rock", "stone", "hit", "mine", "quarry"] },
|
||||
// ---------------------------------------------------------- weapons
|
||||
AudioAlias { key: "sci-fi-sounds/laserSmall", name: "Small Laser", kind: AssetKind::Sound,
|
||||
categories: &["sound/weapon"],
|
||||
aliases: &["laser", "shoot", "shot", "pew", "blaster", "gun", "small", "sci-fi", "space", "zap", "laser gun", "fire"] },
|
||||
AudioAlias { key: "sci-fi-sounds/laserLarge", name: "Large Laser", kind: AssetKind::Sound,
|
||||
categories: &["sound/weapon"],
|
||||
aliases: &["laser", "shoot", "shot", "blaster", "gun", "big", "large", "heavy", "sci-fi", "cannon", "laser gun", "fire"] },
|
||||
AudioAlias { key: "sci-fi-sounds/laserRetro", name: "Retro Laser", kind: AssetKind::Sound,
|
||||
categories: &["sound/weapon"],
|
||||
aliases: &["laser", "shoot", "retro", "arcade", "8-bit", "pew", "blaster", "gun", "old school", "classic"] },
|
||||
AudioAlias { key: "digital-audio/laser", name: "Digital Laser", kind: AssetKind::Sound,
|
||||
categories: &["sound/weapon"],
|
||||
aliases: &["laser", "shoot", "zap", "blaster", "gun", "retro", "8-bit", "arcade", "pew", "digital"] },
|
||||
AudioAlias { key: "digital-audio/zap", name: "Zap", kind: AssetKind::Sound,
|
||||
categories: &["sound/weapon"],
|
||||
aliases: &["zap", "shock", "electric", "laser", "shoot", "retro", "arcade", "8-bit", "hit"] },
|
||||
AudioAlias { key: "digital-audio/zapTwoTone", name: "Zap (two tone)", kind: AssetKind::Sound,
|
||||
categories: &["sound/weapon"],
|
||||
aliases: &["zap", "shoot", "laser", "retro", "arcade", "8-bit", "electric"] },
|
||||
AudioAlias { key: "digital-audio/zapThreeToneUp", name: "Zap Up", kind: AssetKind::Sound,
|
||||
categories: &["sound/weapon", "sound/pickup"],
|
||||
aliases: &["zap", "up", "rising", "powerup", "retro", "arcade", "8-bit", "collect"] },
|
||||
AudioAlias { key: "digital-audio/zapThreeToneDown", name: "Zap Down", kind: AssetKind::Sound,
|
||||
categories: &["sound/weapon"],
|
||||
aliases: &["zap", "down", "falling", "lose", "retro", "arcade", "8-bit", "fail"] },
|
||||
AudioAlias { key: "sci-fi-sounds/explosionCrunch", name: "Explosion", kind: AssetKind::Sound,
|
||||
categories: &["sound/impact"],
|
||||
aliases: &["explosion", "boom", "blast", "crunch", "bang", "destroy", "big", "crash", "blow up", "die"] },
|
||||
AudioAlias { key: "sci-fi-sounds/lowFrequency_explosion", name: "Deep Explosion", kind: AssetKind::Sound,
|
||||
categories: &["sound/impact"],
|
||||
aliases: &["explosion", "boom", "blast", "deep", "low", "rumble", "big", "heavy", "destroy", "blow up"] },
|
||||
AudioAlias { key: "rpg-audio/knifeSlice", name: "Knife Slice", kind: AssetKind::Sound,
|
||||
categories: &["sound/weapon"],
|
||||
aliases: &["knife", "slice", "slash", "sword", "cut", "blade", "attack", "swing", "melee", "medieval"] },
|
||||
AudioAlias { key: "rpg-audio/drawKnife", name: "Draw Knife", kind: AssetKind::Sound,
|
||||
categories: &["sound/weapon"],
|
||||
aliases: &["knife", "draw", "unsheathe", "sword", "blade", "equip", "medieval", "weapon"] },
|
||||
AudioAlias { key: "rpg-audio/chop", name: "Chop", kind: AssetKind::Sound,
|
||||
categories: &["sound/weapon"],
|
||||
aliases: &["chop", "axe", "cut", "wood", "hit", "attack", "melee", "hack"] },
|
||||
// ---------------------------------------------------------- engines
|
||||
AudioAlias { key: "sci-fi-sounds/spaceEngine", name: "Space Engine", kind: AssetKind::Sound,
|
||||
categories: &["sound/engine"],
|
||||
aliases: &["engine", "motor", "rocket", "thrust", "sci-fi", "space", "hum", "flying", "loop"] },
|
||||
AudioAlias { key: "sci-fi-sounds/spaceEngineSmall", name: "Small Space Engine", kind: AssetKind::Sound,
|
||||
categories: &["sound/engine"],
|
||||
aliases: &["engine", "motor", "small", "thrust", "sci-fi", "space", "hum", "loop"] },
|
||||
AudioAlias { key: "sci-fi-sounds/spaceEngineLarge", name: "Large Space Engine", kind: AssetKind::Sound,
|
||||
categories: &["sound/engine"],
|
||||
aliases: &["engine", "motor", "big", "large", "thrust", "sci-fi", "space", "rumble", "loop"] },
|
||||
AudioAlias { key: "sci-fi-sounds/spaceEngineLow", name: "Low Space Engine", kind: AssetKind::Sound,
|
||||
categories: &["sound/engine"],
|
||||
aliases: &["engine", "motor", "low", "deep", "sci-fi", "space", "rumble", "hum", "loop"] },
|
||||
AudioAlias { key: "sci-fi-sounds/engineCircular", name: "Circular Engine", kind: AssetKind::Sound,
|
||||
categories: &["sound/engine"],
|
||||
aliases: &["engine", "motor", "machine", "loop", "hum", "whirr", "sci-fi", "machinery"] },
|
||||
AudioAlias { key: "sci-fi-sounds/thrusterFire", name: "Thruster", kind: AssetKind::Sound,
|
||||
categories: &["sound/engine"],
|
||||
aliases: &["thruster", "rocket", "boost", "jet", "engine", "fire", "sci-fi", "space", "launch"] },
|
||||
// -------------------------------------------------------- ui / menu
|
||||
AudioAlias { key: "interface-sounds/click", name: "Click", kind: AssetKind::Sound,
|
||||
categories: &["sound/ui"],
|
||||
aliases: &["click", "button", "press", "tap", "ui", "menu", "select", "interface", "tick"] },
|
||||
AudioAlias { key: "ui-audio/click", name: "UI Click", kind: AssetKind::Sound,
|
||||
categories: &["sound/ui"],
|
||||
aliases: &["click", "button", "press", "ui", "menu", "interface", "select"] },
|
||||
AudioAlias { key: "ui-audio/mouseclick", name: "Mouse Click", kind: AssetKind::Sound,
|
||||
categories: &["sound/ui"],
|
||||
aliases: &["click", "mouse", "button", "press", "ui", "menu", "interface"] },
|
||||
AudioAlias { key: "ui-audio/mouserelease", name: "Mouse Release", kind: AssetKind::Sound,
|
||||
categories: &["sound/ui"],
|
||||
aliases: &["release", "click", "mouse", "button", "unpress", "ui", "menu"] },
|
||||
AudioAlias { key: "ui-audio/rollover", name: "Rollover", kind: AssetKind::Sound,
|
||||
categories: &["sound/ui"],
|
||||
aliases: &["rollover", "hover", "highlight", "ui", "menu", "focus", "interface"] },
|
||||
AudioAlias { key: "interface-sounds/select", name: "Select", kind: AssetKind::Sound,
|
||||
categories: &["sound/ui"],
|
||||
aliases: &["select", "choose", "pick", "ui", "menu", "confirm", "interface"] },
|
||||
AudioAlias { key: "interface-sounds/confirmation", name: "Confirm", kind: AssetKind::Sound,
|
||||
categories: &["sound/ui"],
|
||||
aliases: &["confirm", "ok", "accept", "yes", "success", "ui", "menu", "positive", "good"] },
|
||||
AudioAlias { key: "interface-sounds/error", name: "Error", kind: AssetKind::Sound,
|
||||
categories: &["sound/ui"],
|
||||
aliases: &["error", "wrong", "fail", "bad", "no", "denied", "buzz", "ui", "menu", "negative", "lose"] },
|
||||
AudioAlias { key: "interface-sounds/question", name: "Question", kind: AssetKind::Sound,
|
||||
categories: &["sound/ui"],
|
||||
aliases: &["question", "prompt", "ask", "ui", "menu", "dialog", "notify"] },
|
||||
AudioAlias { key: "interface-sounds/back", name: "Back", kind: AssetKind::Sound,
|
||||
categories: &["sound/ui"],
|
||||
aliases: &["back", "cancel", "return", "undo", "ui", "menu", "escape", "previous"] },
|
||||
AudioAlias { key: "interface-sounds/close", name: "Close", kind: AssetKind::Sound,
|
||||
categories: &["sound/ui"],
|
||||
aliases: &["close", "shut", "dismiss", "ui", "menu", "window", "exit"] },
|
||||
AudioAlias { key: "interface-sounds/open", name: "Open", kind: AssetKind::Sound,
|
||||
categories: &["sound/ui"],
|
||||
aliases: &["open", "show", "reveal", "ui", "menu", "window", "appear"] },
|
||||
AudioAlias { key: "interface-sounds/maximize", name: "Maximize", kind: AssetKind::Sound,
|
||||
categories: &["sound/ui"],
|
||||
aliases: &["maximize", "expand", "grow", "bigger", "ui", "window", "up"] },
|
||||
AudioAlias { key: "interface-sounds/minimize", name: "Minimize", kind: AssetKind::Sound,
|
||||
categories: &["sound/ui"],
|
||||
aliases: &["minimize", "shrink", "smaller", "collapse", "ui", "window", "down"] },
|
||||
AudioAlias { key: "interface-sounds/toggle", name: "Toggle", kind: AssetKind::Sound,
|
||||
categories: &["sound/ui"],
|
||||
aliases: &["toggle", "switch", "flip", "on", "off", "ui", "menu", "checkbox"] },
|
||||
AudioAlias { key: "interface-sounds/switch", name: "Switch", kind: AssetKind::Sound,
|
||||
categories: &["sound/ui"],
|
||||
aliases: &["switch", "toggle", "change", "flip", "ui", "menu", "lever"] },
|
||||
AudioAlias { key: "ui-audio/switch", name: "UI Switch", kind: AssetKind::Sound,
|
||||
categories: &["sound/ui"],
|
||||
aliases: &["switch", "toggle", "change", "ui", "menu"] },
|
||||
AudioAlias { key: "interface-sounds/scroll", name: "Scroll", kind: AssetKind::Sound,
|
||||
categories: &["sound/ui"],
|
||||
aliases: &["scroll", "wheel", "list", "ui", "menu", "move"] },
|
||||
AudioAlias { key: "interface-sounds/tick", name: "Tick", kind: AssetKind::Sound,
|
||||
categories: &["sound/ui"],
|
||||
aliases: &["tick", "click", "small", "ui", "menu", "increment", "timer"] },
|
||||
AudioAlias { key: "interface-sounds/drop", name: "Drop", kind: AssetKind::Sound,
|
||||
categories: &["sound/ui", "sound/impact"],
|
||||
aliases: &["drop", "place", "put", "release", "ui", "inventory", "item"] },
|
||||
AudioAlias { key: "interface-sounds/pluck", name: "Pluck", kind: AssetKind::Sound,
|
||||
categories: &["sound/ui"],
|
||||
aliases: &["pluck", "pop", "pick", "ui", "menu", "string", "note"] },
|
||||
AudioAlias { key: "interface-sounds/bong", name: "Bong", kind: AssetKind::Sound,
|
||||
categories: &["sound/ui"],
|
||||
aliases: &["bong", "bell", "chime", "gong", "notify", "alert", "ui", "deep"] },
|
||||
AudioAlias { key: "interface-sounds/glass", name: "Glass UI", kind: AssetKind::Sound,
|
||||
categories: &["sound/ui"],
|
||||
aliases: &["glass", "chime", "ting", "ui", "menu", "delicate", "clean"] },
|
||||
AudioAlias { key: "interface-sounds/glitch", name: "Glitch", kind: AssetKind::Sound,
|
||||
categories: &["sound/ui"],
|
||||
aliases: &["glitch", "error", "digital", "broken", "static", "corrupt", "sci-fi", "bad"] },
|
||||
AudioAlias { key: "interface-sounds/scratch", name: "Scratch", kind: AssetKind::Sound,
|
||||
categories: &["sound/ui"],
|
||||
aliases: &["scratch", "scrape", "rough", "record", "ui", "rub"] },
|
||||
// ------------------------------------------------------- pickups etc
|
||||
AudioAlias { key: "digital-audio/powerUp", name: "Power Up", kind: AssetKind::Sound,
|
||||
categories: &["sound/pickup"],
|
||||
aliases: &["powerup", "power up", "collect", "pickup", "coin", "bonus", "good", "reward", "retro", "8-bit", "arcade", "level up", "get"] },
|
||||
AudioAlias { key: "digital-audio/pepSound", name: "Pep Sound", kind: AssetKind::Sound,
|
||||
categories: &["sound/pickup"],
|
||||
aliases: &["pep", "blip", "collect", "pickup", "coin", "point", "retro", "8-bit", "arcade", "positive"] },
|
||||
AudioAlias { key: "digital-audio/highUp", name: "High Up", kind: AssetKind::Sound,
|
||||
categories: &["sound/pickup"],
|
||||
aliases: &["up", "rising", "high", "collect", "pickup", "jump", "positive", "good", "retro", "8-bit"] },
|
||||
AudioAlias { key: "digital-audio/highDown", name: "High Down", kind: AssetKind::Sound,
|
||||
categories: &["sound/ui"],
|
||||
aliases: &["down", "falling", "high", "lose", "negative", "bad", "retro", "8-bit"] },
|
||||
AudioAlias { key: "digital-audio/lowDown", name: "Low Down", kind: AssetKind::Sound,
|
||||
categories: &["sound/ui"],
|
||||
aliases: &["down", "low", "falling", "lose", "fail", "negative", "bad", "die", "retro", "8-bit", "game over"] },
|
||||
AudioAlias { key: "digital-audio/phaseJump", name: "Phase Jump", kind: AssetKind::Sound,
|
||||
categories: &["sound/pickup"],
|
||||
aliases: &["jump", "warp", "teleport", "phase", "retro", "8-bit", "arcade", "sci-fi", "hop"] },
|
||||
AudioAlias { key: "digital-audio/phaserUp", name: "Phaser Up", kind: AssetKind::Sound,
|
||||
categories: &["sound/pickup"],
|
||||
aliases: &["phaser", "up", "rising", "charge", "powerup", "retro", "8-bit", "sci-fi"] },
|
||||
AudioAlias { key: "digital-audio/phaserDown", name: "Phaser Down", kind: AssetKind::Sound,
|
||||
categories: &["sound/ui"],
|
||||
aliases: &["phaser", "down", "falling", "power down", "retro", "8-bit", "sci-fi", "lose"] },
|
||||
AudioAlias { key: "digital-audio/tone", name: "Tone", kind: AssetKind::Sound,
|
||||
categories: &["sound/ui"],
|
||||
aliases: &["tone", "beep", "note", "blip", "simple", "retro", "8-bit"] },
|
||||
AudioAlias { key: "digital-audio/twoTone", name: "Two Tone", kind: AssetKind::Sound,
|
||||
categories: &["sound/ui"],
|
||||
aliases: &["two tone", "beep", "notify", "alert", "retro", "8-bit", "double"] },
|
||||
AudioAlias { key: "digital-audio/threeTone", name: "Three Tone", kind: AssetKind::Sound,
|
||||
categories: &["sound/ui"],
|
||||
aliases: &["three tone", "beep", "notify", "alert", "retro", "8-bit", "triple", "jingle"] },
|
||||
AudioAlias { key: "digital-audio/lowThreeTone", name: "Low Three Tone", kind: AssetKind::Sound,
|
||||
categories: &["sound/ui"],
|
||||
aliases: &["three tone", "low", "deep", "beep", "alert", "retro", "8-bit", "warning"] },
|
||||
AudioAlias { key: "digital-audio/lowRandom", name: "Low Random", kind: AssetKind::Sound,
|
||||
categories: &["sound/ui"],
|
||||
aliases: &["low", "random", "noise", "beep", "retro", "8-bit", "glitch"] },
|
||||
AudioAlias { key: "digital-audio/spaceTrash", name: "Space Trash", kind: AssetKind::Sound,
|
||||
categories: &["sound/ui"],
|
||||
aliases: &["noise", "static", "trash", "junk", "sci-fi", "space", "retro", "8-bit", "glitch"] },
|
||||
// --------------------------------------------------------- sci-fi misc
|
||||
AudioAlias { key: "sci-fi-sounds/computerNoise", name: "Computer Noise", kind: AssetKind::Sound,
|
||||
categories: &["sound/ambient"],
|
||||
aliases: &["computer", "beep", "data", "terminal", "sci-fi", "tech", "processing", "machine"] },
|
||||
AudioAlias { key: "sci-fi-sounds/forceField", name: "Force Field", kind: AssetKind::Sound,
|
||||
categories: &["sound/ambient"],
|
||||
aliases: &["force field", "shield", "barrier", "energy", "hum", "sci-fi", "loop", "protect"] },
|
||||
AudioAlias { key: "sci-fi-sounds/slime", name: "Slime", kind: AssetKind::Sound,
|
||||
categories: &["sound/ambient"],
|
||||
aliases: &["slime", "squelch", "goo", "wet", "gross", "monster", "splat", "organic"] },
|
||||
AudioAlias { key: "sci-fi-sounds/doorOpen", name: "Sci-Fi Door Open", kind: AssetKind::Sound,
|
||||
categories: &["sound/ambient"],
|
||||
aliases: &["door", "open", "sci-fi", "space", "hiss", "automatic", "airlock"] },
|
||||
AudioAlias { key: "sci-fi-sounds/doorClose", name: "Sci-Fi Door Close", kind: AssetKind::Sound,
|
||||
categories: &["sound/ambient"],
|
||||
aliases: &["door", "close", "shut", "sci-fi", "space", "hiss", "automatic", "airlock"] },
|
||||
// -------------------------------------------------------------- rpg
|
||||
AudioAlias { key: "rpg-audio/doorOpen", name: "Door Open", kind: AssetKind::Sound,
|
||||
categories: &["sound/ambient"],
|
||||
aliases: &["door", "open", "creak", "wooden", "medieval", "rpg", "enter"] },
|
||||
AudioAlias { key: "rpg-audio/doorClose", name: "Door Close", kind: AssetKind::Sound,
|
||||
categories: &["sound/ambient"],
|
||||
aliases: &["door", "close", "shut", "slam", "wooden", "medieval", "rpg"] },
|
||||
AudioAlias { key: "rpg-audio/creak", name: "Creak", kind: AssetKind::Sound,
|
||||
categories: &["sound/ambient"],
|
||||
aliases: &["creak", "squeak", "wood", "old", "spooky", "scary", "door", "medieval"] },
|
||||
AudioAlias { key: "rpg-audio/handleCoins", name: "Coins", kind: AssetKind::Sound,
|
||||
categories: &["sound/pickup"],
|
||||
aliases: &["coins", "money", "gold", "jingle", "collect", "pickup", "treasure", "rich", "shop", "buy", "reward"] },
|
||||
AudioAlias { key: "rpg-audio/bookOpen", name: "Book Open", kind: AssetKind::Sound,
|
||||
categories: &["sound/ui"],
|
||||
aliases: &["book", "open", "page", "read", "menu", "journal", "medieval", "rpg"] },
|
||||
AudioAlias { key: "rpg-audio/bookClose", name: "Book Close", kind: AssetKind::Sound,
|
||||
categories: &["sound/ui"],
|
||||
aliases: &["book", "close", "shut", "read", "menu", "journal", "medieval", "rpg"] },
|
||||
AudioAlias { key: "rpg-audio/bookFlip", name: "Page Flip", kind: AssetKind::Sound,
|
||||
categories: &["sound/ui"],
|
||||
aliases: &["page", "flip", "turn", "book", "paper", "read", "menu", "next"] },
|
||||
AudioAlias { key: "rpg-audio/bookPlace", name: "Book Place", kind: AssetKind::Sound,
|
||||
categories: &["sound/ui"],
|
||||
aliases: &["book", "place", "put", "down", "thud", "menu", "medieval"] },
|
||||
AudioAlias { key: "rpg-audio/cloth", name: "Cloth", kind: AssetKind::Sound,
|
||||
categories: &["sound/ambient"],
|
||||
aliases: &["cloth", "fabric", "rustle", "clothes", "equip", "swish", "medieval"] },
|
||||
AudioAlias { key: "rpg-audio/clothBelt", name: "Cloth Belt", kind: AssetKind::Sound,
|
||||
categories: &["sound/ambient"],
|
||||
aliases: &["belt", "cloth", "buckle", "equip", "armour", "armor", "medieval"] },
|
||||
AudioAlias { key: "rpg-audio/beltHandle", name: "Belt Handle", kind: AssetKind::Sound,
|
||||
categories: &["sound/ambient"],
|
||||
aliases: &["belt", "leather", "handle", "equip", "inventory", "medieval"] },
|
||||
AudioAlias { key: "rpg-audio/dropLeather", name: "Drop Leather", kind: AssetKind::Sound,
|
||||
categories: &["sound/ui"],
|
||||
aliases: &["leather", "drop", "place", "inventory", "item", "medieval", "bag"] },
|
||||
AudioAlias { key: "rpg-audio/handleSmallLeather", name: "Leather Handle", kind: AssetKind::Sound,
|
||||
categories: &["sound/ui"],
|
||||
aliases: &["leather", "handle", "bag", "pouch", "inventory", "item", "medieval"] },
|
||||
AudioAlias { key: "rpg-audio/metalClick", name: "Metal Click", kind: AssetKind::Sound,
|
||||
categories: &["sound/ui"],
|
||||
aliases: &["metal", "click", "latch", "lock", "mechanism", "equip", "medieval"] },
|
||||
AudioAlias { key: "rpg-audio/metalLatch", name: "Metal Latch", kind: AssetKind::Sound,
|
||||
categories: &["sound/ui"],
|
||||
aliases: &["metal", "latch", "lock", "chest", "click", "mechanism", "open", "medieval"] },
|
||||
AudioAlias { key: "rpg-audio/metalPot", name: "Metal Pot", kind: AssetKind::Sound,
|
||||
categories: &["sound/impact"],
|
||||
aliases: &["pot", "metal", "pan", "clang", "kitchen", "cooking", "medieval", "hit"] },
|
||||
// ------------------------------------------------------------- music
|
||||
AudioAlias { key: "music-jingles/jingles_HIT", name: "Jingle (hit)", kind: AssetKind::Music,
|
||||
categories: &["music/jingle"],
|
||||
aliases: &["jingle", "music", "sting", "hit", "short", "fanfare", "tune", "melody"] },
|
||||
AudioAlias { key: "music-jingles/jingles_NES", name: "Jingle (NES)", kind: AssetKind::Music,
|
||||
categories: &["music/jingle"],
|
||||
aliases: &["jingle", "music", "nes", "retro", "8-bit", "chiptune", "arcade", "sting", "happy", "victory", "win", "tune"] },
|
||||
AudioAlias { key: "music-jingles/jingles_PIZZI", name: "Jingle (pizzicato)", kind: AssetKind::Music,
|
||||
categories: &["music/jingle"],
|
||||
aliases: &["jingle", "music", "pizzicato", "strings", "playful", "cute", "light", "happy", "sting", "tune"] },
|
||||
AudioAlias { key: "music-jingles/jingles_SAX", name: "Jingle (sax)", kind: AssetKind::Music,
|
||||
categories: &["music/jingle"],
|
||||
aliases: &["jingle", "music", "sax", "saxophone", "jazzy", "smooth", "sting", "happy", "tune"] },
|
||||
AudioAlias { key: "music-jingles/jingles_STEEL", name: "Jingle (steel drum)", kind: AssetKind::Music,
|
||||
categories: &["music/jingle"],
|
||||
aliases: &["jingle", "music", "steel drum", "tropical", "happy", "cheerful", "sting", "tune", "island"] },
|
||||
];
|
||||
|
||||
/// Extra query expansions that only make sense for audio.
|
||||
pub const AUDIO_SYNONYMS: &[(&str, &[&str])] = &[
|
||||
("sfx", &["sound"]),
|
||||
("noise", &["sound"]),
|
||||
("audio", &["sound"]),
|
||||
("smash", &["impact", "crash", "break"]),
|
||||
("crash", &["impact", "smash"]),
|
||||
("bump", &["impact", "hit"]),
|
||||
("thud", &["impact", "soft"]),
|
||||
("clang", &["metal", "impact"]),
|
||||
("bang", &["impact", "explosion"]),
|
||||
("boom", &["explosion"]),
|
||||
("pew", &["laser"]),
|
||||
("shoot", &["laser", "weapon"]),
|
||||
("gunshot", &["laser", "weapon"]),
|
||||
("fanfare", &["jingle", "victory"]),
|
||||
("victory", &["win", "jingle"]),
|
||||
("win", &["victory", "jingle", "powerup"]),
|
||||
("winning", &["victory", "win", "jingle"]),
|
||||
("lose", &["fail", "error", "down"]),
|
||||
("losing", &["lose", "fail"]),
|
||||
("gameover", &["lose", "fail", "down"]),
|
||||
("background", &["music", "loop", "ambient"]),
|
||||
("soundtrack", &["music", "jingle"]),
|
||||
("song", &["music", "jingle"]),
|
||||
("tune", &["music", "jingle"]),
|
||||
("chiptune", &["8-bit", "retro", "nes"]),
|
||||
("footsteps", &["footstep"]),
|
||||
("splash", &["water", "slime"]),
|
||||
("whoosh", &["swish", "cloth"]),
|
||||
("skid", &["engine", "tyre"]),
|
||||
("beep", &["tone", "ui"]),
|
||||
];
|
||||
|
||||
pub fn lookup(key: &str) -> Option<&'static AudioAlias> {
|
||||
AUDIO_ALIASES.iter().find(|a| a.key == key)
|
||||
}
|
||||
|
||||
/// Strip Kenney's variant suffix so `footstep_wood_003` maps to the curated
|
||||
/// `footstep_wood` family.
|
||||
pub fn family_of(stem: &str) -> String {
|
||||
let trimmed = stem.trim_end_matches(|c: char| c.is_ascii_digit());
|
||||
trimmed.trim_end_matches(['_', '-', ' ']).to_string()
|
||||
}
|
||||
|
|
@ -1,326 +0,0 @@
|
|||
//! Minimal GLB probe: rigged/animated flags and an approximate bounding size.
|
||||
//!
|
||||
//! Deliberately not a glTF parser — `libs/game/render` owns the real one. This
|
||||
//! reads the container header and scans the JSON chunk textually for the three
|
||||
//! facts the index needs. Everything is optional: a file we cannot understand
|
||||
//! yields `Probe::default()` and the entry is still indexed and searchable.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
pub struct Probe {
|
||||
pub rigged: bool,
|
||||
pub animated: bool,
|
||||
pub size: Option<[f32; 3]>,
|
||||
/// Animation clip names in file order. This is the fact the AI cannot
|
||||
/// guess: it must know a character HAS `Jump_Land` before writing code
|
||||
/// that plays it, and every pack names its states differently.
|
||||
pub clips: Vec<String>,
|
||||
/// Joint count of the first skin. Equal counts usually mean the same rig,
|
||||
/// which is what lets one animation vocabulary drive many characters.
|
||||
pub joints: u32,
|
||||
}
|
||||
|
||||
/// "glTF" little-endian. The previous value (0x4655_4C67) spells "gLUF", so
|
||||
/// the magic check rejected every real GLB and `probe` returned defaults for
|
||||
/// the whole library — no model was ever detected as rigged, animated or
|
||||
/// sized. Kept as a named constant with this note because the failure was
|
||||
/// invisible: the index still built, it just believed nothing.
|
||||
const GLB_MAGIC: u32 = 0x4654_6C67;
|
||||
const CHUNK_JSON: u32 = 0x4E4F_534A; // "JSON"
|
||||
/// The JSON chunk of a Kenney-scale model is a few KB; cap the read so a
|
||||
/// hostile or corrupt file cannot make us allocate wildly.
|
||||
const MAX_JSON: usize = 4 * 1024 * 1024;
|
||||
|
||||
pub fn probe(path: &Path) -> Probe {
|
||||
// Read only the JSON chunk, not the mesh data behind it. A GLB states its
|
||||
// JSON length in the header, so indexing 5000 models touches a few KB
|
||||
// each instead of the whole file — the difference between a 7 s startup
|
||||
// and a fraction of a second.
|
||||
let Ok(bytes) = read_head(path) else {
|
||||
return Probe::default();
|
||||
};
|
||||
// Two containers reach us: .glb wraps the JSON in a binary chunk (Kenney,
|
||||
// KayKit), while .gltf IS the JSON (Quaternius ships self-contained .gltf
|
||||
// with the buffer base64'd inline). Only the header differs.
|
||||
let Some(json) = json_chunk(&bytes).or_else(|| gltf_json(&bytes)) else {
|
||||
return Probe::default();
|
||||
};
|
||||
let clips = clip_names(&json);
|
||||
Probe {
|
||||
// Presence of a skin means a skeleton; `"skins"` only appears as a
|
||||
// top-level array key in glTF.
|
||||
rigged: json.contains("\"skins\""),
|
||||
animated: !clips.is_empty() || json.contains("\"animations\""),
|
||||
size: bounds(&json),
|
||||
joints: joint_count(&json),
|
||||
clips,
|
||||
}
|
||||
}
|
||||
|
||||
/// Read just enough of the file to answer the probe.
|
||||
///
|
||||
/// For a GLB that is the 20-byte header plus the JSON chunk it declares. For
|
||||
/// a plain `.gltf` there is no such framing, so the document is read whole but
|
||||
/// capped — those embed their buffer as base64 and run to megabytes.
|
||||
fn read_head(path: &Path) -> std::io::Result<Vec<u8>> {
|
||||
use std::io::Read;
|
||||
let mut f = std::fs::File::open(path)?;
|
||||
let mut head = [0u8; 20];
|
||||
let n = f.read(&mut head)?;
|
||||
if n == 20 && u32(&head, 0) == Some(GLB_MAGIC) {
|
||||
let len = u32(&head, 12).unwrap_or(0) as usize;
|
||||
if u32(&head, 16) == Some(CHUNK_JSON) && len <= MAX_JSON {
|
||||
let mut out = head.to_vec();
|
||||
out.resize(20 + len, 0);
|
||||
// A truncated file yields a short read; the caller's bounds check
|
||||
// rejects it rather than reading past what arrived.
|
||||
let got = f.read(&mut out[20..])?;
|
||||
out.truncate(20 + got);
|
||||
return Ok(out);
|
||||
}
|
||||
return Ok(head.to_vec());
|
||||
}
|
||||
// Not a GLB: fall back to the whole document, capped.
|
||||
let mut out = head[..n].to_vec();
|
||||
f.take(MAX_JSON as u64).read_to_end(&mut out)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A plain `.gltf` file is the JSON document itself. Cap it like the GLB path:
|
||||
/// Quaternius embeds its buffer as base64, so these run to a few MB.
|
||||
fn gltf_json(bytes: &[u8]) -> Option<String> {
|
||||
if bytes.len() > 64 * 1024 * 1024 {
|
||||
return None;
|
||||
}
|
||||
let s = String::from_utf8_lossy(bytes);
|
||||
let head = &s[..s.len().min(512)];
|
||||
if !head.trim_start().starts_with('{') || !head.contains("\"asset\"") {
|
||||
return None;
|
||||
}
|
||||
Some(s.into_owned())
|
||||
}
|
||||
|
||||
/// Clip names, read from the `"animations"` array only.
|
||||
///
|
||||
/// Scoped by bracket-matching rather than a global scan for `"name"`, because
|
||||
/// meshes, nodes and materials all carry names too — a global scan would
|
||||
/// report "Cube.003" as an animation state.
|
||||
fn clip_names(json: &str) -> Vec<String> {
|
||||
let Some(arr) = array_after(json, "\"animations\"") else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
let mut depth = 0i32;
|
||||
let mut rest = arr;
|
||||
// Names at object-depth 1 are the animations themselves; deeper ones
|
||||
// belong to channels and samplers. `]` at depth 0 ends the array — without
|
||||
// that stop the scan runs on into `materials` and reports texture names as
|
||||
// animation states.
|
||||
while let Some(i) = rest.find(|c| c == '{' || c == '}' || c == '[' || c == ']' || c == '"') {
|
||||
let ch = rest.as_bytes()[i];
|
||||
match ch {
|
||||
b'{' | b'[' => {
|
||||
depth += 1;
|
||||
rest = &rest[i + 1..];
|
||||
}
|
||||
b']' if depth == 0 => break,
|
||||
b'}' | b']' => {
|
||||
depth -= 1;
|
||||
rest = &rest[i + 1..];
|
||||
}
|
||||
_ => {
|
||||
let after = &rest[i..];
|
||||
if depth == 1 && after.starts_with("\"name\"") {
|
||||
if let Some(v) = string_value(&after[6..]) {
|
||||
out.push(v);
|
||||
}
|
||||
}
|
||||
// Skip the whole string so its contents can't be re-scanned.
|
||||
rest = match string_end(after) {
|
||||
Some(e) => &after[e..],
|
||||
None => break,
|
||||
};
|
||||
}
|
||||
}
|
||||
if out.len() > 512 {
|
||||
break; // absurd clip count: stop rather than grow unbounded
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn joint_count(json: &str) -> u32 {
|
||||
let Some(arr) = array_after(json, "\"joints\"") else {
|
||||
return 0;
|
||||
};
|
||||
let Some(end) = arr.find(']') else { return 0 };
|
||||
arr[..end]
|
||||
.split(',')
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.count() as u32
|
||||
}
|
||||
|
||||
/// Slice starting just after the `[` that follows `key`.
|
||||
fn array_after<'a>(json: &'a str, key: &str) -> Option<&'a str> {
|
||||
let at = json.find(key)?;
|
||||
let open = json[at..].find('[')? + at;
|
||||
Some(&json[open + 1..])
|
||||
}
|
||||
|
||||
/// Read `"..."` from the head of `s` (after a `:`), honouring backslash escapes.
|
||||
fn string_value(s: &str) -> Option<String> {
|
||||
let colon = s.find(':')?;
|
||||
let rest = &s[colon + 1..];
|
||||
let open = rest.find('"')?;
|
||||
let body = &rest[open + 1..];
|
||||
let end = unescaped_quote(body)?;
|
||||
Some(body[..end].to_string())
|
||||
}
|
||||
|
||||
/// Byte offset just past the closing quote of the string starting at `s[0]`.
|
||||
fn string_end(s: &str) -> Option<usize> {
|
||||
let body = &s[1..];
|
||||
Some(1 + unescaped_quote(body)? + 1)
|
||||
}
|
||||
|
||||
fn unescaped_quote(s: &str) -> Option<usize> {
|
||||
let b = s.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < b.len() {
|
||||
match b[i] {
|
||||
b'\\' => i += 2,
|
||||
b'"' => return Some(i),
|
||||
_ => i += 1,
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn json_chunk(bytes: &[u8]) -> Option<String> {
|
||||
if bytes.len() < 20 || u32(bytes, 0)? != GLB_MAGIC {
|
||||
return None;
|
||||
}
|
||||
let len = u32(bytes, 12)? as usize;
|
||||
if u32(bytes, 16)? != CHUNK_JSON || len > MAX_JSON {
|
||||
return None;
|
||||
}
|
||||
let start = 20usize;
|
||||
let end = start.checked_add(len)?;
|
||||
if end > bytes.len() {
|
||||
return None;
|
||||
}
|
||||
Some(String::from_utf8_lossy(&bytes[start..end]).into_owned())
|
||||
}
|
||||
|
||||
fn u32(b: &[u8], at: usize) -> Option<u32> {
|
||||
let s = b.get(at..at + 4)?;
|
||||
Some(u32::from_le_bytes([s[0], s[1], s[2], s[3]]))
|
||||
}
|
||||
|
||||
/// Approximate bounds from the first 3-component accessor min/max pair, which
|
||||
/// by glTF convention is a POSITION accessor (they are the only accessors
|
||||
/// required to carry min/max). Approximate on purpose: it drives "big vs
|
||||
/// small" filters, not collision.
|
||||
fn bounds(json: &str) -> Option<[f32; 3]> {
|
||||
// Search each key independently rather than assuming an order. Kenney's
|
||||
// exporter writes "max" BEFORE "min", so looking for "max" only after
|
||||
// "min" found nothing and every Kenney model came back sizeless — which
|
||||
// silently disabled the big/small filters across the whole library.
|
||||
let min = first_triple(json, "\"min\"")?;
|
||||
let max = first_triple(json, "\"max\"")?;
|
||||
let (lo, hi) = (min, max);
|
||||
Some([
|
||||
(hi[0] - lo[0]).abs(),
|
||||
(hi[1] - lo[1]).abs(),
|
||||
(hi[2] - lo[2]).abs(),
|
||||
])
|
||||
}
|
||||
|
||||
/// First `[a,b,c]` following `key` anywhere in the document. The first
|
||||
/// accessor carrying bounds is POSITION by glTF convention (only it is
|
||||
/// required to have them), so the first hit is the mesh's own extent.
|
||||
fn first_triple(json: &str, key: &str) -> Option<[f32; 3]> {
|
||||
let mut rest = json;
|
||||
while let Some(pos) = rest.find(key) {
|
||||
let after = &rest[pos + key.len()..];
|
||||
if let Some(v) = triple(after) {
|
||||
return Some(v);
|
||||
}
|
||||
rest = &rest[pos + key.len()..];
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Read `[a,b,c]` (exactly three numbers) from the head of `s`.
|
||||
fn triple(s: &str) -> Option<[f32; 3]> {
|
||||
let open = s.find('[')?;
|
||||
let close = s[open..].find(']')? + open;
|
||||
let mut it = s[open + 1..close].split(',');
|
||||
let a = it.next()?.trim().parse::<f32>().ok()?;
|
||||
let b = it.next()?.trim().parse::<f32>().ok()?;
|
||||
let c = it.next()?.trim().parse::<f32>().ok()?;
|
||||
if it.next().is_some() {
|
||||
return None; // 4+ components: not a POSITION accessor
|
||||
}
|
||||
Some([a, b, c])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Clip extraction must read the `animations` array and nothing else:
|
||||
/// meshes, nodes and materials all carry `"name"` too, and a global scan
|
||||
/// would report mesh names as playable animation states.
|
||||
#[test]
|
||||
fn clip_names_come_only_from_the_animations_array() {
|
||||
let json = r#"{"asset":{},"meshes":[{"name":"Cube.003"}],
|
||||
"nodes":[{"name":"Armature"}],
|
||||
"animations":[{"name":"Idle","channels":[{"target":{"path":"translation"}}]},
|
||||
{"name":"Walk_A","samplers":[{"input":0}]}],
|
||||
"materials":[{"name":"Atlas"}]}"#;
|
||||
assert_eq!(clip_names(json), vec!["Idle", "Walk_A"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_names_survive_escapes_and_missing_animations() {
|
||||
let json = r#"{"animations":[{"name":"Say \"Hi\""},{"name":"Wave"}]}"#;
|
||||
assert_eq!(clip_names(json), vec!["Say \\\"Hi\\\"", "Wave"]);
|
||||
assert!(clip_names(r#"{"meshes":[{"name":"X"}]}"#).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn joint_count_reads_the_first_skin() {
|
||||
assert_eq!(joint_count(r#"{"skins":[{"joints":[1,2,3,4]}]}"#), 4);
|
||||
assert_eq!(joint_count(r#"{"meshes":[]}"#), 0);
|
||||
}
|
||||
|
||||
/// A plain .gltf is the JSON itself — Quaternius ships those with the
|
||||
/// buffer base64'd inline, so there is no binary chunk to unwrap.
|
||||
#[test]
|
||||
fn plain_gltf_is_recognised_and_junk_is_not() {
|
||||
let ok = br#"{"asset":{"version":"2.0"},"animations":[{"name":"Idle"}]}"#;
|
||||
assert!(gltf_json(ok).is_some());
|
||||
assert!(gltf_json(b"not json at all").is_none());
|
||||
assert!(gltf_json(br#"{"nope":1}"#).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod bounds_tests {
|
||||
use super::*;
|
||||
|
||||
/// Kenney's exporter writes "max" BEFORE "min". Searching for "max" only
|
||||
/// after "min" found nothing, so every Kenney model indexed with no size
|
||||
/// and the big/small filters silently matched nothing.
|
||||
#[test]
|
||||
fn bounds_do_not_depend_on_key_order() {
|
||||
let kenney = r#"{"accessors":[{"type":"VEC3","max":[0.3,1.02,0.125],"min":[-0.3,0.0,-0.1]}]}"#;
|
||||
let other = r#"{"accessors":[{"type":"VEC3","min":[-0.3,0.0,-0.1],"max":[0.3,1.02,0.125]}]}"#;
|
||||
let a = bounds(kenney).expect("max-first must parse");
|
||||
let b = bounds(other).expect("min-first must parse");
|
||||
assert!((a[0] - 0.6).abs() < 1e-5 && (a[1] - 1.02).abs() < 1e-5);
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,130 +0,0 @@
|
|||
//! Per-pack theme curation — the cheap half of making 4700 models findable.
|
||||
//!
|
||||
//! Hand-curating every model does not survive a catalogue this size, so the
|
||||
//! work is split three ways:
|
||||
//!
|
||||
//! 1. **this file** — ~55 rows, one per pack, giving every model in that pack
|
||||
//! its setting and theme keywords for free. A model in `castle-kit` is
|
||||
//! findable as "medieval" without anyone writing a row for it.
|
||||
//! 2. **filename tokens** (`lib.rs`) — Kenney's names are systematic
|
||||
//! (`tree_pineDefaultA`, `boat-sail-a`), so splitting on separators and
|
||||
//! camelCase yields real nouns at zero curation cost.
|
||||
//! 3. **query-time synonyms** (`aliases.rs`) — one table that applies to the
|
||||
//! whole catalogue regardless of its size, which is why that is where
|
||||
//! curation effort pays best.
|
||||
//!
|
||||
//! Item-level rows in `aliases.rs` are then spent only on the few hundred
|
||||
//! things people actually ask for by name.
|
||||
|
||||
/// Theme keywords contributed to every model in a pack, plus the pack's
|
||||
/// display name. Keyed by directory name.
|
||||
pub struct PackTheme {
|
||||
pub pack: &'static str,
|
||||
pub name: &'static str,
|
||||
pub themes: &'static [&'static str],
|
||||
}
|
||||
|
||||
pub const PACK_THEMES: &[PackTheme] = &[
|
||||
// ---- starter kits (pinned from the KenneyNL GitHub repos) -----------
|
||||
PackTheme { pack: "arena", name: "Mini Arena", themes: &["arena", "battle", "roman", "medieval", "fight", "combat", "colosseum"] },
|
||||
PackTheme { pack: "city", name: "City Builder", themes: &["city", "town", "urban", "street", "modern", "builder"] },
|
||||
PackTheme { pack: "fps", name: "FPS Kit", themes: &["shooter", "fps", "sci-fi", "combat", "arena", "first person"] },
|
||||
PackTheme { pack: "platformer", name: "Platformer Starter", themes: &["platformer", "jumping", "level", "arcade", "side scroller"] },
|
||||
PackTheme { pack: "racing", name: "Racing Starter", themes: &["racing", "race", "speed", "track", "driving"] },
|
||||
// ---- full catalogue -------------------------------------------------
|
||||
PackTheme { pack: "3d-road-tiles", name: "3D Road Tiles", themes: &["road", "street", "tile", "modular", "driving", "city"] },
|
||||
PackTheme { pack: "blaster-kit", name: "Blaster Kit", themes: &["weapon", "blaster", "gun", "sci-fi", "shooter", "target", "shooting"] },
|
||||
PackTheme { pack: "blocky-characters", name: "Blocky Characters", themes: &["character", "person", "people", "blocky", "player", "avatar", "someone to play as"] },
|
||||
PackTheme { pack: "brick-kit", name: "Brick Kit", themes: &["brick", "toy", "plastic", "building block", "lego", "construction", "build"] },
|
||||
PackTheme { pack: "building-kit", name: "Building Kit", themes: &["building", "house", "structure", "modular", "architecture"] },
|
||||
PackTheme { pack: "car-kit", name: "Car Kit", themes: &["car", "vehicle", "driving", "transport", "road", "something to drive"] },
|
||||
PackTheme { pack: "castle-kit", name: "Castle Kit", themes: &["castle", "medieval", "fantasy", "fortress", "knight", "king", "kingdom"] },
|
||||
PackTheme { pack: "city-kit-commercial", name: "City Kit: Commercial", themes: &["city", "building", "skyscraper", "commercial", "shop", "urban", "downtown", "town"] },
|
||||
PackTheme { pack: "city-kit-industrial", name: "City Kit: Industrial", themes: &["city", "factory", "warehouse", "industrial", "urban", "works"] },
|
||||
PackTheme { pack: "city-kit-roads", name: "City Kit: Roads", themes: &["road", "street", "city", "town", "driving", "traffic", "something to drive on"] },
|
||||
PackTheme { pack: "city-kit-suburban", name: "City Kit: Suburban", themes: &["city", "suburban", "house", "home", "neighbourhood", "neighborhood", "town", "somewhere to live"] },
|
||||
PackTheme { pack: "coaster-kit", name: "Coaster Kit", themes: &["rollercoaster", "coaster", "theme park", "ride", "fairground", "attraction", "amusement"] },
|
||||
PackTheme { pack: "cube-pets", name: "Cube Pets", themes: &["animal", "pet", "cute", "creature", "cube", "blocky"] },
|
||||
PackTheme { pack: "factory-kit", name: "Factory Kit", themes: &["factory", "industrial", "conveyor", "warehouse", "machine", "production", "belt"] },
|
||||
PackTheme { pack: "fantasy-town-kit", name: "Fantasy Town Kit", themes: &["fantasy", "medieval", "town", "village", "building", "rpg"] },
|
||||
PackTheme { pack: "food-kit", name: "Food Kit", themes: &["food", "eat", "kitchen", "cooking", "meal", "snack", "something to eat"] },
|
||||
PackTheme { pack: "furniture-kit", name: "Furniture Kit", themes: &["furniture", "interior", "house", "home", "room", "indoor", "decor"] },
|
||||
PackTheme { pack: "graveyard-kit", name: "Graveyard Kit", themes: &["graveyard", "halloween", "spooky", "horror", "scary", "monster", "creepy", "ghost"] },
|
||||
PackTheme { pack: "hexagon-kit", name: "Hexagon Kit", themes: &["hexagon", "hex", "tile", "terrain", "strategy", "board", "modular"] },
|
||||
PackTheme { pack: "holiday-kit", name: "Holiday Kit", themes: &["christmas", "holiday", "winter", "snow", "festive", "xmas", "cabin"] },
|
||||
PackTheme { pack: "marble-kit", name: "Marble Kit", themes: &["marble", "track", "ball", "run", "puzzle", "rolling"] },
|
||||
PackTheme { pack: "mini-arcade", name: "Mini Arcade", themes: &["arcade", "game", "machine", "retro", "play", "cabinet"] },
|
||||
PackTheme { pack: "mini-arena", name: "Mini Arena", themes: &["arena", "battle", "roman", "fight", "combat", "colosseum"] },
|
||||
PackTheme { pack: "mini-characters", name: "Mini Characters", themes: &["character", "person", "people", "player", "avatar", "someone to play as"] },
|
||||
PackTheme { pack: "mini-dungeon", name: "Mini Dungeon", themes: &["dungeon", "rpg", "roguelike", "medieval", "cave", "adventure", "crawl"] },
|
||||
PackTheme { pack: "mini-forest", name: "Mini Forest", themes: &["forest", "nature", "woods", "camp", "outdoors", "archer", "tent"] },
|
||||
PackTheme { pack: "mini-market", name: "Mini Market", themes: &["market", "shop", "store", "supermarket", "shopping", "grocery"] },
|
||||
PackTheme { pack: "mini-skate", name: "Mini Skate", themes: &["skate", "skateboard", "park", "ramp", "street", "trick"] },
|
||||
PackTheme { pack: "minigolf-kit", name: "Minigolf Kit", themes: &["golf", "minigolf", "course", "putting", "level", "sport"] },
|
||||
PackTheme { pack: "modular-buildings", name: "Modular Buildings", themes: &["building", "modular", "house", "city", "town", "architecture"] },
|
||||
PackTheme { pack: "modular-cave-kit", name: "Modular Cave Kit", themes: &["cave", "underground", "modular", "tunnel", "rock", "dungeon"] },
|
||||
PackTheme { pack: "modular-dungeon-kit", name: "Modular Dungeon Kit", themes: &["dungeon", "modular", "underground", "rpg", "medieval", "tunnel"] },
|
||||
PackTheme { pack: "modular-space-kit", name: "Modular Space Kit", themes: &["space", "sci-fi", "station", "modular", "future", "spaceship", "corridor"] },
|
||||
PackTheme { pack: "nature-kit", name: "Nature Kit", themes: &["nature", "outdoors", "forest", "tree", "plant", "scenery", "landscape", "countryside"] },
|
||||
PackTheme { pack: "pirate-kit", name: "Pirate Kit", themes: &["pirate", "ship", "boat", "island", "sea", "ocean", "treasure", "sailing"] },
|
||||
PackTheme { pack: "platformer-kit", name: "Platformer Kit", themes: &["platformer", "level", "jumping", "arcade", "obstacle"] },
|
||||
PackTheme { pack: "prototype-kit", name: "Prototype Kit", themes: &["prototype", "placeholder", "blockout", "greybox", "test", "simple"] },
|
||||
PackTheme { pack: "racing-kit", name: "Racing Kit", themes: &["racing", "race", "track", "car", "speed", "driving", "circuit"] },
|
||||
PackTheme { pack: "retro-fantasy-kit", name: "Retro Fantasy Kit", themes: &["retro", "fantasy", "medieval", "town", "castle", "pixel", "old school"] },
|
||||
PackTheme { pack: "retro-urban-kit", name: "Retro Urban Kit", themes: &["retro", "urban", "city", "street", "old school", "town"] },
|
||||
PackTheme { pack: "space-kit", name: "Space Kit", themes: &["space", "sci-fi", "future", "planet", "rocket", "spaceship", "astronaut", "alien"] },
|
||||
PackTheme { pack: "space-station-kit", name: "Space Station Kit", themes: &["space", "station", "sci-fi", "interior", "future", "corridor", "spaceship"] },
|
||||
PackTheme { pack: "survival-kit", name: "Survival Kit", themes: &["survival", "nature", "camp", "outdoors", "wilderness", "craft"] },
|
||||
PackTheme { pack: "tower-defense-kit", name: "Tower Defense Kit", themes: &["tower defense", "defense", "castle", "medieval", "strategy", "tower"] },
|
||||
PackTheme { pack: "toy-car-kit", name: "Toy Car Kit", themes: &["toy", "car", "vehicle", "track", "play", "cute", "something to drive"] },
|
||||
PackTheme { pack: "train-kit", name: "Train Kit", themes: &["train", "railway", "railroad", "rail", "track", "locomotive", "tram"] },
|
||||
PackTheme { pack: "watercraft-kit", name: "Watercraft Kit", themes: &["boat", "ship", "water", "sea", "sailing", "vehicle", "ocean", "river"] },
|
||||
];
|
||||
|
||||
pub fn theme_of(pack: &str) -> Option<&'static PackTheme> {
|
||||
PACK_THEMES.iter().find(|p| p.pack == pack)
|
||||
}
|
||||
|
||||
/// Fallback category for a pack, so the 4400 uncurated models still land
|
||||
/// somewhere sensible in the category tree (and so the prompt summary counts
|
||||
/// mean something). Item-level curation overrides this when present.
|
||||
pub fn default_category(pack: &str) -> Option<&'static str> {
|
||||
Some(match pack {
|
||||
"car-kit" | "toy-car-kit" | "racing-kit" | "racing" => "vehicle/ground",
|
||||
"watercraft-kit" | "pirate-kit" => "vehicle/water",
|
||||
"train-kit" => "vehicle/rail",
|
||||
"blocky-characters" | "mini-characters" => "character/player",
|
||||
"cube-pets" => "character/animal",
|
||||
"nature-kit" | "mini-forest" | "survival-kit" => "nature/plant",
|
||||
"food-kit" => "prop/food",
|
||||
"furniture-kit" => "prop/furniture",
|
||||
"blaster-kit" => "prop/weapon",
|
||||
"castle-kit" | "fantasy-town-kit" | "retro-fantasy-kit" | "tower-defense-kit" => {
|
||||
"building/medieval"
|
||||
}
|
||||
"city-kit-commercial" | "city-kit-industrial" | "city-kit-suburban" | "modular-buildings"
|
||||
| "building-kit" | "retro-urban-kit" | "mini-market" => "building/urban",
|
||||
"city-kit-roads" | "3d-road-tiles" => "road/street",
|
||||
"space-kit" | "space-station-kit" | "modular-space-kit" => "sci-fi/space",
|
||||
"mini-dungeon" | "modular-dungeon-kit" | "modular-cave-kit" => "building/dungeon",
|
||||
"graveyard-kit" | "holiday-kit" => "prop/decoration",
|
||||
"platformer-kit" | "platformer" | "marble-kit" | "minigolf-kit" | "coaster-kit"
|
||||
| "mini-skate" | "hexagon-kit" | "brick-kit" | "prototype-kit" | "factory-kit"
|
||||
| "mini-arcade" | "mini-arena" | "arena" | "city" | "fps" => "terrain/platform",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Tokens that carry no meaning in a Kenney filename: variant markers and
|
||||
/// filler. Stripped so `tree_pineDefaultA` indexes as "tree pine", not as a
|
||||
/// thing called "default a".
|
||||
pub fn is_noise_token(t: &str) -> bool {
|
||||
if t.len() == 1 {
|
||||
return true; // trailing A/B/C variant letters
|
||||
}
|
||||
matches!(
|
||||
t,
|
||||
"default" | "type" | "variant" | "alt" | "version" | "new" | "old2" | "obj" | "mesh"
|
||||
) || (t.starts_with("type") && t[4..].chars().all(|c| c.is_ascii_digit()))
|
||||
|| t.chars().all(|c| c.is_ascii_digit())
|
||||
}
|
||||
|
|
@ -1,233 +0,0 @@
|
|||
//! Animation-state and modular-tile vocabulary.
|
||||
//!
|
||||
//! Two problems, one shape: an asset's *usefulness* is described by words that
|
||||
//! never appear in its filename. A character is useful because it can attack;
|
||||
//! a road tile is useful because it is a corner. Both facts live in structure
|
||||
//! the AI cannot see — clip names inside the GLB, and a kit's naming
|
||||
//! convention — so both are translated into ordinary search keywords here.
|
||||
|
||||
/// Query words → the clip-name fragments that satisfy them.
|
||||
///
|
||||
/// Clip vocabularies differ per pack (`Running_A` vs `Run` vs `Gallop`), so a
|
||||
/// game asking for "a character that can run" must match all of them. Matching
|
||||
/// is case-insensitive substring against each clip name.
|
||||
pub const STATE_WORDS: &[(&str, &[&str])] = &[
|
||||
("idle", &["idle", "stand"]),
|
||||
("walk", &["walk"]),
|
||||
("run", &["run", "sprint", "gallop", "dash"]),
|
||||
("sprint", &["sprint", "run"]),
|
||||
("jump", &["jump", "leap", "hop"]),
|
||||
("fall", &["fall", "falling", "air"]),
|
||||
("land", &["land", "jump_land", "landing"]),
|
||||
("attack", &["attack", "slash", "chop", "stab", "slice", "punch", "kick", "swing", "melee", "headbutt"]),
|
||||
("punch", &["punch", "unarmed"]),
|
||||
("kick", &["kick"]),
|
||||
("shoot", &["shoot", "ranged", "aiming", "throw", "spellcast"]),
|
||||
("block", &["block", "shield", "defend"]),
|
||||
("dodge", &["dodge", "roll", "evade"]),
|
||||
("hurt", &["hit", "hitreact", "recievehit", "receivehit", "damage", "hurt"]),
|
||||
("die", &["death", "die", "defeat", "dead"]),
|
||||
("dance", &["dance", "cheer", "celebrate"]),
|
||||
("sit", &["sit", "sitdown", "chair"]),
|
||||
("wave", &["wave", "greet", "interact"]),
|
||||
("swim", &["swim"]),
|
||||
("fly", &["fly", "flying", "hover"]),
|
||||
("eat", &["eat", "eating", "graze"]),
|
||||
("carry", &["carry", "pickup", "pick_up"]),
|
||||
("climb", &["climb"]),
|
||||
("sleep", &["sleep", "lie", "rest"]),
|
||||
// The skeleton packs' 19 extra clips were invisible to the vocabulary
|
||||
// above: an undead rising from the floor is exactly the thing a game asks
|
||||
// for by name, and "Skeletons_Awaken_Floor" matched nothing.
|
||||
("spawn", &["spawn", "awaken", "summon", "emerge"]),
|
||||
("resurrect", &["resurrect", "revive", "reanimate"]),
|
||||
("taunt", &["taunt", "provoke", "jeer"]),
|
||||
("use", &["use_item", "useitem", "interact", "activate"]),
|
||||
];
|
||||
|
||||
/// Modular-tile roles → filename fragments that identify them.
|
||||
///
|
||||
/// Grounded in the actual Kenney naming seen in the downloaded kits:
|
||||
/// `road-bend`, `corridor-intersection`, `building-corner-window`,
|
||||
/// `block-grass-slope`. Order matters — the more specific variants are tested
|
||||
/// before the general ones, so `corner-inner` never resolves to `corner`.
|
||||
pub const TILE_ROLES: &[(&str, &[&str])] = &[
|
||||
("corner-inner", &["corner-inner", "cornerinner", "inner-corner"]),
|
||||
("corner-outer", &["corner-outer", "cornerouter", "outer-corner"]),
|
||||
("junction", &["intersection", "junction", "crossroad", "crossing", "-cross", "split"]),
|
||||
("corner", &["corner", "bend", "curve", "turn", "-elbow"]),
|
||||
("end", &["-end", "end-", "cap", "deadend", "stub"]),
|
||||
("ramp", &["ramp", "slope", "incline", "-hill"]),
|
||||
("stairs", &["stair", "steps"]),
|
||||
("door", &["door", "gate", "entrance", "archway"]),
|
||||
("window", &["window"]),
|
||||
("bridge", &["bridge"]),
|
||||
("roof", &["roof", "ceiling"]),
|
||||
("wall", &["wall", "fence", "railing", "barrier"]),
|
||||
("pillar", &["pillar", "column", "post"]),
|
||||
("floor", &["floor", "ground", "tile", "platform", "block-"]),
|
||||
("straight", &["straight", "-line", "middle", "section", "corridor", "road", "track"]),
|
||||
];
|
||||
|
||||
/// Packs that are MODULAR KITS: their models are designed to snap together on
|
||||
/// a grid, so they are level vocabulary rather than standalone props.
|
||||
///
|
||||
/// Curated rather than inferred. A filename heuristic mislabels both ways —
|
||||
/// `castle-kit` is named "kit" but is mostly scenery, while `city` genuinely
|
||||
/// tiles — and 52 packs is few enough that being right matters more than
|
||||
/// being clever.
|
||||
pub const KIT_PACKS: &[&str] = &[
|
||||
"city-kit-roads",
|
||||
"city-kit-commercial",
|
||||
"city-kit-suburban",
|
||||
"city-kit-industrial",
|
||||
"modular-buildings",
|
||||
"modular-dungeon-kit",
|
||||
"modular-cave-kit",
|
||||
"modular-space-kit",
|
||||
"hexagon-kit",
|
||||
"brick-kit",
|
||||
"building-kit",
|
||||
"platformer-kit",
|
||||
"tower-defense-kit",
|
||||
"mini-dungeon",
|
||||
"mini-arena",
|
||||
"retro-urban-kit",
|
||||
"coaster-kit",
|
||||
"marble-kit",
|
||||
"minigolf-kit",
|
||||
"train-kit",
|
||||
"racing-kit",
|
||||
"city",
|
||||
"arena",
|
||||
];
|
||||
|
||||
pub fn is_kit(pack: &str) -> bool {
|
||||
KIT_PACKS.contains(&pack)
|
||||
}
|
||||
|
||||
/// Which roles may legitimately connect to which. Data, not prose, because a
|
||||
/// composition layer consumes it.
|
||||
///
|
||||
/// Deliberately coarse: Kenney filenames say what a piece IS, never which of
|
||||
/// its edges are open, so anything finer would be invented. A layout planner
|
||||
/// uses this to know a corridor may follow a corner; it must still decide
|
||||
/// rotation from its own grid.
|
||||
pub const ROLE_ADJACENCY: &[(&str, &[&str])] = &[
|
||||
("straight", &["straight", "corner", "junction", "end", "ramp", "door", "bridge"]),
|
||||
("corner", &["straight", "junction", "corner", "end"]),
|
||||
("junction", &["straight", "corner", "end", "junction"]),
|
||||
("end", &["straight", "corner", "junction"]),
|
||||
("ramp", &["straight", "floor", "stairs"]),
|
||||
("stairs", &["floor", "straight", "ramp"]),
|
||||
("floor", &["floor", "wall", "ramp", "stairs", "door"]),
|
||||
("wall", &["wall", "corner", "door", "window", "pillar"]),
|
||||
("door", &["wall", "straight", "floor"]),
|
||||
("window", &["wall"]),
|
||||
("roof", &["roof", "wall"]),
|
||||
("pillar", &["wall", "floor"]),
|
||||
("bridge", &["straight", "end"]),
|
||||
];
|
||||
|
||||
pub fn connects_to(role: &str) -> &'static [&'static str] {
|
||||
ROLE_ADJACENCY
|
||||
.iter()
|
||||
.find(|(r, _)| *r == role)
|
||||
.map(|(_, v)| *v)
|
||||
.unwrap_or(&[])
|
||||
}
|
||||
|
||||
/// Composition words a person uses when BUILDING rather than when naming a
|
||||
/// thing. These are query-side: they expand to role names above.
|
||||
pub const COMPOSITION_WORDS: &[(&str, &str)] = &[
|
||||
("road", "straight"),
|
||||
("track", "straight"),
|
||||
("path", "straight"),
|
||||
("straight bit", "straight"),
|
||||
("corner piece", "corner"),
|
||||
("bend", "corner"),
|
||||
("t junction", "junction"),
|
||||
("crossroads", "junction"),
|
||||
("wall section", "wall"),
|
||||
("dungeon floor", "floor"),
|
||||
("roof piece", "roof"),
|
||||
("dead end", "end"),
|
||||
("ramp", "ramp"),
|
||||
("doorway", "door"),
|
||||
("slope", "ramp"),
|
||||
("crossroad", "junction"),
|
||||
("intersection", "junction"),
|
||||
("floor tile", "floor"),
|
||||
("platform", "floor"),
|
||||
];
|
||||
|
||||
/// State keywords implied by a model's clip list, e.g. a model whose clips
|
||||
/// include `1H_Melee_Attack_Chop` becomes findable as "attack".
|
||||
pub fn states_from_clips(clips: &[String]) -> Vec<String> {
|
||||
let lower: Vec<String> = clips.iter().map(|c| c.to_ascii_lowercase()).collect();
|
||||
let mut out = Vec::new();
|
||||
for (word, frags) in STATE_WORDS {
|
||||
if lower
|
||||
.iter()
|
||||
.any(|c| frags.iter().any(|f| c.contains(&f.to_ascii_lowercase())))
|
||||
{
|
||||
out.push((*word).to_string());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The tile role a filename implies, if any. First match wins, and the more
|
||||
/// specific inner/outer corners are tested before plain "corner".
|
||||
pub fn role_of(stem: &str) -> Option<&'static str> {
|
||||
let s = stem.to_ascii_lowercase();
|
||||
for role in ["corner-inner", "corner-outer"] {
|
||||
if let Some((_, frags)) = TILE_ROLES.iter().find(|(r, _)| *r == role) {
|
||||
if frags.iter().any(|f| s.contains(f)) {
|
||||
return Some(role);
|
||||
}
|
||||
}
|
||||
}
|
||||
TILE_ROLES
|
||||
.iter()
|
||||
.find(|(r, frags)| {
|
||||
!r.starts_with("corner-") && frags.iter().any(|f| s.contains(f))
|
||||
})
|
||||
.map(|(r, _)| *r)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn clip_vocabularies_differ_per_pack_but_map_to_the_same_words() {
|
||||
// KayKit names it Running_A, Quaternius names it Run, an animal
|
||||
// gallops. "run" must find all three or the query is useless.
|
||||
let kaykit = vec!["Running_A".into(), "1H_Melee_Attack_Chop".into(), "Death_A".into()];
|
||||
let quaternius = vec!["Run".into(), "Punch".into(), "Death".into()];
|
||||
let animal = vec!["Gallop".into(), "Attack_Headbutt".into(), "Eating".into()];
|
||||
for set in [&kaykit, &quaternius, &animal] {
|
||||
let s = states_from_clips(set);
|
||||
assert!(s.contains(&"run".to_string()), "no run in {s:?}");
|
||||
assert!(s.contains(&"attack".to_string()), "no attack in {s:?}");
|
||||
}
|
||||
assert!(states_from_clips(&animal).contains(&"eat".to_string()));
|
||||
assert!(states_from_clips(&kaykit).contains(&"die".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_model_with_no_clips_claims_no_states() {
|
||||
assert!(states_from_clips(&[]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tile_roles_come_from_kenney_naming() {
|
||||
assert_eq!(role_of("road-corner"), Some("corner"));
|
||||
assert_eq!(role_of("road-straight"), Some("straight"));
|
||||
assert_eq!(role_of("road-intersection"), Some("junction"));
|
||||
assert_eq!(role_of("wall-doorway"), Some("door"));
|
||||
assert_eq!(role_of("stairs-corner-inner"), Some("corner-inner"));
|
||||
assert_eq!(role_of("banner"), None);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,411 +0,0 @@
|
|||
//! Variety and palette selection — turning a search engine into a composition
|
||||
//! tool.
|
||||
//!
|
||||
//! The ranking was never the problem. Asking for "suburban house building"
|
||||
//! already returns 21 distinct houses at equal score, and "pine tree" returns
|
||||
//! six distinct pines. The problem was that [`crate::AssetIndex::find`] hands
|
||||
//! back a ranked list, the caller takes hit #1, and places it N times — so a
|
||||
//! village came out as five identical houses on five identical lots. With 4400
|
||||
//! models installed, a scene used about six of them.
|
||||
//!
|
||||
//! So the fix is an API that *affords* variety rather than better scoring:
|
||||
//! ask for N models and get N DIFFERENT ones, or ask for a palette and get a
|
||||
//! set that visually belongs together.
|
||||
|
||||
use crate::{AssetEntry, AssetIndex, Filters, Hit};
|
||||
|
||||
/// How different the returned models should be from each other.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
|
||||
pub enum Spread {
|
||||
/// Round-robin across variant families, then within them. Maximum spread
|
||||
/// of shapes first, filled out with variants of those shapes. Never
|
||||
/// repeats a model.
|
||||
///
|
||||
/// The right default, and it handles both real cases with one rule: a
|
||||
/// village asking for 5 houses gets `building-type-a..e` (one family, five
|
||||
/// members), while a forest asking for 5 trees gets pine, oak, palm and
|
||||
/// two more species before it takes a second pine.
|
||||
#[default]
|
||||
Mixed,
|
||||
/// At most one model per family — maximum shape diversity, fewer results.
|
||||
/// For when repetition of a silhouette is the thing to avoid.
|
||||
Kinds,
|
||||
/// All from the best-matching family: `building-type-a`, `-b`, `-c`. A
|
||||
/// coherent row of the same kind of thing.
|
||||
Variants,
|
||||
}
|
||||
|
||||
/// Parameters for a variety query.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct VarietyParams {
|
||||
pub count: usize,
|
||||
pub spread: Spread,
|
||||
/// Seeds the choice WITHIN families, so a re-run of the same game places
|
||||
/// the same models and two different seeds place different ones. Never the
|
||||
/// world rng: selection has to be reproducible from (query, seed) alone
|
||||
/// for multiplayer to replicate a scene without shipping model lists.
|
||||
pub seed: u64,
|
||||
pub filters: Filters,
|
||||
}
|
||||
|
||||
impl Default for VarietyParams {
|
||||
fn default() -> Self {
|
||||
VarietyParams { count: 5, spread: Spread::Mixed, seed: 0, filters: Filters::default() }
|
||||
}
|
||||
}
|
||||
|
||||
impl VarietyParams {
|
||||
pub fn new(count: usize) -> Self {
|
||||
VarietyParams { count, ..Default::default() }
|
||||
}
|
||||
pub fn spread(mut self, s: Spread) -> Self {
|
||||
self.spread = s;
|
||||
self
|
||||
}
|
||||
pub fn seed(mut self, s: u64) -> Self {
|
||||
self.seed = s;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A coherent set of models drawn from ONE pack, grouped by what each is for.
|
||||
///
|
||||
/// This is what a composer actually wants: not "the best house" but "houses,
|
||||
/// trees, fences and street furniture that look like they come from the same
|
||||
/// game". Drawing from one pack is what guarantees that — Kenney authors each
|
||||
/// pack as a matched set.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Palette {
|
||||
pub pack: String,
|
||||
pub name: String,
|
||||
/// Group name (the family, e.g. "building", "fence") → model ids, most
|
||||
/// relevant first.
|
||||
pub groups: Vec<(String, Vec<String>)>,
|
||||
}
|
||||
|
||||
impl Palette {
|
||||
/// Ids in a named group, empty when the pack has nothing of that sort.
|
||||
pub fn group(&self, name: &str) -> &[String] {
|
||||
self.groups
|
||||
.iter()
|
||||
.find(|(g, _)| g == name)
|
||||
.map(|(_, v)| v.as_slice())
|
||||
.unwrap_or(&[])
|
||||
}
|
||||
pub fn total(&self) -> usize {
|
||||
self.groups.iter().map(|(_, v)| v.len()).sum()
|
||||
}
|
||||
}
|
||||
|
||||
/// The variant family a model belongs to: its name with variant markers
|
||||
/// stripped.
|
||||
///
|
||||
/// Kenney names variants systematically, but the SAME suffix means different
|
||||
/// things in different packs — `building-type-a`..`-u` are twenty-one
|
||||
/// genuinely different house designs, while `tree_blocks` / `tree_blocks_dark`
|
||||
/// is one tree in two colours. A name cannot tell those apart, and guessing
|
||||
/// wrong in either direction hurts: collapse too much and a village has one
|
||||
/// house, collapse too little and a "forest" is five recolours of one trunk.
|
||||
///
|
||||
/// So the family is deliberately coarse — it drops only markers that are
|
||||
/// certainly not descriptive (single letters, digits, "default"/"type") via
|
||||
/// the same noise rule the indexer uses — and the SELECTION strategy does the
|
||||
/// rest. `Mixed` draws across families before drawing within one, so it is
|
||||
/// correct whichever way a pack happens to be named.
|
||||
pub fn family_of(entry: &AssetEntry) -> String {
|
||||
let stem = entry.id.rsplit('/').next().unwrap_or(&entry.id);
|
||||
let tokens: Vec<String> = crate::split_ident(stem)
|
||||
.into_iter()
|
||||
.filter(|t| !crate::packs::is_noise_token(t))
|
||||
.filter(|t| !is_reskin_token(t))
|
||||
.collect();
|
||||
if tokens.is_empty() {
|
||||
stem.to_string()
|
||||
} else {
|
||||
tokens.join(" ")
|
||||
}
|
||||
}
|
||||
|
||||
/// Tokens that mark a RE-SKIN rather than a different shape: colours, seasons
|
||||
/// and lighting words. `tree_blocks`, `tree_blocks_dark` and `tree_blocks_fall`
|
||||
/// are one tree in three palettes, and treating them as three "kinds" made a
|
||||
/// request for six kinds of tree return the same silhouette six times.
|
||||
///
|
||||
/// Colour is deliberately included even though "a red car" is a real request —
|
||||
/// dropping it only affects GROUPING, and `Mixed` still returns the red and
|
||||
/// blue cars as distinct members of one family. What it prevents is a
|
||||
/// "variety" of six identical cars in six colours being mistaken for variety.
|
||||
fn is_reskin_token(t: &str) -> bool {
|
||||
matches!(
|
||||
t,
|
||||
"dark" | "light" | "fall" | "autumn" | "winter" | "summer" | "spring" | "snow"
|
||||
| "red" | "blue" | "green" | "yellow" | "orange" | "purple" | "pink" | "white"
|
||||
| "black" | "grey" | "gray" | "brown" | "teal" | "beige" | "tan"
|
||||
)
|
||||
}
|
||||
|
||||
/// A coarse bucket for palette grouping: the first meaningful token, or the
|
||||
/// tile role when the pack is a modular kit.
|
||||
///
|
||||
/// [`family_of`] is the wrong key here — it is deliberately specific, and on a
|
||||
/// 300-model pack it produced 167 groups of one id each, which is a listing
|
||||
/// rather than a palette. A composer wants "buildings", "trees", "fences",
|
||||
/// not "balcony wall fence".
|
||||
fn group_of(entry: &AssetEntry) -> String {
|
||||
if let Some(role) = entry.role {
|
||||
return role.to_string();
|
||||
}
|
||||
crate::split_ident(entry.id.rsplit('/').next().unwrap_or(&entry.id))
|
||||
.into_iter()
|
||||
.find(|t| !crate::packs::is_noise_token(t) && !is_reskin_token(t))
|
||||
.unwrap_or_else(|| "misc".to_string())
|
||||
}
|
||||
|
||||
/// Deterministic per-(seed, id) key. FNV-1a so the same seed always yields the
|
||||
/// same order and a different seed yields a genuinely different one, without
|
||||
/// carrying any RNG state around.
|
||||
fn shuffle_key(seed: u64, id: &str) -> u64 {
|
||||
let mut h: u64 = 0xcbf2_9ce4_8422_2325 ^ seed.wrapping_mul(0x100_0000_01b3);
|
||||
for b in id.as_bytes() {
|
||||
h ^= *b as u64;
|
||||
h = h.wrapping_mul(0x100_0000_01b3);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// The pack the query most belongs to.
|
||||
///
|
||||
/// Only hits at or near the TOP score are considered, and they are summed
|
||||
/// within that band. Summing across all 60 hits instead — mass rather than
|
||||
/// quality — let a pack with many weak brushes outrank the pack that actually
|
||||
/// answered the query: "street light post tall" ranks three `racing-kit`
|
||||
/// lightPosts at 18, but `nature-kit` has enough incidental "tall" matches to
|
||||
/// out-sum them, so the preferred pack became nature-kit and asking for a
|
||||
/// lamp returned a CACTUS. `find` had the right answer all along; only this
|
||||
/// preference threw it away.
|
||||
///
|
||||
/// The band keeps the tie-breaking that summing was for: when two packs both
|
||||
/// hold top-scoring hits, the one with more of them still wins.
|
||||
fn dominant_pack(hits: &[Hit<'_>]) -> Option<String> {
|
||||
let top = hits.first()?.score;
|
||||
// Generous enough that a pack whose best hit is a point or two behind can
|
||||
// still win on depth, tight enough that an unrelated pack cannot buy the
|
||||
// preference with volume.
|
||||
let floor = top.saturating_sub(top / 4);
|
||||
let mut packs: Vec<(&str, u32)> = Vec::new();
|
||||
for h in hits.iter().take(60).filter(|h| h.score >= floor) {
|
||||
let p = h.entry.pack.as_str();
|
||||
match packs.iter_mut().find(|(name, _)| *name == p) {
|
||||
Some((_, s)) => *s += h.score,
|
||||
None => packs.push((p, h.score)),
|
||||
}
|
||||
}
|
||||
packs.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
|
||||
packs.first().map(|(p, _)| p.to_string())
|
||||
}
|
||||
|
||||
/// Group ranked hits into families, preserving each family's best score and
|
||||
/// the ranked order of its members.
|
||||
///
|
||||
/// Families from the query's dominant pack come first. Without that, asking
|
||||
/// for five houses returned one house from each of five packs — a suburban
|
||||
/// house, a hex-tile house, a modular house and a driveway — which is the
|
||||
/// junk-drawer failure that variety was supposed to fix, arrived at from the
|
||||
/// opposite direction. Crossing packs is still allowed once the best pack runs
|
||||
/// out, because a wider net beats returning nothing.
|
||||
fn group_families<'a>(
|
||||
hits: &[Hit<'a>],
|
||||
seed: u64,
|
||||
prefer_pack: Option<&str>,
|
||||
) -> Vec<(String, u32, Vec<&'a AssetEntry>)> {
|
||||
let mut groups: Vec<(String, u32, bool, Vec<&'a AssetEntry>)> = Vec::new();
|
||||
for h in hits {
|
||||
let fam = family_of(h.entry);
|
||||
let native = prefer_pack.is_some_and(|p| p == h.entry.pack);
|
||||
match groups.iter_mut().find(|(f, _, _, _)| *f == fam) {
|
||||
Some((_, best, is_native, members)) => {
|
||||
*best = (*best).max(h.score);
|
||||
*is_native |= native;
|
||||
members.push(h.entry);
|
||||
}
|
||||
None => groups.push((fam, h.score, native, vec![h.entry])),
|
||||
}
|
||||
}
|
||||
// Dominant pack first, then relevance; members shuffled by seed so a
|
||||
// different seed picks different members of an equally-good family.
|
||||
groups.sort_by(|a, b| {
|
||||
b.2.cmp(&a.2)
|
||||
.then_with(|| b.1.cmp(&a.1))
|
||||
.then_with(|| a.0.cmp(&b.0))
|
||||
});
|
||||
let mut out: Vec<(String, u32, Vec<&'a AssetEntry>)> = Vec::new();
|
||||
for (fam, score, _, mut members) in groups {
|
||||
members.sort_by_key(|e| (shuffle_key(seed, &e.id), e.id.clone()));
|
||||
out.push((fam, score, members));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl AssetIndex {
|
||||
/// Find N *distinct* models for one query.
|
||||
///
|
||||
/// This is the call a composer wants and [`AssetIndex::find`] is not: it
|
||||
/// never returns the same model twice, and by default it spreads across
|
||||
/// variant families before repeating a shape.
|
||||
pub fn find_many(&self, query: &str, params: &VarietyParams) -> Vec<&AssetEntry> {
|
||||
if params.count == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
// Pull a generous candidate pool: enough to have several families to
|
||||
// spread across, and cheap because the inverted index already narrowed
|
||||
// it. 12x is empirical — a village asking for 5 houses wants to see
|
||||
// all 21 building-types, not the first 5.
|
||||
let pool = (params.count * 12).clamp(24, 400);
|
||||
let hits: Vec<Hit> = self
|
||||
.find_filtered(query, ¶ms.filters)
|
||||
.into_iter()
|
||||
.take(pool)
|
||||
.collect();
|
||||
if hits.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let prefer = dominant_pack(&hits);
|
||||
let mut groups = group_families(&hits, params.seed, prefer.as_deref());
|
||||
|
||||
// Variety must not drift off-topic. Round-robin is right when the
|
||||
// families are kinds of the SAME thing (pine, oak, palm) and wrong
|
||||
// when they are different things sharing a pack theme: asking for five
|
||||
// houses returned one house then two driveways and two fences, because
|
||||
// `city-kit-suburban` themes all of them "house".
|
||||
//
|
||||
// Scoring cannot separate those — an exact one-word hit (`tree`)
|
||||
// outscores a compound sibling (`tree_blocks`) purely for being
|
||||
// shorter, so a relevance band cuts real variety while keeping the
|
||||
// drift. What actually distinguishes them is whether the family IS the
|
||||
// thing asked for: "tree", "tree blocks" and "tree cone" all name a
|
||||
// tree; "driveway" and "fence" do not name a house.
|
||||
//
|
||||
// Applied only when it leaves something, because a functional query
|
||||
// ("somewhere to hide") names no noun the filenames share, and there
|
||||
// the ranking's own judgement is all we have.
|
||||
let named: Vec<(String, u32, Vec<&AssetEntry>)> = {
|
||||
let terms = crate::tokenize(query);
|
||||
groups
|
||||
.iter()
|
||||
.filter(|(fam, _, _)| {
|
||||
terms.iter().any(|t| {
|
||||
fam.split_whitespace()
|
||||
.any(|w| w == t || crate::stem(t).is_some_and(|s| w == s))
|
||||
})
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
};
|
||||
if !named.is_empty() {
|
||||
groups = named;
|
||||
}
|
||||
|
||||
let mut out: Vec<&AssetEntry> = Vec::new();
|
||||
match params.spread {
|
||||
Spread::Kinds => {
|
||||
for (_, _, members) in &groups {
|
||||
if out.len() >= params.count {
|
||||
break;
|
||||
}
|
||||
if let Some(first) = members.first() {
|
||||
out.push(first);
|
||||
}
|
||||
}
|
||||
}
|
||||
Spread::Variants => {
|
||||
if let Some((_, _, members)) = groups.first() {
|
||||
out.extend(members.iter().take(params.count));
|
||||
}
|
||||
}
|
||||
Spread::Mixed => {
|
||||
// Round-robin: one from each family, then a second from each,
|
||||
// and so on — the widest spread the pool allows before any
|
||||
// shape repeats.
|
||||
//
|
||||
// Run it over the dominant pack FIRST and only spill into
|
||||
// other packs if that pack cannot fill the count. Breadth
|
||||
// across packs is not free: five houses drawn from five packs
|
||||
// are five art styles in one street. `city-kit-suburban` alone
|
||||
// holds 21 house designs, so a village never needs to leave it.
|
||||
let native: Vec<_> = groups
|
||||
.iter()
|
||||
.filter(|(_, _, m)| m.first().is_some_and(|e| Some(&e.pack) == prefer.as_ref()))
|
||||
.collect();
|
||||
let foreign: Vec<_> = groups
|
||||
.iter()
|
||||
.filter(|(_, _, m)| m.first().is_none_or(|e| Some(&e.pack) != prefer.as_ref()))
|
||||
.collect();
|
||||
for stage in [native, foreign] {
|
||||
if out.len() >= params.count {
|
||||
break;
|
||||
}
|
||||
let deepest = stage.iter().map(|(_, _, m)| m.len()).max().unwrap_or(0);
|
||||
'stage: for depth in 0..deepest {
|
||||
for (_, _, members) in &stage {
|
||||
if let Some(e) = members.get(depth) {
|
||||
out.push(e);
|
||||
if out.len() >= params.count {
|
||||
break 'stage;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A coherent set of models drawn from ONE pack, for building a scene that
|
||||
/// looks authored rather than assembled from a junk drawer.
|
||||
///
|
||||
/// The pack is chosen by which one the query's best hits actually come
|
||||
/// from, so "a village" lands on a village pack and takes its houses,
|
||||
/// fences and props together.
|
||||
pub fn palette(&self, query: &str, seed: u64) -> Option<Palette> {
|
||||
// Which pack owns this query? Score by summed relevance of its hits,
|
||||
// not by count, so a pack with two perfect matches beats one with ten
|
||||
// weak brushes.
|
||||
let hits = self.find(query);
|
||||
if hits.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut packs: Vec<(&str, u32)> = Vec::new();
|
||||
for h in hits.iter().take(60) {
|
||||
let p = h.entry.pack.as_str();
|
||||
match packs.iter_mut().find(|(name, _)| *name == p) {
|
||||
Some((_, s)) => *s += h.score,
|
||||
None => packs.push((p, h.score)),
|
||||
}
|
||||
}
|
||||
packs.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
|
||||
let pack = packs.first()?.0.to_string();
|
||||
|
||||
// Everything that pack ships, bucketed by family — that IS the palette.
|
||||
let mut groups: Vec<(String, Vec<String>)> = Vec::new();
|
||||
let mut members: Vec<&AssetEntry> = self
|
||||
.entries()
|
||||
.iter()
|
||||
.filter(|e| e.pack == pack && e.kind == crate::AssetKind::Model)
|
||||
.collect();
|
||||
members.sort_by_key(|e| (shuffle_key(seed, &e.id), e.id.clone()));
|
||||
for e in members {
|
||||
let g = group_of(e);
|
||||
match groups.iter_mut().find(|(name, _)| *name == g) {
|
||||
Some((_, ids)) => ids.push(e.id.clone()),
|
||||
None => groups.push((g, vec![e.id.clone()])),
|
||||
}
|
||||
}
|
||||
// Biggest groups first: they are what the pack is mostly about.
|
||||
groups.sort_by(|a, b| b.1.len().cmp(&a.1.len()).then_with(|| a.0.cmp(&b.0)));
|
||||
let name = crate::packs::theme_of(&pack)
|
||||
.map(|t| t.name.to_string())
|
||||
.unwrap_or_else(|| pack.replace('-', " "));
|
||||
Some(Palette { pack, name, groups })
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue