Compare commits
835 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c879f99d2 | ||
|
|
b82e112cbc | ||
|
|
2fa84e0477 | ||
|
|
6d003287e4 | ||
|
|
e963c83c4d | ||
|
|
0cd7537ce6 | ||
|
|
ed67f371cb | ||
|
|
246e56187c | ||
|
|
214eaad1f5 | ||
|
|
9e90b18f1f | ||
|
|
d4910a4147 | ||
|
|
fe096436da | ||
|
|
7b5e9b564d | ||
|
|
98c77ed798 | ||
|
|
68eb16f7e7 | ||
|
|
559730eae4 | ||
|
|
aa4b3f3794 | ||
|
|
0e752972f0 | ||
|
|
38d7b55032 | ||
|
|
913a8144f1 | ||
|
|
4772d63719 | ||
|
|
626d146375 | ||
|
|
3c7b8948ae | ||
|
|
d1f0ac4956 | ||
|
|
b891d6f38c | ||
|
|
20b19c53ff | ||
|
|
c900ec80db | ||
|
|
63da1ff38c | ||
|
|
3f6278f281 | ||
|
|
4527eb972e | ||
|
|
d54ba0a191 | ||
|
|
45a8e0055d | ||
|
|
f6ee09b6c7 | ||
|
|
e33c35bbe0 | ||
|
|
1481047b94 | ||
|
|
037d9d86fb | ||
|
|
9763ceaa1c | ||
|
|
5a17b9022d | ||
|
|
0efbefb999 | ||
|
|
55c4c574ec | ||
|
|
df0ab43e70 | ||
|
|
097a4b04d8 | ||
|
|
7e210de5e7 | ||
|
|
991704ee19 | ||
|
|
c5f2728ad0 | ||
|
|
6c3cf8b4b4 | ||
|
|
1cb92a454d | ||
|
|
430fa5c354 | ||
|
|
5da2cf14db | ||
|
|
e7903d5895 | ||
|
|
6a92641d57 | ||
|
|
90a8f73b93 | ||
|
|
13918a579f | ||
|
|
20e2b64390 | ||
|
|
0a5e5c66c2 | ||
|
|
5f80e880c2 | ||
|
|
3a9e7c1cd8 | ||
|
|
abbda898c7 | ||
|
|
1170c5970e | ||
|
|
39f0f20dc6 | ||
|
|
04d6b3dddb | ||
|
|
c45ebe60e1 | ||
|
|
b360fa3998 | ||
|
|
29fbb56c88 | ||
|
|
a31c8b8a86 | ||
|
|
3dfa9bd361 | ||
|
|
43d5d65366 | ||
|
|
0098660d7b | ||
|
|
cd2d28dedd | ||
|
|
b6f38f835d | ||
|
|
b44da28020 | ||
|
|
06ceb827f3 | ||
|
|
17f4e77054 | ||
|
|
939c71aab7 | ||
|
|
af57e8a33e | ||
|
|
e3d8a0f669 | ||
|
|
3cf81b53ad | ||
|
|
5e2dfdd926 | ||
|
|
c624964b76 | ||
|
|
e9650db4d8 | ||
|
|
5aeeb8a974 | ||
|
|
e11a5956bd | ||
|
|
618f292ec9 | ||
|
|
849010fc75 | ||
|
|
2439048f59 | ||
|
|
ed5f76221b | ||
|
|
eaa3bf6965 | ||
|
|
ba11e48c7d | ||
|
|
7e0c1b0337 | ||
|
|
48482b2a65 | ||
|
|
e1e99e73e7 | ||
|
|
3802b10956 | ||
|
|
bef5550a95 | ||
|
|
933d20d559 | ||
|
|
cddc274701 | ||
|
|
fe4c111fa9 | ||
|
|
a06f53af29 | ||
|
|
52bc2bdf8e | ||
|
|
f7b8d3c7bc | ||
|
|
01dd4eb1a8 | ||
|
|
a3b283b51e | ||
|
|
1df89884ed | ||
|
|
2b35dd5bce | ||
|
|
ebe848ec99 | ||
|
|
9bf418258f | ||
|
|
65664bba7b | ||
|
|
d7e1b969e8 | ||
|
|
fd590f8353 | ||
|
|
f417162f6f | ||
|
|
25cb6f2f05 | ||
|
|
f422b4b395 | ||
|
|
f896e1bdfd | ||
|
|
80edfa1ae9 | ||
|
|
64ca8f008c | ||
|
|
2ee86b50d2 | ||
|
|
f140804713 | ||
|
|
a1f357f317 | ||
|
|
7ba261c7d9 | ||
|
|
7a9e3a6eb1 | ||
|
|
b0650e7d6e | ||
|
|
6d6f4f7c47 | ||
|
|
816b249f67 | ||
|
|
50ebd95509 | ||
|
|
15a8be1c15 | ||
|
|
a088e50b3b | ||
|
|
1e02c6aa6f | ||
|
|
2c95c7eacd | ||
|
|
b4ebdf069d | ||
|
|
45dbeacc4a | ||
|
|
6d580af817 | ||
|
|
bcbb536bdc | ||
|
|
c8de4edfff | ||
|
|
91a5ab6786 | ||
|
|
4ac01a403f | ||
|
|
9309e9c838 | ||
|
|
77afca5799 | ||
|
|
4ebe5c1149 | ||
|
|
34e430e8a1 | ||
|
|
47313521ed | ||
|
|
6f27332cce | ||
|
|
073aba65ab | ||
|
|
71abc13330 | ||
|
|
8feb732a8f | ||
|
|
a87db67d84 | ||
|
|
d675cddafb | ||
|
|
2df4dc5829 | ||
|
|
42d543b235 | ||
|
|
94a7ec5a2c | ||
|
|
590ecb36ae | ||
|
|
96bc4e0a3c | ||
|
|
51bc3dfe83 | ||
|
|
3e7390ffc0 | ||
|
|
ca1a48c0fe | ||
|
|
3752902663 | ||
|
|
753a375e89 | ||
|
|
7c0335a24d | ||
|
|
6de69604d9 | ||
|
|
6bce30545d | ||
|
|
04b284a267 | ||
|
|
9e1adc768b | ||
|
|
952ec10cb5 | ||
|
|
41e5b5f9b8 | ||
|
|
8b03d09c78 | ||
|
|
ae8b42e0c9 | ||
|
|
a6aaaa7266 | ||
|
|
feaf09e4e2 | ||
|
|
9e45710189 | ||
|
|
53ed2f0948 | ||
|
|
7ed3d38489 | ||
|
|
db02c19a0d | ||
|
|
e8cb95a60b | ||
|
|
257e9db64b | ||
|
|
3c00e0ba36 | ||
|
|
c9a2897bc2 | ||
|
|
fc1f85dac9 | ||
|
|
9369b2b167 | ||
|
|
ca42dbcf59 | ||
|
|
08c86c6129 | ||
|
|
b375f431af | ||
|
|
af66a53b2b | ||
|
|
dfc8798ff6 | ||
|
|
172a8e8a6b | ||
|
|
eebe8b9581 | ||
|
|
8efa3a572a | ||
|
|
e45a580d1a | ||
|
|
2b488b2d89 | ||
|
|
642c977df3 | ||
|
|
b7e352a4a3 | ||
|
|
87d7952960 | ||
|
|
99dec75731 | ||
|
|
9883dcf4ef | ||
|
|
24a55d7c88 | ||
|
|
49b691adde | ||
|
|
7c498f195e | ||
|
|
f7ee18a28c | ||
|
|
eff62a9765 | ||
|
|
7f90ee5f7d | ||
|
|
81b305b2f6 | ||
|
|
58cc6ff61e | ||
|
|
c1bf88d3dd | ||
|
|
f48cef5250 | ||
|
|
f2366be039 | ||
|
|
4077bd0de3 | ||
|
|
fd3e657387 | ||
|
|
167360f596 | ||
|
|
e3a128df60 | ||
|
|
ea9cc5ef07 | ||
|
|
a7553457ad | ||
|
|
9e090f967d | ||
|
|
29fb8791e5 | ||
|
|
2ab983ec0a | ||
|
|
4399c9c75a | ||
|
|
ba7e715222 | ||
|
|
854ad03523 | ||
|
|
d251955684 | ||
|
|
5384561413 | ||
|
|
fa06eb8d7a | ||
|
|
3b257df8d3 | ||
|
|
0b957cfeb1 | ||
|
|
3020e3b038 | ||
|
|
56611958f7 | ||
|
|
28a26cdc14 | ||
|
|
9c5b241cae | ||
|
|
58deed916c | ||
|
|
a0f282fb8e | ||
|
|
fa85d06271 | ||
|
|
f1681d18c2 | ||
|
|
14d9c8ade9 | ||
|
|
63609ba77e | ||
|
|
2dcd73d44a | ||
|
|
dd10236b22 | ||
|
|
028fc016b0 | ||
|
|
48806a3dfd | ||
|
|
e8f82d885f | ||
|
|
1e5160b40d | ||
|
|
2cf6afa590 | ||
|
|
cf2f399371 | ||
|
|
a36862e65b | ||
|
|
c6c904ca68 | ||
|
|
db34ca72f9 | ||
|
|
59d26e6a00 | ||
|
|
662787c287 | ||
|
|
e69ac492c7 | ||
|
|
e5fd2b67da | ||
|
|
0be59c21eb | ||
|
|
ada36e8d61 | ||
|
|
bf8b55a9c9 | ||
|
|
f8e4b5e707 | ||
|
|
4a5f0e36c6 | ||
|
|
ed7c7461b6 | ||
|
|
fbe10e90a2 | ||
|
|
26c66d6857 | ||
|
|
8533eac5db | ||
|
|
55a3daa649 | ||
|
|
1f2a58d21c | ||
|
|
dac868edbf | ||
|
|
4c9afe5a9f | ||
|
|
be275d380d | ||
|
|
a750a569ab | ||
|
|
fae19ab37b | ||
|
|
b7e05f2eb9 | ||
|
|
459e71e632 | ||
|
|
821bc4201f | ||
|
|
4cb2afcc74 | ||
|
|
5443514583 | ||
|
|
e3443b73d8 | ||
|
|
751b6b92c3 | ||
|
|
7dbaeec606 | ||
|
|
e4ce9d0bcc | ||
|
|
6971eb4d54 | ||
|
|
ee718323cd | ||
|
|
094e94a69c | ||
|
|
7c35782079 | ||
|
|
4037cdf86b | ||
|
|
406243d490 | ||
|
|
d5b9a2a307 | ||
|
|
5fcce3f453 | ||
|
|
0119f45d54 | ||
|
|
3359c21387 | ||
|
|
a294338080 | ||
|
|
0d59ffef85 | ||
|
|
20e67b491e | ||
|
|
fbd256c876 | ||
|
|
3bc6aa9c7d | ||
|
|
0ca0467192 | ||
|
|
f1ba1815f7 | ||
|
|
e8a6ae4a27 | ||
|
|
b74ce300e5 | ||
|
|
ac008e9564 | ||
|
|
4ee724030c | ||
|
|
e30fd1e5fd | ||
|
|
c463b83ce1 | ||
|
|
c7af08b694 | ||
|
|
3946880156 | ||
|
|
0642915c16 | ||
|
|
bdbab78840 | ||
|
|
3ea7a045da | ||
|
|
e866710af6 | ||
|
|
5551e546de | ||
|
|
62a3ed99aa | ||
|
|
b7cedc2426 | ||
|
|
e0004fb840 | ||
|
|
3d21277593 | ||
|
|
4943759cd3 | ||
|
|
946a889159 | ||
|
|
e60423e8c0 | ||
|
|
64e51d6a62 | ||
|
|
a73c34e576 | ||
|
|
8cbba99c0c | ||
|
|
9e2575cae4 | ||
|
|
1bd165ad35 | ||
|
|
3c6bff77d6 | ||
|
|
dfb31207ba | ||
|
|
4097aa6e84 | ||
|
|
dddde89b03 | ||
|
|
456ebc334b | ||
|
|
86404dbc5b | ||
|
|
7d36d09715 | ||
|
|
11747f0d6e | ||
|
|
931f3c19c7 | ||
|
|
d6e43a70ca | ||
|
|
e699172b85 | ||
|
|
42560626bf | ||
|
|
20dad5c76a | ||
|
|
7bcb1ac018 | ||
|
|
dece34f365 | ||
|
|
f64302f324 | ||
|
|
c27af789cb | ||
|
|
1f6c86c146 | ||
|
|
d874e4d956 | ||
|
|
5cc5370817 | ||
|
|
f79a8cc259 | ||
|
|
1040646393 | ||
|
|
76c34007a2 | ||
|
|
7c306e2234 | ||
|
|
19354bf5cf | ||
|
|
bd2364cbff | ||
|
|
e63544ec55 | ||
|
|
477cf804af | ||
|
|
4f0ff2b346 | ||
|
|
0100f1e12c | ||
|
|
e134b4cbd9 | ||
|
|
ba5d6c966c | ||
|
|
8ffc6e1127 | ||
|
|
4b63b4f65f | ||
|
|
81fd712adc | ||
|
|
b97f99cbaf | ||
|
|
bddde32325 | ||
|
|
54d38527f2 | ||
|
|
9b76c6b9ce | ||
|
|
c7012f2565 | ||
|
|
da18684cbb | ||
|
|
d5fd7b546d | ||
|
|
d8ac50efa9 | ||
|
|
51acfc4353 | ||
|
|
bf51cc2e0b | ||
|
|
b87ce730f2 | ||
|
|
4e83d7a3fd | ||
|
|
5b3cc62c8c | ||
|
|
acfad66974 | ||
|
|
884d6d9164 | ||
|
|
0b6ea68a7b | ||
|
|
0c774d343e | ||
|
|
69a344595c | ||
|
|
54823f62bf | ||
|
|
134270e3e3 | ||
|
|
43168f75cd | ||
|
|
75d7691925 | ||
|
|
652e7e8108 | ||
|
|
d21bfd4601 | ||
|
|
7cb2d949db | ||
|
|
ff33c42cc2 | ||
|
|
1e38ccdc30 | ||
|
|
32a5bc31bb | ||
|
|
6a2d6d608d | ||
|
|
5653fc9c12 | ||
|
|
b9037c1ce1 | ||
|
|
ac9b702eaa | ||
|
|
f89cccedb2 | ||
|
|
cf72daacbe | ||
|
|
5ab45b95c6 | ||
|
|
6e7338a214 | ||
|
|
570fad84ab | ||
|
|
788e4ec3bc | ||
|
|
fcab03f1d1 | ||
|
|
a91e1a831e | ||
|
|
22f78f171c | ||
|
|
5df34712b6 | ||
|
|
db9ef8e51d | ||
|
|
3aec2b1fe6 | ||
|
|
bb7c468174 | ||
|
|
62eb4dc4bd | ||
|
|
ed814cbf60 | ||
|
|
1650a3fc94 | ||
|
|
b6ed3b6502 | ||
|
|
a1041c3e6f | ||
|
|
0f6f912055 | ||
|
|
602f16be8c | ||
|
|
83f0ac9813 | ||
|
|
73fff50a00 | ||
|
|
2f9b0c1154 | ||
|
|
dfed3f2db9 | ||
|
|
fbf3cb5ae2 | ||
|
|
c2ed444ca0 | ||
|
|
6a6a3a212e | ||
|
|
a2ec070a32 | ||
|
|
9efe3d00ef | ||
|
|
b4acc74e0a | ||
|
|
fb1e749a28 | ||
|
|
69296dff67 | ||
|
|
723af175cb | ||
|
|
bfacde7525 | ||
|
|
e64a553365 | ||
|
|
246ef468de | ||
|
|
c6594712df | ||
|
|
8f40f575bf | ||
|
|
c60158cc47 | ||
|
|
fc6b7472b6 | ||
|
|
30c17c0c8b | ||
|
|
560fd93c8b | ||
|
|
67c53f97dd | ||
|
|
cc48136d94 | ||
|
|
7ceab97472 | ||
|
|
f389f4d027 | ||
|
|
c162445627 | ||
|
|
f788f7a892 | ||
|
|
f35da9cc2b | ||
|
|
04940ff42c | ||
|
|
a7310cf59b | ||
|
|
3d0142310e | ||
|
|
999467bb1f | ||
|
|
b5b7ccbc46 | ||
|
|
0ba112aec5 | ||
|
|
04e350b624 | ||
|
|
5c1b2dbd9d | ||
|
|
c6d9eb3271 | ||
|
|
05e8f05b41 | ||
|
|
aed96c8e41 | ||
|
|
31a1049655 | ||
|
|
fda1c6c66e | ||
|
|
f03144ba91 | ||
|
|
a72ec8c476 | ||
|
|
a4c1aa869b | ||
|
|
683ab6a2c9 | ||
|
|
1ce704864c | ||
|
|
90fa352a50 | ||
|
|
6fc4f6b58f | ||
|
|
323ed3ee18 | ||
|
|
cff756597c | ||
|
|
fe7731efe7 | ||
|
|
c6c3ff2d71 | ||
|
|
0f1afbb877 | ||
|
|
9cf2b82c8e | ||
|
|
d1646532d5 | ||
|
|
0e1bd91075 | ||
|
|
6446a4654b | ||
|
|
b08750696e | ||
|
|
ed6a98392d | ||
|
|
656d124c89 | ||
|
|
6c2602246e | ||
|
|
c9bcc92bdf | ||
|
|
6d519cfa0a | ||
|
|
69d62f8266 | ||
|
|
a657682035 | ||
|
|
29df283f80 | ||
|
|
e277a1363e | ||
|
|
a41baee3fc | ||
|
|
21286920a2 | ||
|
|
2673f7f9f8 | ||
|
|
d77b25c7c1 | ||
|
|
65535eaded | ||
|
|
80ef57b0b8 | ||
|
|
c55a415099 | ||
|
|
1b55e82061 | ||
|
|
1b48c291c9 | ||
|
|
aac1e09c72 | ||
|
|
0d35274d6a | ||
|
|
f5b1b4a83d | ||
|
|
044c08c3fc | ||
|
|
83793603d3 | ||
|
|
adac6774a4 | ||
|
|
c8686f9d66 | ||
|
|
4454970564 | ||
|
|
f0cd4d3c5a | ||
|
|
18b9fd6d4d | ||
|
|
2183e7de3c | ||
|
|
130b917c81 | ||
|
|
93d77ea297 | ||
|
|
2109e4f4fa | ||
|
|
15a37457de | ||
|
|
fb2f79823c | ||
|
|
d700561c0f | ||
|
|
09d220b2a3 | ||
|
|
48803b1483 | ||
|
|
de53582d47 | ||
|
|
746300a514 | ||
|
|
b9a311a18c | ||
|
|
f6a79456e9 | ||
|
|
1c5f63b61b | ||
|
|
6b0bb9cb3d | ||
|
|
c475500bdb | ||
|
|
be769b7dc8 | ||
|
|
819a2e2203 | ||
|
|
a32b29976e | ||
|
|
6c99752f37 | ||
|
|
41ab804f79 | ||
|
|
63f09283ea | ||
|
|
0561e15d0a | ||
|
|
2c1d047f4b | ||
|
|
f54edf8692 | ||
|
|
f6f83a45f1 | ||
|
|
1e79704f70 | ||
|
|
fa47c28136 | ||
|
|
dee46b77df | ||
|
|
b72e20a4a5 | ||
|
|
a2914cc291 | ||
|
|
d10cba7935 | ||
|
|
2c9fbbba50 | ||
|
|
b9f31b4170 | ||
|
|
1fc8e35843 | ||
|
|
66ca5f5fff | ||
|
|
f30b9b34dd | ||
|
|
bf952433f1 | ||
|
|
6ab3ff9b0b | ||
|
|
8211da6cc3 | ||
|
|
4e5b2f02d9 | ||
|
|
934a2ec1fa | ||
|
|
d4fab4fba2 | ||
|
|
46a84e80b8 | ||
|
|
f798bd2c9c | ||
|
|
effc709a01 | ||
|
|
6470575a7b | ||
|
|
c7815ba841 | ||
|
|
adb0c4c869 | ||
|
|
059a523347 | ||
|
|
8fbd454059 | ||
|
|
30b4b89a95 | ||
|
|
a45a0138b9 | ||
|
|
ab22c6ff3f | ||
|
|
9c2809be29 | ||
|
|
0500518537 | ||
|
|
0bb3f78a33 | ||
|
|
09c1976608 | ||
|
|
86bf90ee89 | ||
|
|
a0b34217b4 | ||
|
|
019f6a78bc | ||
|
|
7b54f88981 | ||
|
|
d3067173d6 | ||
|
|
afe68e1b20 | ||
|
|
1727df4e38 | ||
|
|
effa166c67 | ||
|
|
0e1e577281 | ||
|
|
d567a2553e | ||
|
|
1a7dc3e737 | ||
|
|
d20b9bd825 | ||
|
|
e630893bef | ||
|
|
715d5412e4 | ||
|
|
1968420027 | ||
|
|
a8800838d4 | ||
|
|
756a563135 | ||
|
|
2381bd38b9 | ||
|
|
1aa406fc7b | ||
|
|
bf743caee5 | ||
|
|
6ea46c238c | ||
|
|
42dd4b9ce6 | ||
|
|
151f66a0fb | ||
|
|
b0aeee6ff0 | ||
|
|
077a809168 | ||
|
|
74eaab3fde | ||
|
|
8742e4de25 | ||
|
|
8986a497fd | ||
|
|
99af6025ee | ||
|
|
53838cbdd5 | ||
|
|
a413d1afc2 | ||
|
|
296d1a356b | ||
|
|
9391052011 | ||
|
|
86c4561647 | ||
|
|
f6bf33274f | ||
|
|
78800214d0 | ||
|
|
aa0bb5cbc2 | ||
|
|
148beb7d74 | ||
|
|
78f59b38c1 | ||
|
|
48f085d0df | ||
|
|
2fa35336cb | ||
|
|
7addaefbf0 | ||
|
|
cf90c7ac67 | ||
|
|
afec553961 | ||
|
|
7e262feeb6 | ||
|
|
6aed1fa38e | ||
|
|
4d5c1b4332 | ||
|
|
cace6d4180 | ||
|
|
c3889399fd | ||
|
|
d4da572940 | ||
|
|
7cf92f7841 | ||
|
|
408e4cd9f6 | ||
|
|
2eb7c4cff9 | ||
|
|
b42b091181 | ||
|
|
6587cbf7f0 | ||
|
|
af7d2c741b | ||
|
|
a91d7f24a1 | ||
|
|
1021f30fb8 | ||
|
|
f0e702e397 | ||
|
|
e48c2ed457 | ||
|
|
1273843f26 | ||
|
|
22758e64ac | ||
|
|
341296dc3f | ||
|
|
55f0d51e1c | ||
|
|
62eb1a86e4 | ||
|
|
3790e0f07e | ||
|
|
daa3581be0 | ||
|
|
55bf250c63 | ||
|
|
eec7ab2e1a | ||
|
|
25b4b32f94 | ||
|
|
7c82a4cdf9 | ||
|
|
0f02e270f2 | ||
|
|
07c9a83f75 | ||
|
|
bf81738e19 | ||
|
|
55abe8b53a | ||
|
|
f858668ca8 | ||
|
|
2d7df413ee | ||
|
|
dae66de160 | ||
|
|
8b60a44b09 | ||
|
|
2103c649f6 | ||
|
|
6d4326fe5b | ||
|
|
3e18e38f15 | ||
|
|
9ade84c6fe | ||
|
|
bf3c501353 | ||
|
|
a529414e42 | ||
|
|
e4ab7dde86 | ||
|
|
a865647eae | ||
|
|
f02fa1a9bb | ||
|
|
fea93aa9ec | ||
|
|
00ac94ac8c | ||
|
|
c34aaadf06 | ||
|
|
efd25294ac | ||
|
|
beb5a5c743 | ||
|
|
f3f73a5e41 | ||
|
|
189ab96e3c | ||
|
|
16f11f3a9b | ||
|
|
c8ed4b9fba | ||
|
|
2fa231a6cd | ||
|
|
1c9dee1213 | ||
|
|
85a81878cc | ||
|
|
df14b62b94 | ||
|
|
5e6784c235 | ||
|
|
0f5b8098f6 | ||
|
|
93954135d2 | ||
|
|
90008c3a89 | ||
|
|
bd4c04c1d8 | ||
|
|
f95913b6d8 | ||
|
|
3a04b6276c | ||
|
|
4d23acb27e | ||
|
|
077ac65650 | ||
|
|
299b1578a7 | ||
|
|
413fdce590 | ||
|
|
cfdbe0705a | ||
|
|
769d424dde | ||
|
|
8671bee08b | ||
|
|
c17169f49d | ||
|
|
fde7d9a11c | ||
|
|
fec09c03ba | ||
|
|
0f0c7437a6 | ||
|
|
410af788fc | ||
|
|
a96c089d7f | ||
|
|
b8dd0ae41e | ||
|
|
75e981335f | ||
|
|
6c2afd7e32 | ||
|
|
2f130861d6 | ||
|
|
5572b3e692 | ||
|
|
b75253a249 | ||
|
|
831a9392e3 | ||
|
|
81f7e7fd64 | ||
|
|
823fe4de37 | ||
|
|
bd4cc10b16 | ||
|
|
ef78085e40 | ||
|
|
422c702777 | ||
|
|
6063ea9fba | ||
|
|
db2a24319d | ||
|
|
b01d50c7e8 | ||
|
|
b2ff27f241 | ||
|
|
235cc79e8b | ||
|
|
5f804dde14 | ||
|
|
e6d7876679 | ||
|
|
a23afe3d90 | ||
|
|
ed58cbac6b | ||
|
|
829184056e | ||
|
|
9d9c5a3988 | ||
|
|
35bbac9c99 | ||
|
|
a44ed295a2 | ||
|
|
90b583df44 | ||
|
|
f521bd6d2d | ||
|
|
4ee56fad01 | ||
|
|
9735ef93e9 | ||
|
|
6d39fd3831 | ||
|
|
35f040c8d7 | ||
|
|
2849260672 | ||
|
|
f462ae3394 | ||
|
|
c4bb1f3df1 | ||
|
|
e754e483ec | ||
|
|
aeac5c285b | ||
|
|
c1ba299e9f | ||
|
|
90b6498ff9 | ||
|
|
30261a106f | ||
|
|
8428572def | ||
|
|
4838bb189b | ||
|
|
54381370c0 | ||
|
|
d981a0a4be | ||
|
|
3b07c78518 | ||
|
|
076aa9133a | ||
|
|
70df85187f | ||
|
|
f7080a2697 | ||
|
|
cb62786470 | ||
|
|
caf4906176 | ||
|
|
564d247657 | ||
|
|
c4dbdcb650 | ||
|
|
30062485ab | ||
|
|
87ac6f94a3 | ||
|
|
7a710ee5dc | ||
|
|
700d3898ab | ||
|
|
8505b132bb | ||
|
|
dfb274e85d | ||
|
|
b97aaa81a9 | ||
|
|
0cd1ea342d | ||
|
|
3c43b8d7cd | ||
|
|
0885bfc9d2 | ||
|
|
411005eaa6 | ||
|
|
89ae9aa4a3 | ||
|
|
fa18238e60 | ||
|
|
c1452e005f | ||
|
|
e51665b612 | ||
|
|
8d197ce479 | ||
|
|
f0680ce0c4 | ||
|
|
72cba627a8 | ||
|
|
aa41336b90 | ||
|
|
ec5d9f7b12 | ||
|
|
0d376e003e | ||
|
|
560a89cfd3 | ||
|
|
e7c9bbe846 | ||
|
|
8d26de6915 | ||
|
|
14061dec0e | ||
|
|
4218657c28 | ||
|
|
dff3081194 | ||
|
|
349af15e05 | ||
|
|
50c81c5c96 | ||
|
|
3a454d77f5 | ||
|
|
ac6e33bb6c | ||
|
|
6c6ce8f344 | ||
|
|
4b6a8e49ae | ||
|
|
5cb29c8f7d | ||
|
|
fb301b3b3e | ||
|
|
ca4eaa86cb | ||
|
|
39d463932b | ||
|
|
e803af2fd2 | ||
|
|
3514f25f2b | ||
|
|
6804169a8a | ||
|
|
37072fa003 | ||
|
|
c8343f1a23 | ||
|
|
81c7f97d72 | ||
|
|
1b6bd68879 | ||
|
|
8ff0bd803a | ||
|
|
350ac08d4d | ||
|
|
84840d2b02 | ||
|
|
e7e02281a2 | ||
|
|
83a1dd23ff | ||
|
|
bf74530aa2 | ||
|
|
131f06ee84 | ||
|
|
49ff0d9723 | ||
|
|
37b44bcca4 | ||
|
|
060dfdc7d7 | ||
|
|
f670224460 | ||
|
|
32b4399683 | ||
|
|
dedaa45d07 | ||
|
|
368e4676fd | ||
|
|
509b3ca3b7 | ||
|
|
0c9a7e3d07 | ||
|
|
a07e10218e | ||
|
|
f1b040f176 | ||
|
|
603a74a751 | ||
|
|
2820c067c5 | ||
|
|
a0e37422b9 | ||
|
|
8c2f71294f | ||
|
|
fedbd200fc | ||
|
|
d03d61516f | ||
|
|
09eb03dad6 | ||
|
|
8073604271 | ||
|
|
edbe32c148 | ||
|
|
9e53aa75a0 | ||
|
|
4388402850 | ||
|
|
e3de4d59c7 | ||
|
|
f7e83342a0 | ||
|
|
be5cc7f97f | ||
|
|
e9d542f438 | ||
|
|
a7c38ae0af | ||
|
|
bb168d7e1e | ||
|
|
e53f00a432 | ||
|
|
a8ce3bca11 | ||
|
|
2e1a87199a | ||
|
|
36b9f2efd7 | ||
|
|
197113c842 | ||
|
|
b20e2cac8c | ||
|
|
5c2fc32546 | ||
|
|
7f8efaa4e0 | ||
|
|
8adb630862 | ||
|
|
4cd0585d0b | ||
|
|
4f5d169d06 | ||
|
|
9b873764fb | ||
|
|
8326b42bc7 | ||
|
|
f99b9826be | ||
|
|
f4363b8fda | ||
|
|
6df0c96d1b | ||
|
|
db3af69ff7 | ||
|
|
dfa0fa4a9e | ||
|
|
325a06395d | ||
|
|
76051870b7 | ||
|
|
432bb881e6 | ||
|
|
8711d87a36 | ||
|
|
ade135514b | ||
|
|
684021f93d | ||
|
|
78bb1f2dc1 | ||
|
|
70d6739465 | ||
|
|
760ca374ce | ||
|
|
c8d80dfc63 | ||
|
|
ccfd5c30ce | ||
|
|
e908434a20 | ||
|
|
5b7273e9d9 | ||
|
|
e6a4e88935 | ||
|
|
d2e900dc3a | ||
|
|
30a145599c | ||
|
|
5c6cd01b39 | ||
|
|
4a0ba44116 | ||
|
|
967520c1bd | ||
|
|
b40805c02e | ||
|
|
a2e1ed4820 | ||
|
|
fb71dd1ff1 | ||
|
|
12066072ec |
58
README.md
@ -1,8 +1,16 @@
|
||||
# Lexy's Labyrinth
|
||||
|
||||
This is a web implementation of a puzzle game that bears a _striking_ similarity to [Chip's Challenge](https://wiki.bitbusters.club/Chip%27s_Challenge) and its [sequel](https://wiki.bitbusters.club/Chip%27s_Challenge_2), but is legally distinct, and also free!
|
||||
This is a reimplementation of [**Chip's Challenge®**](https://wiki.bitbusters.club/Chip%27s_Challenge), that puzzle game you might remember from the early 90s (and its long-awaited [sequel](https://wiki.bitbusters.club/Chip%27s_Challenge_2)).
|
||||
|
||||
It is a work in progress and also might be abandoned and forgotten at any time.
|
||||
It's free; runs in a browser; has completely new artwork, sounds, and music; comes with hundreds of quality fan-made levels built in; and can load the original levels from a copy of the commercial game!
|
||||
|
||||
Documentation is underway on the [wiki](https://github.com/eevee/lexys-labyrinth/wiki).
|
||||
|
||||
## My lawyer is telling me to say this
|
||||
|
||||
To be absolutely clear: this is a ***fan project*** and is not affiliated with, sponsored by, endorsed by, or in any way approved of by Bridgestone Multimedia Group LLC. **Chip's Challenge** is a registered trademark of Bridgestone Multimedia Group LLC, and is used here for identification purposes only.
|
||||
|
||||
Despite the names, the built-in "Chip's Challenge Level Packs" are community creations and have no relation to the commercial games or their levels.
|
||||
|
||||
## Play online
|
||||
|
||||
@ -10,37 +18,41 @@ Give it a try, I guess! [https://c.eev.ee/lexys-labyrinth/](https://c.eev.ee/le
|
||||
|
||||
## Current status
|
||||
|
||||
- Supports 99% of Chip's Challenge 1
|
||||
- Supports 75% of Chip's Challenge 2
|
||||
- Fully compatible with Chip's Challenge 1 levels... barring a few obscure rule changes
|
||||
- Fully compatible with Chip's Challenge 2 levels... barring a few obscure bugs
|
||||
- Completely original tileset, sound effects, and music
|
||||
- Can load MS Chip's Challenge DAT/CCL files and Steam Chip's Challenge C2M files
|
||||
- Can load levels from your hard drive
|
||||
- Can play back replays (demos) from C2M files, though some may desync
|
||||
- Allows undoing moves, with moderate success
|
||||
- Has the beginning bits of a level editor
|
||||
- Compatible with MS Chip's Challenge DAT/CCL files, Steam Chip's Challenge C2G/C2M files, and ZIP files
|
||||
- Can load one of its built-in level packs, the original levels, or anything you've got lying around
|
||||
- Able to record and play back demos (replays) from Steam-format levels
|
||||
- Lets you rewind your mistakes, up to 30 seconds back
|
||||
- Lets you take the pressure off by switching from real-time to turn-based mode, where nothing moves until you do
|
||||
- Contains a completely usable level editor with support for every tile in Chip's Challenge 2
|
||||
- Works on touchscreens too
|
||||
- Has compatibility settings for opting into behavior (or bugs) from particular implementations
|
||||
- Debug mode (click the logo in the lower left)
|
||||
|
||||
### Planned features
|
||||
|
||||
- Save your score, and compare it to the BBC leaderboards
|
||||
- Load levels directly from the BBC set list
|
||||
- Support for all of the nonsense in Chip's Challenge 2
|
||||
- Allow playing the original commercial levels by dragging the data files in from your own computer
|
||||
- Support various sets of bugs from various implementations
|
||||
- Play the game turn-based instead of realtime (i.e., nothing moves until Chip does)
|
||||
- Record demos
|
||||
- Mouse and touchscreen support
|
||||
- Bunches of debug features
|
||||
- Outright cheat in a variety of ways
|
||||
- Mouse support
|
||||
|
||||
### Noble aspirations
|
||||
## For developers
|
||||
|
||||
- New exclusive puzzle elements?? Embrace extend extinguish baby
|
||||
It's all static JS; there's no build system. If you want to run it locally, just throw your favorite HTTP server at a checkout and open a browser. (Browsers won't allow XHR from `file:///` URLs, alas. If you don't have a favorite HTTP server, try `python -m http.server`.)
|
||||
|
||||
If you have Node installed, you can test the solutions included with the bundled level packs without needing a web browser:
|
||||
|
||||
```
|
||||
node js/headless/bulktest.mjs
|
||||
```
|
||||
|
||||
Note that solution playback is still not perfect, so don't be alarmed if you don't get 100% — only if you make a change and something regresses.
|
||||
|
||||
## Special thanks
|
||||
|
||||
- The incredible nerds who put together the [Chip Wiki](https://wiki.bitbusters.club/) and also reside on the Bit Busters Discord
|
||||
- The incredible nerds who put together the [Chip Wiki](https://wiki.bitbusters.club/) and also reside on the Bit Busters Discord, including:
|
||||
- ruben for documenting the CC2 PRNG
|
||||
- The Architect for documenting the CC2 C2G parser
|
||||
- Everyone who worked on [Chip's Challenge Level Pack 1](https://wiki.bitbusters.club/Chip%27s_Challenge_Level_Pack_1), the default set of levels
|
||||
- [Tile World](https://wiki.bitbusters.club/Tile_World) for being an incredible reference on Lynx mechanics
|
||||
- Everyone who contributed music — see [`js/soundtrack.js`](js/soundtrack.js) for a list!
|
||||
|
||||
Not associated with or blessed by Chuck Somerville, Niffler, or AOP.
|
||||
|
||||
BIN
ending.png
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
BIN
icon-debug.png
Normal file
|
After Width: | Height: | Size: 522 B |
BIN
icon.png
|
Before Width: | Height: | Size: 333 B After Width: | Height: | Size: 494 B |
BIN
icons/compat-custom.png
Normal file
|
After Width: | Height: | Size: 422 B |
BIN
icons/compat-lexy.png
Normal file
|
After Width: | Height: | Size: 491 B |
|
Before Width: | Height: | Size: 332 B After Width: | Height: | Size: 329 B |
|
Before Width: | Height: | Size: 716 B After Width: | Height: | Size: 711 B |
BIN
icons/compat-steam-strict.png
Normal file
|
After Width: | Height: | Size: 681 B |
BIN
icons/compat-steam.png
Normal file
|
After Width: | Height: | Size: 670 B |
BIN
icons/editor-icons.aseprite
Normal file
BIN
icons/layer-actor.png
Normal file
|
After Width: | Height: | Size: 490 B |
BIN
icons/layer-all.png
Normal file
|
After Width: | Height: | Size: 597 B |
BIN
icons/layer-canopy.png
Normal file
|
After Width: | Height: | Size: 456 B |
BIN
icons/layer-item.png
Normal file
|
After Width: | Height: | Size: 451 B |
BIN
icons/layer-item_mod.png
Normal file
|
After Width: | Height: | Size: 444 B |
BIN
icons/layer-swivel.png
Normal file
|
After Width: | Height: | Size: 421 B |
BIN
icons/layer-terrain.png
Normal file
|
After Width: | Height: | Size: 395 B |
BIN
icons/layer-thin_wall.png
Normal file
|
After Width: | Height: | Size: 413 B |
BIN
icons/rotate-left.png
Normal file
|
After Width: | Height: | Size: 478 B |
BIN
icons/rotate-right.png
Normal file
|
After Width: | Height: | Size: 478 B |
|
Before Width: | Height: | Size: 299 B After Width: | Height: | Size: 496 B |
|
Before Width: | Height: | Size: 155 B After Width: | Height: | Size: 421 B |
BIN
icons/tool-camera.png
Normal file
|
After Width: | Height: | Size: 395 B |
|
Before Width: | Height: | Size: 276 B After Width: | Height: | Size: 440 B |
|
Before Width: | Height: | Size: 207 B After Width: | Height: | Size: 406 B |
|
Before Width: | Height: | Size: 226 B After Width: | Height: | Size: 381 B |
BIN
icons/tool-ice.png
Normal file
|
After Width: | Height: | Size: 484 B |
|
Before Width: | Height: | Size: 154 B After Width: | Height: | Size: 423 B |
|
Before Width: | Height: | Size: 184 B After Width: | Height: | Size: 368 B |
BIN
icons/tool-rotate.png
Normal file
|
After Width: | Height: | Size: 439 B |
BIN
icons/tool-select-box.png
Normal file
|
After Width: | Height: | Size: 408 B |
BIN
icons/tool-select-wand.png
Normal file
|
After Width: | Height: | Size: 498 B |
BIN
icons/tool-text.png
Normal file
|
After Width: | Height: | Size: 452 B |
BIN
icons/tool-thin-walls.png
Normal file
|
After Width: | Height: | Size: 429 B |
BIN
icons/tool-tracks.png
Normal file
|
After Width: | Height: | Size: 385 B |
|
Before Width: | Height: | Size: 257 B After Width: | Height: | Size: 488 B |
557
index.html
@ -5,201 +5,450 @@
|
||||
<title>Lexy's Labyrinth</title>
|
||||
<link rel="stylesheet" type="text/css" href="style.css">
|
||||
<link rel="shortcut icon" type="image/png" href="icon.png">
|
||||
<script>
|
||||
"use strict";
|
||||
{
|
||||
let domloaded = false;
|
||||
window.addEventListener('DOMContentLoaded', ev => domloaded = true);
|
||||
|
||||
let _ll_log_fatal_error = (err, ev) => {
|
||||
document.getElementById('loading').setAttribute('hidden', '');
|
||||
let failed = document.getElementById('failed');
|
||||
failed.removeAttribute('hidden');
|
||||
document.body.setAttribute('data-mode', 'failed');
|
||||
|
||||
failed.classList.add('--got-error');
|
||||
let stack = '(origin unknown)';
|
||||
if (err.stack && err.stack.match(/\n/)) {
|
||||
// Chrome sometimes gives us a stack that's actually just the message without
|
||||
// any filenames, in which case skip it
|
||||
stack = err.stack.replace(/^/mg, " ");
|
||||
}
|
||||
else if (err.fileName) {
|
||||
stack = `in ${err.fileName} at ${err.lineNumber}:${err.columnNumber}`;
|
||||
}
|
||||
else if (ev) {
|
||||
stack = `in ${ev.filename} at ${ev.lineno}:${ev.colno}`;
|
||||
}
|
||||
failed.querySelector('pre').textContent = `${err.toString()}\n\n${stack}`;
|
||||
};
|
||||
window.ll_log_fatal_error = function(err, ev) {
|
||||
if (domloaded) {
|
||||
_ll_log_fatal_error(err, ev);
|
||||
}
|
||||
else {
|
||||
window.addEventListener('DOMContentLoaded', () => _ll_log_fatal_error(err, ev));
|
||||
}
|
||||
};
|
||||
let error_listener = ev => {
|
||||
if (! ev.error)
|
||||
// Not a script error
|
||||
return;
|
||||
try {
|
||||
ll_log_fatal_error(ev.error, ev);
|
||||
}
|
||||
catch (err) {}
|
||||
};
|
||||
window.addEventListener('error', error_listener, true);
|
||||
// Once we've loaded successfully, drop the handler
|
||||
window.ll_successfully_loaded = function() {
|
||||
window.removeEventListener('error', error_listener, true);
|
||||
};
|
||||
}
|
||||
</script>
|
||||
<!-- FIXME it would be super swell if i could load this lazily -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@latest/Sortable.min.js"></script>
|
||||
<script type="module" src="js/main.js"></script>
|
||||
<meta name="og:type" content="website">
|
||||
<meta name="og:image" content="https://c.eev.ee/lexys-labyrinth/og-preview.png">
|
||||
<meta name="og:title" content="Lexy's Labyrinth">
|
||||
<meta name="og:description" content="A (work in progress) reimplementation of Chip's Challenge 1 and 2, using entirely free assets.">
|
||||
<meta name="og:description" content="Free online puzzle game that emulates Chip's Challenge. Play hundreds of community curated levels, load the levels from the commercial games, or make your own with the built-in editor.">
|
||||
<meta name="description" content="Free online puzzle game that emulates Chip's Challenge. Play hundreds of community curated levels, load the levels from the commercial games, or make your own with the built-in editor.">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
</head>
|
||||
<body data-mode="splash">
|
||||
<body data-mode="failed">
|
||||
<script>document.body.setAttribute('data-mode', 'loading');</script>
|
||||
<svg id="svg-iconsheet">
|
||||
<defs>
|
||||
<g id="svg-icon-menu-chevron">
|
||||
<path d="M2,4 l6,6 l6,-6 v3 l-6,6 l-6,-6 z"></path>
|
||||
</g>
|
||||
<g id="svg-icon-prev">
|
||||
<path d="M14,1 2,8 14,14 z">
|
||||
</g>
|
||||
<g id="svg-icon-next">
|
||||
<path d="M2,1 14,8 2,14 z">
|
||||
</g>
|
||||
<!-- Actions -->
|
||||
<g id="svg-icon-up">
|
||||
<path d="M0,12 l8,-8 l8,8 z"></path>
|
||||
</g>
|
||||
<g id="svg-icon-right">
|
||||
<use href="#svg-icon-up" transform="rotate(90 8 8)"></use>
|
||||
</g>
|
||||
<g id="svg-icon-down">
|
||||
<use href="#svg-icon-up" transform="rotate(180 8 8)"></use>
|
||||
</g>
|
||||
<g id="svg-icon-left">
|
||||
<use href="#svg-icon-up" transform="rotate(270 8 8)"></use>
|
||||
</g>
|
||||
<g id="svg-icon-drop">
|
||||
<path d="M6,0 h4 v9 h3 l-5,5 h7 v2 h-14 v-2 h7 l-5,-5 h3"></path>
|
||||
</g>
|
||||
<g id="svg-icon-cycle">
|
||||
<path d="M2,3 H11 V1 l4,4 -4,4 V7 H2 Z"></path>
|
||||
<path d="M14,9 H5 V7 l-4,4 4,4 v-2 h9 z"></path>
|
||||
</g>
|
||||
<g id="svg-icon-swap">
|
||||
<path d="m 7,1 h 2 l 1,1 V 6 L 9,7 v 4 L 8,11.5 7,11 V 7 L 6,6 V 2 Z"></path>
|
||||
<path d="M 8,13 13,11 8,9 3,11 Z m 0,2 7,-3 V 11 L 8,8 1,11 v 1 z"></path>
|
||||
<ellipse cx="5.5" cy="11" rx="0.75" ry="0.5"></ellipse>
|
||||
</g>
|
||||
<!-- Hint background -->
|
||||
<g id="svg-icon-hint">
|
||||
<path d="M1,8 a7,7 0 1,1 14,0 7,7 0 1,1 -14,0 M2,8 a6,6 0 1,0 12,0 6,6 0 1,0 -12,0"></path>
|
||||
<path d="M5,6 a1,1 0 0,0 2,0 a1,1 0 1,1 1,1 a1,1 0 0,0 -1,1 v1 a1,1 0 1,0 2,0 v-0.17 A3,3 0 1,0 5,6"></path>
|
||||
<circle cx="8" cy="12" r="1"></circle>
|
||||
</g>
|
||||
<!-- Editor stuff -->
|
||||
<g id="svg-icon-zoom">
|
||||
<path d="M1,6 a5,5 0 1,1 10,0 a5,5 0 1,1 -10,0 m2,0 a3,3 0 1,0 6,0 a3,3 0 1,0 -6,0"></path>
|
||||
<path d="M14,12 l-2,2 -4,-4 2,-2 4,4"></path>
|
||||
</g>
|
||||
<g id="svg-icon-mouse1">
|
||||
<path d="
|
||||
M9,2 a3,3 0 0,1 3,3 v5 a4,4 0 0,1 -8,0 v-5 a3,3 0 0,1 3,-3 z
|
||||
M9,3 v5 h-4 v-3 a2,2 0 0,1 2,-2 h1 z
|
||||
"></path>
|
||||
<!--M9,3 a2,2 0 0,0 -2,2 v3 h3 z-->
|
||||
</g>
|
||||
<g id="svg-icon-mouse2">
|
||||
<path d="
|
||||
M9,2 a3,3 0 0,1 3,3 v5 a4,4 0 0,1 -8,0 v-5 a3,3 0 0,1 3,-3 z
|
||||
M7,3 h2 a2,2 0 0,1 2,2 v3 h-4 v-5 z
|
||||
"></path>
|
||||
</g>
|
||||
</defs>
|
||||
</svg>
|
||||
<header id="header-main">
|
||||
<img src="icon.png" alt="">
|
||||
<h1>Lexy's Labyrinth</h1>
|
||||
<p>— a game by <a href="https://eev.ee/">eevee</a></p>
|
||||
<img id="header-icon" src="icon.png" alt="">
|
||||
<h1><a href="https://github.com/eevee/lexys-labyrinth">Lexy's Labyrinth</a></h1>
|
||||
<p>— an <a href="https://github.com/eevee/lexys-labyrinth">open source</a> game by <a href="https://eev.ee/">eevee</a></p>
|
||||
<nav>
|
||||
<button id="main-about" type="button">about</button>
|
||||
<button id="main-help" type="button" disabled>help</button>
|
||||
<button id="main-options" type="button" disabled>options</button>
|
||||
<button id="main-compat" type="button"><img src="icons/compat-lexy.png" alt=""> <output>lexy</output></button>
|
||||
<button id="main-options" type="button">options</button>
|
||||
</nav>
|
||||
</header>
|
||||
<header id="header-pack">
|
||||
<h2 id="level-pack-name">Chip's Challenge Level Pack 1</h2>
|
||||
<nav>
|
||||
<button id="main-test-pack" type="button">Bulk test</button>
|
||||
<button id="main-change-pack" type="button">Change pack</button>
|
||||
<button id="player-edit" type="button">Edit</button>
|
||||
<button id="editor-play" type="button">Test</button>
|
||||
<button id="editor-play" type="button">Play</button>
|
||||
</nav>
|
||||
</header>
|
||||
<header id="header-level">
|
||||
<h3 id="level-name">Level 1 — Key Pyramid</h3>
|
||||
<nav>
|
||||
<button id="main-prev-level" type="button">←</button>
|
||||
<button id="main-prev-level" type="button">
|
||||
<svg class="svg-icon" viewBox="0 0 16 16" title="previous"><use href="#svg-icon-prev"></svg>
|
||||
</button>
|
||||
<button id="main-choose-level" type="button">Level select</button>
|
||||
<button id="main-next-level" type="button">→</button>
|
||||
<button id="main-next-level" type="button">
|
||||
<svg class="svg-icon" viewBox="0 0 16 16" title="next"><use href="#svg-icon-next"></svg>
|
||||
</button>
|
||||
</nav>
|
||||
</header>
|
||||
<main id="splash">
|
||||
<main id="failed">
|
||||
<h1>oops!</h1>
|
||||
<p>Sorry, the game was unable to load at all.</p>
|
||||
<p>If you have JavaScript partly or wholly blocked, I salute you! ...but this is an interactive game and cannot work without it.</p>
|
||||
<p>If not, it's possible that the game updated, but you have a mix of old and new code. Try a hard refresh (Ctrl-Shift-R).</p>
|
||||
<p class="-with-error">I did manage to capture this error, which you might be able to <a href="https://github.com/eevee/lexys-labyrinth/issues/new">report somewhere</a>:</p>
|
||||
<pre class="-with-error stack-trace"></pre>
|
||||
</main>
|
||||
<main id="loading" hidden>
|
||||
<p>...loading...</p>
|
||||
<div class="scrolling-sidewalk">
|
||||
<img src="loading.gif" alt="Lexy walking">
|
||||
</div>
|
||||
</main>
|
||||
<script>
|
||||
document.querySelector('#failed').setAttribute('hidden', '');
|
||||
document.querySelector('#loading').removeAttribute('hidden');
|
||||
</script>
|
||||
<main id="splash" hidden>
|
||||
<div class="drag-overlay"></div>
|
||||
<header>
|
||||
<h1><img src="og-preview.png" alt="">Lexy's Labyrinth</h1>
|
||||
<img src="og-preview.png" alt="">
|
||||
<h1>Lexy's Labyrinth</h1>
|
||||
<p>an unofficial <strong>Chip's Challenge</strong>® emulator</p>
|
||||
<button id="splash-fullscreen" type="button" title="Toggle fullscreen">
|
||||
<svg class="svg-icon" viewBox="0 0 16 16">
|
||||
<path d="m 11,1 h 4 V 5 L 14,4 12,6 10,4 12,2 Z"></path>
|
||||
<path d="m 11,15 h 4 v -4 l -1,1 -2,-2 -2,2 2,2 z"></path>
|
||||
<path d="M 5,1 H 1 V 5 L 2,4 4,6 6,4 4,2 Z"></path>
|
||||
<path d="M 5,15 H 1 v -4 l 1,1 2,-2 2,2 -2,2 z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
<section id="splash-intro">
|
||||
<p><strong>Welcome</strong> to Lexy's Labyrinth, an open source puzzle game that is curiously similar to — but legally distinct from — the Atari classic <a href="https://en.wikipedia.org/wiki/Chip%27s_Challenge">Chip's Challenge</a>!</p>
|
||||
<p>(This is a Chip's Challenge <em>emulator</em>, designed to be an accessible way to play community-made levels with free assets. It's 99% compatible with Chip's Challenge 1, and support for Chip's Challenge 2 is underway. But you can safely ignore all that and treat this as its own game.)</p>
|
||||
<p>Please note that <em>levels themselves</em> may contain hints or lore referring to a guy named Chip collecting computer chips, even though you are clearly a fox named Lexy collecting hearts. Weird, right? Sorry for any confusion!</p>
|
||||
<p>Pick a level pack to get started! You can also get more technical details or report bugs on <a href="https://github.com/eevee/lexys-labyrinth">GitHub</a>, find out more about Chip's Challenge via the <a href="https://bitbusters.club/">Bit Busters Club</a> fansite, or support this endeavor (and other things I do) via <a href="https://www.patreon.com/eevee">Patreon</a>!</p>
|
||||
<!-- TODO i want to make clear this is a chip's challenge emulator without bogging people down too much about what that means -->
|
||||
</section>
|
||||
<div id="splash-links">
|
||||
<a href="https://github.com/eevee/lexys-labyrinth/wiki">About</a>
|
||||
<a href="https://github.com/eevee/lexys-labyrinth/wiki/How-To-Play">How to play</a>
|
||||
<a href="https://github.com/eevee/lexys-labyrinth">Source code and more</a>
|
||||
<a href="https://patreon.com/eevee">Support on Patreon</a>
|
||||
</div>
|
||||
<p id="splash-disclaimer"><strong>Chip's Challenge</strong> is a registered trademark of Bridgestone Media Group LLC, used here for identification purposes only. Not affiliated with, sponsored, or endorsed by Bridgestone Media Group LLC.</p>
|
||||
|
||||
<section id="splash-stock-levels">
|
||||
<h2>Just play something</h2>
|
||||
<!-- populated by js -->
|
||||
</section>
|
||||
<h2>Play</h2>
|
||||
<ul class="played-pack-list" id="splash-stock-pack-list">
|
||||
<!-- populated by js -->
|
||||
</ul>
|
||||
<div class="button-row">
|
||||
<button type="button" class="button-big" disabled>More levels</button>
|
||||
<button type="button" class="button-big" disabled>Other saved scores</button>
|
||||
</div>
|
||||
|
||||
<section id="splash-upload-levels">
|
||||
<h2>Other levels</h2>
|
||||
<p>You can play <code>CHIPS.DAT</code> from the original Microsoft version, any custom levels you have lying around, or perhaps ones you found on the <a href="https://sets.bitbusters.club/">Bit Busters Club set list</a>!</p>
|
||||
<!-- TODO explain how to find chips.dat or steam folder -->
|
||||
<!-- TODO drag and drop? -->
|
||||
<input id="splash-upload" type="file" accept=".dat,.ccl,.c2m,.ccs">
|
||||
<button type="button" id="splash-upload-button" class="button-big">Open a local level<!-- TODO: <br>(or drag and drop a file into this window) --></button>
|
||||
<p>Supports both the old Microsoft <code>CHIPS.DAT</code> format and the Steam <code>C2M</code> format.</p>
|
||||
<p>Does <em>not</em> yet support the Steam <code>C2G</code> format, so tragically, the original Steam levels can only be played one at a time. This should be fixed soon!</p>
|
||||
<!--
|
||||
<p>If you own the Steam versions of <a href="https://store.steampowered.com/app/346850/Chips_Challenge_1/">Chip's Challenge 1</a> (<em>free!</em>) or <a href="https://store.steampowered.com/app/348300/Chips_Challenge_2/">Chip's Challenge 2</a> ($5 last I checked), you can play those too, even on Linux or Mac:</p>
|
||||
<ol class="normal-list">
|
||||
<li>Right-click the game in Steam and choose <em>Properties</em>. On the <em>Local Files</em> tab, click <em>Browse local files</em>.</li>
|
||||
<li>Open the <code>data</code> folder, then <code>games</code>.</li>
|
||||
<li>You should see either a <code>cc1</code> or <code>cc2</code> folder. Drag it into this window.</li>
|
||||
</ol>
|
||||
-->
|
||||
<h2>More levels</h2>
|
||||
<p>Supports CCL/DAT, C2G, C2M, and ZIP; drag and drop; and both custom and official levels. <a href="https://github.com/eevee/lexys-labyrinth/wiki/Loading-Levels">More details</a></p>
|
||||
<div class="button-row">
|
||||
<input id="splash-upload-file" type="file" accept=".dat,.ccl,.c2m,.ccs,.zip" multiple>
|
||||
<input id="splash-upload-dir" type="file" webkitdirectory>
|
||||
<button type="button" id="splash-upload-file-button" class="button-big button-bright">Load files</button>
|
||||
<button type="button" id="splash-upload-dir-button" class="button-big button-bright">Load directory</button>
|
||||
</div>
|
||||
<ul class="played-pack-list" id="splash-other-pack-list">
|
||||
<!-- populated by js -->
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="splash-your-levels">
|
||||
<h2>Make your own (WIP lol)</h2>
|
||||
<p>Please note that the level editor is <strong>extremely</strong> unfinished, and can't even save yet.</p>
|
||||
<p><button type="button" id="splash-create-level" class="button-big">Create a level</button></p>
|
||||
<h2>Create</h2>
|
||||
<div class="button-row">
|
||||
<button type="button" id="splash-create-pack" class="button-big button-bright">New pack</button>
|
||||
<button type="button" id="splash-create-level" class="button-big button-bright">New scratch level<br>(won't be saved!)</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<main id="player" hidden>
|
||||
<section class="-main-area">
|
||||
<div class="level"><!-- level canvas and any overlays go here --></div>
|
||||
<div class="overlay-message">
|
||||
<h1 class="-top"></h1>
|
||||
<div class="-middle"></div>
|
||||
<p class="-bottom"></p>
|
||||
<p class="-keyhint"></p>
|
||||
<div id="player-main">
|
||||
<div id="player-controls">
|
||||
<button class="control-pause" type="button" title="pause">
|
||||
<svg class="svg-icon" viewBox="0 0 16 16"><path d="M2,1 h4 v14 h-4 z M10,1 h4 v14 h-4 z"></path></svg>
|
||||
<span class="-optional-label">pause</span> <span class="keyhint"><kbd>p</kbd></span></button>
|
||||
<button class="control-restart" type="button" title="restart">
|
||||
<svg class="svg-icon" viewBox="0 0 16 16"><path d="M13,13 A 7,7 270 1,1 13,3 L15,1 15,7 9,7 11,5 A 4,4 270 1,0 11,11 z"></path></svg>
|
||||
<span class="-optional-label">retry</span> <span class="keyhint"><kbd>r</kbd></span></button>
|
||||
<button class="control-undo" type="button" title="undo">
|
||||
<svg class="svg-icon" viewBox="0 0 16 16"><path d="M6,5 6,2 1,7 6,12 6,9 A 10,10 60 0,1 15,12 A 10,10 90 0,0 6,5"></path></svg>
|
||||
<span class="-optional-label">undo</span> <span class="keyhint"><kbd>u</kbd></span></button>
|
||||
<button class="control-rewind" type="button" title="rewind">
|
||||
<svg class="svg-icon" viewBox="0 0 16 16"><path d="M1,8 7,2 7,14 z M9,8 15,2 15,14 z"></path></svg>
|
||||
<span class="-optional-label">rewind</span> <span class="keyhint"><kbd>z</kbd></span></button>
|
||||
<div class="radio-faux-button-set">
|
||||
<label><input class="control-turn-based" type="checkbox"> <span>Step <br>mode</span></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="message"></div>
|
||||
<div class="chips">
|
||||
<h3>Hearts</h3>
|
||||
<output></output>
|
||||
<div id="player-actions">
|
||||
<button class="action-drop" type="button">
|
||||
<svg class="svg-icon" viewBox="0 0 16 16"><use href="#svg-icon-drop"></use></svg>
|
||||
drop <span class="keyhint"><kbd>q</kbd></span></button>
|
||||
<button class="action-cycle" type="button">
|
||||
<svg class="svg-icon" viewBox="0 0 16 16"><use href="#svg-icon-cycle"></use></svg>
|
||||
cycle <span class="keyhint"><kbd>e</kbd></span></button>
|
||||
<button class="action-swap" type="button">
|
||||
<svg class="svg-icon" viewBox="0 0 16 16"><use href="#svg-icon-swap"></use></svg>
|
||||
switch <span class="keyhint"><kbd>c</kbd></span></button>
|
||||
</div>
|
||||
<div class="time">
|
||||
<h3>Time</h3>
|
||||
<output></output>
|
||||
</div>
|
||||
<div class="bonus">
|
||||
<h3>Bonus</h3>
|
||||
<output></output>
|
||||
</div>
|
||||
<div class="inventory"></div>
|
||||
</section>
|
||||
<div id="player-music">
|
||||
<div id="player-music-left">
|
||||
🎵 <a id="player-music-title">title</a> by <a id="player-music-author">author</a>
|
||||
</div>
|
||||
<div id="player-music-right">
|
||||
<input id="player-music-volume" type="range" min="0" max="1" step="0.05" value="1">
|
||||
<input id="player-music-unmute" type="checkbox" checked>
|
||||
</div>
|
||||
<audio loop preload="auto">
|
||||
</div>
|
||||
<div class="controls">
|
||||
<div class="play-controls">
|
||||
<button class="control-pause" type="button">Pause (p)</button>
|
||||
<button class="control-restart" type="button">Restart</button>
|
||||
<button class="control-undo" type="button">Undo</button>
|
||||
<button class="control-rewind" type="button">Rewind (z)</button>
|
||||
</div>
|
||||
<div class="demo-controls">
|
||||
<button class="demo-play" type="button">View replay</button>
|
||||
<button class="demo-step-1" type="button">Step 1 tic</button>
|
||||
<button class="demo-step-4" type="button">Step 1 move</button>
|
||||
<div class="input"></div>
|
||||
<section id="player-game-area">
|
||||
<div class="level"><!-- level canvas and any overlays go here --></div>
|
||||
<div class="player-overlay-message"></div>
|
||||
<div class="player-overlay-captions"></div>
|
||||
<div class="player-hint-wrapper">
|
||||
<div class="player-hint"></div>
|
||||
<svg class="player-hint-bg-icon svg-icon" viewBox="0 0 16 16"><use href="#svg-icon-hint"></use></svg>
|
||||
</div>
|
||||
<div class="player-level-number">
|
||||
Level
|
||||
<output></output>
|
||||
</div>
|
||||
<div class="chips">
|
||||
<h3>
|
||||
<svg class="svg-icon" viewBox="0 0 16 16" title="Hearts">
|
||||
<path d="M4,2 C 2,2 1,4 1,6 C 1,8 2,10 4,12 C 6,14 8,15 8,15 C 8,15 10,14 12,12 C 14,10 15,8 15,6 C 15,4 14,2 12,2 C 10,2 8,5 8,5 C 8,5 6,2 4,2 z M12,4 C 12,5 13,6 14,6 C 13,6 12,7 12,8 C 12,7 11,6 10,6 C 11,6 12,5 12,4 z"></path>
|
||||
</svg>
|
||||
</h3>
|
||||
<output></output>
|
||||
</div>
|
||||
<div class="time">
|
||||
<h3>
|
||||
<svg class="svg-icon" viewBox="0 0 16 16" title="Time">
|
||||
<path d="M 7,3 A -6,6 0 0 1 13,9 -6,6 0 0 1 7,15 -6,6 0 0 1 1,9 -6,6 0 0 1 7,3 Z M 7,4 A -5,5 0 0 0 2,9 -5,5 0 0 0 7,14 -5,5 0 0 0 12,9 -5,5 0 0 0 7,4 Z"></path>
|
||||
<!-- cap -->
|
||||
<path d="M 15,4 12,1 c -1,0 -1,0 -1,1 l 1,1 -2,2 1,1 2,-2 1,1 c 1,0 1,0 1,-1 z"></path>
|
||||
<!-- arrow -->
|
||||
<path d="M 8,9 10,6 7,8 Z"></path>
|
||||
<!-- center -->
|
||||
<circle cx="7" cy="9" r="1"></circle>
|
||||
</svg>
|
||||
</h3>
|
||||
<output></output>
|
||||
</div>
|
||||
<div class="bonus">
|
||||
<h3>
|
||||
<svg class="svg-icon" viewBox="0 0 16 16" title="Bonus">
|
||||
<circle cx="8" cy="8" r="4"></circle>
|
||||
<path d="m9,7 2,-6 c 2,0 1,1 2,2 1,1 2,0 2,2 z"></path>
|
||||
<path d="M7,9 5,15 C 3,15 4,14 3,13 2,12 1,13 1,11 z"></path>
|
||||
</svg>
|
||||
</h3>
|
||||
<output></output>
|
||||
</div>
|
||||
<div class="player-rules">
|
||||
<p id="player-rule-compat-lynx" title="This level is known to have compatibility issues with the default Lexy rules, but is playable with the rules it was designed for.">
|
||||
<svg class="svg-icon" viewBox="0 0 16 16"><path d="M 5,1 C 4,1 3,2 3,3 v 9 c 0,1 0,1 -1,1 -1,0 -1,2 0,2 h 9 c 1,0 2,-1 2,-2 V 4 c 0,-1 0,-1 1,-1 1,0 1,-2 0,-2 z m 0,2 h 6 V 4 H 5 Z m 0,3 h 6 V 7 H 5 Z m 0,3 h 6 v 1 H 5 Z m 0,3 h 6 v 1 H 5 Z"></path></svg>
|
||||
<span>May require Lynx rules</span>
|
||||
</p>
|
||||
<p id="player-rule-logic-hidden" title="In this level, wires and logic gates are invisible. X-ray glasses will reveal them.">
|
||||
<svg class="svg-icon" viewBox="0 0 16 16"><path d="M1,8 a7,7 0 0,1 14,0 7,7 0 0,1 -14,0 M3,8 a5,5 0 0,1 10,0 5,5 0 0,1 -10,0"></path><path d="M2,7 h5 v-5 h2 v5 h5 v2 h-5 v5 h-2 v-5 h-5 v-2"></path><path d="M1,14 L14,1 l1,1 L2,15 l-1,-1"></path></svg>
|
||||
<span>Logic hidden</span>
|
||||
</p>
|
||||
<p id="player-rule-cc1-boots" title="In this level, tools cannot be dropped, and only the tools available in CC1 (cleats, suction boots, fire boots, and flippers) can be picked up.">
|
||||
<svg class="svg-icon" viewBox="0 0 16 16"><path d="m 1,12 v 3 h 3 l 5,-5 h 3 L 15,7 V 4 L 12,7 H 11 L 9,5 V 4 L 12,1 H 9 L 6,4 v 3 z"></path></svg>
|
||||
<span>Retro tool mode</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="inventory"></div>
|
||||
</section>
|
||||
<div id="player-music">
|
||||
🎵 <a id="player-music-title" target="_blank">title</a> by <a id="player-music-author" target="_blank">author</a>
|
||||
<audio loop preload="auto">
|
||||
</div>
|
||||
</div>
|
||||
<!-- TODO debug panel?
|
||||
- current tic
|
||||
- list of actors, or currently pointed-to actor?
|
||||
<div class="debug">
|
||||
<button>« 4 tics</button>
|
||||
<button>« 1 tic</button>
|
||||
<button>1 tic »</button>
|
||||
<button>4 tics »</button>
|
||||
</div>
|
||||
-->
|
||||
<form id="player-debug">
|
||||
<h3>Time</h3>
|
||||
<table class="-time-controls">
|
||||
<tr>
|
||||
<td><button type="button" class="-time-button" data-dt="-1">← 1 tic</button></td>
|
||||
<td id="player-debug-time-tics">0</td>
|
||||
<td>tics</td>
|
||||
<td><button type="button" class="-time-button" data-dt="1">1 tic →</button></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><button type="button" class="-time-button" data-dt="-4">← 1 move</button></td>
|
||||
<td id="player-debug-time-moves">0</td>
|
||||
<td>moves</td>
|
||||
<td><button type="button" class="-time-button" data-dt="4">1 move →</button></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><button type="button" class="-time-button" data-dt="-20">← 1 s</button></td>
|
||||
<td id="player-debug-time-secs">0</td>
|
||||
<td>seconds</td>
|
||||
<td><button type="button" class="-time-button" data-dt="20">1 s →</button></td>
|
||||
</tr>
|
||||
</table>
|
||||
<div class="-buttons" id="player-debug-time-buttons">
|
||||
<!-- populated in js -->
|
||||
</div>
|
||||
<div id="player-debug-speed" class="radio-faux-button-set">
|
||||
<label><input type="radio" name="speed" value="1/4"><span class="-button">¼</span></label>
|
||||
<label><input type="radio" name="speed" value="1/3"><span class="-button">⅓</span></label>
|
||||
<label><input type="radio" name="speed" value="1/2"><span class="-button">½</span></label>
|
||||
<label><input type="radio" name="speed" value="1"><span class="-button"><strong>1×</strong></span></label>
|
||||
<label><input type="radio" name="speed" value="2"><span class="-button">2</span></label>
|
||||
<label><input type="radio" name="speed" value="3"><span class="-button">3</span></label>
|
||||
<label><input type="radio" name="speed" value="5"><span class="-button">5</span></label>
|
||||
<label><input type="radio" name="speed" value="10"><span class="-button">10</span></label>
|
||||
<label><input type="radio" name="speed" value="25"><span class="-button">25</span></label>
|
||||
<label><input type="radio" name="speed" value="100"><span class="-button">100</span></label>
|
||||
</div>
|
||||
|
||||
<h3>Inventory</h3>
|
||||
<div class="-inventory">
|
||||
<!-- populated in js -->
|
||||
</div>
|
||||
|
||||
<h3>Replay</h3>
|
||||
<!-- TODO...
|
||||
play back replay
|
||||
record replay, including altering it from here
|
||||
stop replay, without restarting the level
|
||||
show progress in %, length in tics + time
|
||||
browse replay? jump to any point? label points???
|
||||
edit manually?
|
||||
-->
|
||||
<div class="-replay-columns">
|
||||
<div id="player-debug-input"></div>
|
||||
<div class="-replay-status">
|
||||
<!-- This should be a fixed height and is always showing one of the following -->
|
||||
<div class="-none">No replay in progress</div>
|
||||
<div class="-playback">
|
||||
<progress max="0" value="0"></progress>
|
||||
<output>100%</output>
|
||||
<span>0 tics (0:00s)</span>
|
||||
<button disabled>Relinquish control</button>
|
||||
</div>
|
||||
<div class="-recording">Recording...</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- js inserts a bunch of stuff here -->
|
||||
|
||||
<h3>Misc</h3>
|
||||
<p>Viewport size:
|
||||
<select id="player-debug-viewport">
|
||||
<option value="default" selected>Standard</option>
|
||||
<option value="12">12 × 12</option>
|
||||
<option value="16">16 × 16</option>
|
||||
<option value="24">24 × 24</option>
|
||||
<option value="32">32 × 32</option>
|
||||
<option value="max">Entire level</option>
|
||||
</select>
|
||||
</p>
|
||||
<ul>
|
||||
<li><label><input type="checkbox" name="disable_interpolation"> Disable interpolation</label></li>
|
||||
<li><label><input type="checkbox" name="show_actor_bboxes"> Show actor bounding boxes</label></li>
|
||||
<li><label><input type="checkbox" name="show_actor_order"> Show actor order</label></li>
|
||||
<li><label><input type="checkbox" name="show_actor_tooltips"> Show actor tooltips</label></li>
|
||||
<!--
|
||||
<li><label><input type="checkbox" disabled> Freeze time for everything else</label></li>
|
||||
<li><label><input type="checkbox" disabled> Player is immortal</label></li>
|
||||
<li><label><input type="checkbox" disabled> Player ignores collision</label></li>
|
||||
<li><label><input type="checkbox" disabled> Player levitates</label></li>
|
||||
-->
|
||||
</ul>
|
||||
<div class="-buttons" id="player-debug-misc-buttons">
|
||||
<!-- populated in js -->
|
||||
</div>
|
||||
<p>Tip: Middle-click to teleport.</p>
|
||||
<!-- TODO?
|
||||
- inspect with mouse
|
||||
- list of actors, or currently pointed-to actor?
|
||||
- activate something manually?
|
||||
- click a button ingame?
|
||||
- pan viewport (like editor)
|
||||
- show connections, directions, other editor features
|
||||
- look under anything
|
||||
|
||||
- other game info?
|
||||
- count tiles?
|
||||
- total hearts?
|
||||
- total bonus flags?
|
||||
-->
|
||||
</form>
|
||||
</main>
|
||||
<main id="editor" hidden>
|
||||
<header>
|
||||
<!-- TODO
|
||||
- close
|
||||
- export
|
||||
- delete??
|
||||
|
||||
- zoom
|
||||
|
||||
also deal with levels vs level /packs/ somehow, not sure how that'll work (including downloading them, yeargh?)
|
||||
-->
|
||||
</header>
|
||||
<div class="level"><!-- level canvas and any overlays go here --></div>
|
||||
<div class="controls">
|
||||
<button id="editor-share-url" type="button">Share?</button>
|
||||
<!--
|
||||
<p style>
|
||||
Tip: Right click to color drop.<br>
|
||||
Tip: Ctrl-click with terrain to replace only the current tile's terrain, rather than overwriting the whole tile.
|
||||
</p>
|
||||
|
||||
<p>Layer: [all/auto] [terrain] [item] [actor] [overlay]</p>
|
||||
|
||||
<p>Actor direction: [north] [south] [east] [west]</p>
|
||||
|
||||
<p>[ ] Show connections</p>
|
||||
<p>[ ] Toggle green objects</p>
|
||||
<p>[ ] Show monster pathing</p>
|
||||
<p>[ ] Show circuits???</p>
|
||||
|
||||
<pre>
|
||||
Metadata:
|
||||
xxx / yyy chips required
|
||||
Time limit: [____]
|
||||
Title: [__________]
|
||||
Author: [__________]
|
||||
map size
|
||||
</pre>
|
||||
-->
|
||||
<header></header>
|
||||
<div class="editor-canvas">
|
||||
<div class="-container">
|
||||
<!-- level canvas and any overlays go here -->
|
||||
<!-- the container is to allow them to scroll as a single unit -->
|
||||
</div>
|
||||
</div>
|
||||
<nav class="controls"></nav>
|
||||
<div class="palette"></div>
|
||||
<!-- TODO:
|
||||
controls
|
||||
- play!
|
||||
- object palette
|
||||
- choose direction
|
||||
- choose layer to /modify/: terrain, item, creature, overlay
|
||||
- stack (place item atop whatever terrain), or replace (placing a tile overwrites the whole cell)
|
||||
[XXX mode that allows arbitrary stacking of objects?]
|
||||
- level metadata
|
||||
- change size
|
||||
|
||||
XXX how do i handle thin walls? treat specially, allow drawing/erasing them along edges instead of tiles? ehh then you can't control which tile they're in though... but the game seems to prefer south+east so maybe that works...
|
||||
|
||||
hotkeys
|
||||
- mod a tile on the board: rotate a creature, alter thin walls??
|
||||
- "pick up" a tile
|
||||
|
||||
cool stuff
|
||||
- set chip count by hand, set extra ones automatically
|
||||
-->
|
||||
<div id="editor-statusbar"></div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
230
js/algorithms.js
Normal file
@ -0,0 +1,230 @@
|
||||
import { DIRECTIONS, LAYERS } from './defs.js';
|
||||
|
||||
// Iterates over every terrain tile in the grid that has one of the given types (a Set of type
|
||||
// names), in linear order, optionally in reverse. The starting cell is checked last.
|
||||
// Yields [tile, cell].
|
||||
export function* find_terrain_linear(levelish, start_cell, type_names, reverse = false) {
|
||||
let i = levelish.coords_to_scalar(start_cell.x, start_cell.y);
|
||||
while (true) {
|
||||
if (reverse) {
|
||||
i -= 1;
|
||||
if (i < 0) {
|
||||
i += levelish.size_x * levelish.size_y;
|
||||
}
|
||||
}
|
||||
else {
|
||||
i += 1;
|
||||
i %= levelish.size_x * levelish.size_y;
|
||||
}
|
||||
|
||||
let cell = levelish.linear_cells[i];
|
||||
let tile = cell[LAYERS.terrain];
|
||||
if (tile && type_names.has(tile.type.name)) {
|
||||
yield [tile, cell];
|
||||
}
|
||||
|
||||
if (cell === start_cell)
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Iterates over every terrain tile in the grid that has one of the given types (a Set of type
|
||||
// names), spreading outward in a diamond pattern. The starting cell is not included.
|
||||
// Only used by orange buttons.
|
||||
// Yields [tile, cell].
|
||||
export function* find_terrain_diamond(levelish, start_cell, type_names) {
|
||||
// Note that this won't search the entire level in all cases, but it does match CC2 behavior.
|
||||
// Not worth a compat flag since it only affects level design, and fairly perversely
|
||||
let max_search_radius = Math.max(levelish.size_x, levelish.size_y) + 1;
|
||||
for (let dist = 1; dist <= max_search_radius; dist++) {
|
||||
// Start east and move counterclockwise
|
||||
let sx = start_cell.x + dist;
|
||||
let sy = start_cell.y;
|
||||
for (let direction of [[-1, -1], [-1, 1], [1, 1], [1, -1]]) {
|
||||
for (let i = 0; i < dist; i++) {
|
||||
let cell = levelish.cell(sx, sy);
|
||||
sx += direction[0];
|
||||
sy += direction[1];
|
||||
|
||||
if (! cell)
|
||||
continue;
|
||||
let terrain = cell[LAYERS.terrain];
|
||||
if (type_names.has(terrain.type.name)) {
|
||||
yield [terrain, cell];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const CONNECTION_FUNCTIONS = {
|
||||
forward: find_terrain_linear,
|
||||
diamond: find_terrain_diamond,
|
||||
};
|
||||
|
||||
|
||||
export class Circuit {
|
||||
constructor() {
|
||||
this.is_powered = null;
|
||||
this.tiles = new Map;
|
||||
this.inputs = new Map;
|
||||
}
|
||||
|
||||
add_tile_edge(tile, edgebits) {
|
||||
this.tiles.set(tile, (this.tiles.get(tile) ?? 0) | edgebits);
|
||||
}
|
||||
|
||||
add_input_edge(tile, edgebits) {
|
||||
this.inputs.set(tile, (this.inputs.get(tile) ?? 0) | edgebits);
|
||||
}
|
||||
}
|
||||
|
||||
// Traces a wire circuit and calls the given callbacks when finding either a new wire or an ending.
|
||||
// actor_mode describes how to handle circuit blocks:
|
||||
// - still: Actor wires are examined only for actors with a zero cooldown. (Normal behavior.)
|
||||
// - always: Actor wires are always examined. (compat.tiles_react_instantly behavior.)
|
||||
// - ignore: Skip actors entirely. (Editor behavior.)
|
||||
// Returns a Circuit.
|
||||
export function trace_floor_circuit(levelish, actor_mode, start_cell, start_edge, on_wire, on_dead_end) {
|
||||
let is_first = true;
|
||||
let pending = [[start_cell, start_edge]];
|
||||
let seen_cells = new Map;
|
||||
let circuit = new Circuit;
|
||||
while (pending.length > 0) {
|
||||
let next = [];
|
||||
for (let [cell, edge] of pending) {
|
||||
let terrain = cell.get_terrain();
|
||||
if (! terrain)
|
||||
continue;
|
||||
|
||||
let edgeinfo = DIRECTIONS[edge];
|
||||
let seen_edges = seen_cells.get(cell) ?? 0;
|
||||
if (seen_edges & edgeinfo.bit)
|
||||
continue;
|
||||
|
||||
let tile = terrain;
|
||||
let actor = cell.get_actor();
|
||||
if (actor && actor.type.contains_wire && (
|
||||
(actor_mode === 'still' && actor.movement_cooldown === 0) || actor_mode === 'always'))
|
||||
{
|
||||
tile = actor;
|
||||
}
|
||||
|
||||
// The wire comes in from this edge towards the center; see how it connects within this
|
||||
// cell, then check for any neighbors
|
||||
let connections = edgeinfo.bit;
|
||||
let mode = tile.wire_propagation_mode ?? tile.type.wire_propagation_mode;
|
||||
if (! is_first && ((tile.wire_directions ?? 0) & edgeinfo.bit) === 0) {
|
||||
// There's not actually a wire here, so check for things that respond to receiving
|
||||
// power... but if this is the starting cell, we trust the caller and skip it (XXX why)
|
||||
for (let tile2 of cell) {
|
||||
if (! tile2)
|
||||
continue;
|
||||
|
||||
if (tile2.type.name === 'logic_gate') {
|
||||
// Logic gates are technically not wired, but still attached to
|
||||
// circuits, mostly so blue teleporters can follow them
|
||||
let wire = tile2.type._gate_types[tile2.gate_type][
|
||||
(DIRECTIONS[edge].index - DIRECTIONS[tile2.direction].index + 4) % 4];
|
||||
if (! wire)
|
||||
continue;
|
||||
circuit.add_tile_edge(tile2, DIRECTIONS[edge].bit);
|
||||
if (wire.match(/^out/)) {
|
||||
circuit.add_input_edge(tile2, DIRECTIONS[edge].bit);
|
||||
}
|
||||
}
|
||||
else if (tile2.type.on_power) {
|
||||
circuit.add_tile_edge(tile2, DIRECTIONS[edge].bit);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
else if (mode === 'none') {
|
||||
// The wires in this tile never connect to each other
|
||||
}
|
||||
else if (mode === 'cross' || (mode === 'autocross' && tile.wire_directions === 0x0f)) {
|
||||
// This is a cross pattern, so only opposite edges connect
|
||||
if (tile.wire_directions & edgeinfo.opposite_bit) {
|
||||
connections |= edgeinfo.opposite_bit;
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Everything connects
|
||||
connections |= tile.wire_directions;
|
||||
}
|
||||
|
||||
seen_cells.set(cell, seen_edges | connections);
|
||||
|
||||
circuit.add_tile_edge(tile, connections);
|
||||
|
||||
if (tile.type.update_power_emission) {
|
||||
// TODO could just do this in a pass afterwards?
|
||||
circuit.add_input_edge(tile, connections);
|
||||
}
|
||||
|
||||
for (let [direction, dirinfo] of Object.entries(DIRECTIONS)) {
|
||||
// Obviously don't go backwards, but that doesn't apply if this is our first pass
|
||||
if (direction === edge && ! is_first)
|
||||
continue;
|
||||
|
||||
if ((connections & dirinfo.bit) === 0)
|
||||
continue;
|
||||
|
||||
let neighbor;
|
||||
if ((terrain.wire_tunnel_directions ?? 0) & dirinfo.bit) {
|
||||
// Search in this direction for a matching tunnel
|
||||
// Note that while actors (the fuckin circuit block) can be wired, tunnels ONLY
|
||||
// appear on terrain, and are NOT affected by actors on top
|
||||
neighbor = find_matching_wire_tunnel(levelish, cell.x, cell.y, direction);
|
||||
}
|
||||
else {
|
||||
neighbor = levelish.get_neighboring_cell(cell, direction);
|
||||
}
|
||||
|
||||
/*
|
||||
if (! neighbor || (((neighbor.get_terrain().wire_directions ?? 0) & dirinfo.opposite_bit) === 0)) {
|
||||
console.log("bailing here", neighbor, direction);
|
||||
continue;
|
||||
}
|
||||
*/
|
||||
if (! neighbor)
|
||||
continue;
|
||||
|
||||
next.push([neighbor, dirinfo.opposite]);
|
||||
}
|
||||
}
|
||||
pending = next;
|
||||
is_first = false;
|
||||
}
|
||||
|
||||
return circuit;
|
||||
}
|
||||
|
||||
export function find_matching_wire_tunnel(levelish, x, y, direction) {
|
||||
let dirinfo = DIRECTIONS[direction];
|
||||
let [dx, dy] = dirinfo.movement;
|
||||
let nesting = 0;
|
||||
while (true) {
|
||||
x += dx;
|
||||
y += dy;
|
||||
let candidate = levelish.cell(x, y);
|
||||
if (! candidate)
|
||||
return null;
|
||||
|
||||
let neighbor = candidate.get_terrain();
|
||||
if (! neighbor)
|
||||
continue;
|
||||
|
||||
if ((neighbor.wire_tunnel_directions ?? 0) & dirinfo.opposite_bit) {
|
||||
if (nesting === 0) {
|
||||
return candidate;
|
||||
}
|
||||
else {
|
||||
nesting -= 1;
|
||||
}
|
||||
}
|
||||
if ((neighbor.wire_tunnel_directions ?? 0) & dirinfo.bit) {
|
||||
nesting += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
297
js/defs.js
@ -4,33 +4,330 @@ export const DIRECTIONS = {
|
||||
north: {
|
||||
movement: [0, -1],
|
||||
bit: 0x01,
|
||||
opposite_bit: 0x04,
|
||||
index: 0,
|
||||
action: 'up',
|
||||
left: 'west',
|
||||
right: 'east',
|
||||
opposite: 'south',
|
||||
mirrored: 'north',
|
||||
flipped: 'south',
|
||||
},
|
||||
south: {
|
||||
movement: [0, 1],
|
||||
bit: 0x04,
|
||||
opposite_bit: 0x01,
|
||||
index: 2,
|
||||
action: 'down',
|
||||
left: 'east',
|
||||
right: 'west',
|
||||
opposite: 'north',
|
||||
mirrored: 'south',
|
||||
flipped: 'north',
|
||||
},
|
||||
west: {
|
||||
movement: [-1, 0],
|
||||
bit: 0x08,
|
||||
opposite_bit: 0x02,
|
||||
index: 3,
|
||||
action: 'left',
|
||||
left: 'south',
|
||||
right: 'north',
|
||||
opposite: 'east',
|
||||
mirrored: 'east',
|
||||
flipped: 'west',
|
||||
},
|
||||
east: {
|
||||
movement: [1, 0],
|
||||
bit: 0x02,
|
||||
opposite_bit: 0x08,
|
||||
index: 1,
|
||||
action: 'right',
|
||||
left: 'north',
|
||||
right: 'south',
|
||||
opposite: 'west',
|
||||
mirrored: 'west',
|
||||
flipped: 'east',
|
||||
},
|
||||
};
|
||||
// Should match the bit ordering above, and CC2's order
|
||||
export const DIRECTION_ORDER = ['north', 'east', 'south', 'west'];
|
||||
|
||||
export const INPUT_BITS = {
|
||||
drop: 0x01,
|
||||
down: 0x02,
|
||||
left: 0x04,
|
||||
right: 0x08,
|
||||
up: 0x10,
|
||||
swap: 0x20,
|
||||
cycle: 0x40,
|
||||
// Not real input; used to force advancement for turn-based mode
|
||||
wait: 0x8000,
|
||||
};
|
||||
|
||||
export const LAYERS = {
|
||||
terrain: 0,
|
||||
item: 1,
|
||||
item_mod: 2,
|
||||
actor: 3,
|
||||
vfx: 4,
|
||||
swivel: 5,
|
||||
thin_wall: 6,
|
||||
canopy: 7,
|
||||
|
||||
MAX: 8,
|
||||
};
|
||||
|
||||
export const COLLISION = {
|
||||
real_player1: 0x0001,
|
||||
real_player2: 0x0002,
|
||||
real_player: 0x0003,
|
||||
doppel1: 0x0004,
|
||||
doppel2: 0x0008,
|
||||
doppel: 0x000c,
|
||||
playerlike1: 0x0005,
|
||||
playerlike2: 0x000a,
|
||||
playerlike: 0x000f,
|
||||
|
||||
block_cc1: 0x0010,
|
||||
block_cc2: 0x0020, // ice + frame (+ circuit, etc)
|
||||
bowling_ball: 0x0040, // rolling ball, dynamite
|
||||
|
||||
// Monsters are a little complicated, because some of them have special rules, e.g. fireballs
|
||||
// aren't blocked by fire.
|
||||
// For a monster's MASK, you should use ONLY ONE of these specific monster bits (if
|
||||
// appropriate), OR the generic bit -- DO NOT combine them!
|
||||
monster_generic: 0x0100,
|
||||
fireball: 0x0200,
|
||||
bug: 0x0400,
|
||||
yellow_tank: 0x0800,
|
||||
rover: 0x1000,
|
||||
ghost: 0x8000,
|
||||
// For a tile's COLLISION, use one of these bit combinations
|
||||
monster_typical: 0x6f00, // everything but ghost and rover
|
||||
monster_any: 0xff00, // everything including ghost (only used for monster/fire compat flag)
|
||||
|
||||
// Combo masks used for matching
|
||||
all_but_ghost: 0xffff & ~0x8000,
|
||||
all_but_real_player: 0xffff & ~0x0003,
|
||||
all: 0xffff,
|
||||
};
|
||||
|
||||
// Item pickup priority, which both actors and items have. An actor will pick up an item if the
|
||||
// item's priority is greater than or equal to the actor's.
|
||||
export const PICKUP_PRIORITIES = {
|
||||
never: 4, // cc2 blocks, never pick anything up
|
||||
always: 3, // all actors; blue keys, yellow teleporters (everything picks up except cc2 blocks)
|
||||
// TODO is this even necessary? in cc2 the general rule seems to be that anything stepping on
|
||||
// an item picks it up, and collision is used to avoid that most of the time
|
||||
normal: 3, // actors with inventories; most items
|
||||
player: 1, // players and doppelgangers; red keys (ignored by everything else)
|
||||
real_player: 0,
|
||||
};
|
||||
|
||||
export const COMPAT_RULESET_LABELS = {
|
||||
lexy: "Lexy",
|
||||
steam: "Steam",
|
||||
'steam-strict': "Steam (strict)",
|
||||
lynx: "Lynx",
|
||||
ms: "Microsoft",
|
||||
custom: "Custom",
|
||||
};
|
||||
export const COMPAT_RULESET_ORDER = ['lexy', 'steam', 'steam-strict', 'lynx', 'ms', 'custom'];
|
||||
// FIXME some of the names of the flags themselves kinda suck
|
||||
// TODO some ms compat things that wouldn't be too hard to add:
|
||||
// - walkers choose a random /unblocked/ direction, not just a random direction
|
||||
// - (boosting) player cooldown is /zero/ after ending a slide
|
||||
// - cleats allow walking through ice corner walls while standing on them
|
||||
// - blocks can be pushed through thin walls + ice corners
|
||||
export const COMPAT_FLAG_CATEGORIES = [{
|
||||
title: "Level loading",
|
||||
flags: [{
|
||||
key: 'no_auto_convert_ccl_popwalls',
|
||||
label: "Recessed walls under actors are not auto-converted in CCL levels",
|
||||
rulesets: new Set(['steam-strict', 'lynx', 'ms']),
|
||||
}, {
|
||||
key: 'no_auto_convert_ccl_blue_walls',
|
||||
label: "Blue walls under blocks are not auto-converted in CCL levels",
|
||||
rulesets: new Set(['steam-strict', 'lynx', 'ms']),
|
||||
}, {
|
||||
key: 'no_auto_convert_ccl_bombs',
|
||||
label: "Mines under actors are not auto-converted in CCL levels",
|
||||
rulesets: new Set(['steam-strict', 'lynx', 'ms']),
|
||||
}],
|
||||
}, {
|
||||
title: "Actor behavior",
|
||||
flags: [{
|
||||
key: 'emulate_60fps',
|
||||
label: "Actors update at 60 FPS",
|
||||
rulesets: new Set(['steam', 'steam-strict']),
|
||||
}, {
|
||||
key: 'no_separate_idle_phase',
|
||||
label: "Actors teleport immediately after moving",
|
||||
rulesets: new Set(['steam', 'steam-strict']),
|
||||
}, {
|
||||
key: 'allow_double_cooldowns',
|
||||
label: "Actors may move forwards twice in one tic",
|
||||
rulesets: new Set(['steam', 'steam-strict', 'lynx']),
|
||||
}, {
|
||||
key: 'player_moves_last',
|
||||
label: "Players always update last",
|
||||
rulesets: new Set(['lynx']),
|
||||
}, {
|
||||
key: 'reuse_actor_slots',
|
||||
label: "New actors reuse slots in the actor list",
|
||||
rulesets: new Set(['lynx']),
|
||||
}, {
|
||||
key: 'player_protected_by_items',
|
||||
label: "Players can't be trampled while standing on items",
|
||||
rulesets: new Set(['lynx']),
|
||||
}, {
|
||||
key: 'force_lynx_animation_lengths',
|
||||
label: "Animations play at their slower Lynx duration",
|
||||
rulesets: new Set(['lynx']),
|
||||
}, {
|
||||
// Note that this requires no_early_push as well
|
||||
key: 'player_safe_at_decision_time',
|
||||
label: "Players can't be trampled at decision time",
|
||||
rulesets: new Set(['lynx', 'ms']),
|
||||
}, {
|
||||
key: 'bonking_isnt_instant',
|
||||
label: "Bonking while sliding doesn't apply instantly",
|
||||
rulesets: new Set(['lynx', 'ms']),
|
||||
}, {
|
||||
key: 'actors_move_instantly',
|
||||
label: "Movement is instant",
|
||||
rulesets: new Set(['ms']),
|
||||
}],
|
||||
}, {
|
||||
title: "Monsters",
|
||||
flags: [{
|
||||
// TODO ms needs "player doesn't block monsters", but tbh that's kind of how it should work
|
||||
// anyway, especially in combination with the ankh
|
||||
// TODO? in lynx they ignore the button while in motion too
|
||||
// TODO what about in a trap, in every game??
|
||||
// TODO what does ms do when a tank is on ice or a ff? wiki's description is wacky
|
||||
// TODO yellow tanks seem to have memory too??
|
||||
key: 'tanks_always_obey_button',
|
||||
label: "Blue tanks obey blue buttons even on clone machines",
|
||||
rulesets: new Set(['steam-strict']),
|
||||
}, {
|
||||
key: 'tanks_ignore_button_while_moving',
|
||||
label: "Blue tanks ignore blue buttons while moving",
|
||||
rulesets: new Set(['lynx']),
|
||||
}, {
|
||||
key: 'blobs_use_tw_prng',
|
||||
label: "Blobs use the Lynx RNG",
|
||||
rulesets: new Set(['lynx']),
|
||||
}, {
|
||||
key: 'teeth_target_internal_position',
|
||||
label: "Teeth pursue the cell the player is moving into",
|
||||
rulesets: new Set(['lynx']),
|
||||
}, {
|
||||
key: 'rff_blocks_monsters',
|
||||
label: "Monsters cannot step on random force floors",
|
||||
rulesets: new Set(['ms']),
|
||||
}, {
|
||||
key: 'fire_allows_most_monsters',
|
||||
label: "Monsters can walk into fire, except for bugs and walkers",
|
||||
rulesets: new Set(['ms']),
|
||||
}],
|
||||
}, {
|
||||
title: "Blocks",
|
||||
flags: [{
|
||||
key: 'use_legacy_hooking',
|
||||
label: "Pulling blocks with the hook happens earlier, and may prevent moving",
|
||||
rulesets: new Set(['steam', 'steam-strict']),
|
||||
}, {
|
||||
key: 'no_directly_pushing_sliding_blocks',
|
||||
label: "Pushing sliding blocks queues a move, rather than moving them right away",
|
||||
rulesets: new Set(['steam', 'steam-strict']),
|
||||
}, {
|
||||
key: 'emulate_spring_mining',
|
||||
label: "Pushing a block off a recessed wall may cause you to move into the resulting wall",
|
||||
rulesets: new Set(['steam-strict']),
|
||||
}, {
|
||||
key: 'no_early_push',
|
||||
label: "Pushing blocks happens at move time (block slapping is disabled)",
|
||||
// XXX wait but the DEFAULT behavior allows block slapping, which lynx has, so why is lynx listed here?
|
||||
rulesets: new Set(['lynx', 'ms']),
|
||||
}, {
|
||||
key: 'use_pgchip_ice_blocks',
|
||||
label: "Ice blocks use pgchip rules",
|
||||
rulesets: new Set(['ms']),
|
||||
}, {
|
||||
key: 'allow_pushing_blocks_off_faux_walls',
|
||||
label: "Blocks may be pushed off of blue (fake), invisible, and revealing walls",
|
||||
rulesets: new Set(['lynx']),
|
||||
}, {
|
||||
key: 'block_splashes_dont_block',
|
||||
label: "Block splashes don't block the player",
|
||||
rulesets: new Set(['ms']),
|
||||
/* XXX not implemented
|
||||
}, {
|
||||
key: 'emulate_flicking',
|
||||
label: "Flicking is possible",
|
||||
rulesets: new Set(['ms']),
|
||||
*/
|
||||
}],
|
||||
}, {
|
||||
title: "Terrain",
|
||||
flags: [{
|
||||
key: 'green_teleports_can_fail',
|
||||
label: "Green teleporters sometimes fail",
|
||||
rulesets: new Set(['steam-strict']),
|
||||
}, {
|
||||
key: 'no_backwards_override',
|
||||
label: "Players can't override backwards on a force floor",
|
||||
rulesets: new Set(['lynx']),
|
||||
}, {
|
||||
key: 'traps_like_lynx',
|
||||
label: "Traps eject faster, and eject when already open",
|
||||
rulesets: new Set(['lynx']),
|
||||
}, {
|
||||
key: 'blue_floors_vanish_on_arrive',
|
||||
label: "Fake blue walls vanish when stepped on",
|
||||
rulesets: new Set(['lynx']),
|
||||
}, {
|
||||
key: 'popwalls_pop_on_arrive',
|
||||
label: "Recessed walls activate when stepped on",
|
||||
rulesets: new Set(['lynx', 'ms']),
|
||||
}, {
|
||||
key: 'rff_actually_random',
|
||||
label: "Random force floors are actually random",
|
||||
rulesets: new Set(['ms']),
|
||||
}],
|
||||
}, {
|
||||
title: "Items",
|
||||
flags: [{
|
||||
key: 'cloned_bowling_balls_can_be_lost',
|
||||
label: "Bowling balls on cloners are destroyed when fired at point blank",
|
||||
rulesets: new Set(['steam-strict']),
|
||||
}, {
|
||||
// XXX is this necessary, with the addition of the dormant bomb?
|
||||
key: 'bombs_immediately_detonate_under_players',
|
||||
label: "Mines under players detonate when the level starts",
|
||||
rulesets: new Set(['steam-strict']),
|
||||
}, {
|
||||
key: 'bombs_detonate_on_arrive',
|
||||
label: "Mines detonate only when stepped on",
|
||||
rulesets: new Set(['lynx', 'ms']),
|
||||
}, {
|
||||
key: 'monsters_ignore_keys',
|
||||
label: "Monsters completely ignore keys",
|
||||
rulesets: new Set(['ms']),
|
||||
}],
|
||||
}];
|
||||
|
||||
|
||||
export function compat_flags_for_ruleset(ruleset) {
|
||||
let compat = {};
|
||||
for (let category of COMPAT_FLAG_CATEGORIES) {
|
||||
for (let compatdef of category.flags) {
|
||||
if (compatdef.rulesets.has(ruleset)) {
|
||||
compat[compatdef.key] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return compat;
|
||||
}
|
||||
|
||||
547
js/editor/dialogs.js
Normal file
@ -0,0 +1,547 @@
|
||||
import * as c2g from '../format-c2g.js';
|
||||
import { DialogOverlay, AlertOverlay, flash_button } from '../main-base.js';
|
||||
import CanvasRenderer from '../renderer-canvas.js';
|
||||
import { mk, mk_button } from '../util.js';
|
||||
import * as util from '../util.js';
|
||||
|
||||
export class EditorPackMetaOverlay extends DialogOverlay {
|
||||
constructor(conductor, stored_pack) {
|
||||
super(conductor);
|
||||
this.set_title("pack properties");
|
||||
let dl = mk('dl.formgrid');
|
||||
this.main.append(dl);
|
||||
|
||||
dl.append(
|
||||
mk('dt', "Title"),
|
||||
mk('dd', mk('input', {name: 'title', type: 'text', value: stored_pack.title})),
|
||||
);
|
||||
// TODO...? what else is a property of the pack itself
|
||||
|
||||
this.add_button("save", () => {
|
||||
let els = this.root.elements;
|
||||
|
||||
let title = els.title.value;
|
||||
if (title !== stored_pack.title) {
|
||||
stored_pack.title = title;
|
||||
this.conductor.update_level_title();
|
||||
}
|
||||
|
||||
this.close();
|
||||
});
|
||||
this.add_button("nevermind", () => {
|
||||
this.close();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class EditorLevelMetaOverlay extends DialogOverlay {
|
||||
constructor(conductor, stored_level) {
|
||||
super(conductor);
|
||||
this.set_title("level properties");
|
||||
let dl = mk('dl.formgrid');
|
||||
this.main.append(dl);
|
||||
|
||||
let time_limit_input = mk('input', {name: 'time_limit', type: 'number', min: 0, max: 65535, value: stored_level.time_limit});
|
||||
let time_limit_output = mk('output');
|
||||
let update_time_limit = () => {
|
||||
let time_limit = parseInt(time_limit_input.value, 10);
|
||||
// FIXME need a change event for this tbh?
|
||||
// FIXME handle NaN; maybe block keydown of not-numbers
|
||||
time_limit = Math.max(0, Math.min(65535, time_limit));
|
||||
time_limit_input.value = time_limit;
|
||||
|
||||
let text;
|
||||
if (time_limit === 0) {
|
||||
text = "No time limit";
|
||||
}
|
||||
else {
|
||||
text = util.format_duration(time_limit);
|
||||
}
|
||||
time_limit_output.textContent = text;
|
||||
};
|
||||
update_time_limit();
|
||||
time_limit_input.addEventListener('input', update_time_limit);
|
||||
|
||||
let make_size_input = (name) => {
|
||||
let input = mk('input', {name: name, type: 'number', min: 10, max: 100, value: stored_level[name]});
|
||||
// TODO maybe block keydown of non-numbers too?
|
||||
// Note that this is a change event, not an input event, so we don't prevent them from
|
||||
// erasing the whole value to type a new one
|
||||
input.addEventListener('change', ev => {
|
||||
let value = parseInt(ev.target.value, 10);
|
||||
if (isNaN(value)) {
|
||||
ev.target.value = stored_level[name];
|
||||
}
|
||||
else if (value < 1) {
|
||||
// Smaller than 10×10 isn't supported by CC2, but LL doesn't mind, so let it
|
||||
// through if they try it manually
|
||||
ev.target.value = 1;
|
||||
}
|
||||
else if (value > 100) {
|
||||
ev.target.value = 100;
|
||||
}
|
||||
});
|
||||
return input;
|
||||
};
|
||||
|
||||
dl.append(
|
||||
mk('dt', "Title"),
|
||||
mk('dd.-one-field', mk('input', {name: 'title', type: 'text', value: stored_level.title})),
|
||||
mk('dt', "Author"),
|
||||
mk('dd.-one-field', mk('input', {name: 'author', type: 'text', value: stored_level.author})),
|
||||
mk('dt', "Comment"),
|
||||
mk('dd.-textarea', mk('textarea', {name: 'comment', rows: 4, cols: 20}, stored_level.comment)),
|
||||
mk('dt', "Time limit"),
|
||||
mk('dd.-with-buttons',
|
||||
mk('div.-left',
|
||||
time_limit_input,
|
||||
" ",
|
||||
time_limit_output,
|
||||
),
|
||||
mk('div.-right',
|
||||
mk_button("None", () => {
|
||||
this.root.elements['time_limit'].value = 0;
|
||||
update_time_limit();
|
||||
}),
|
||||
mk_button("−30s", () => {
|
||||
this.root.elements['time_limit'].value = Math.max(0,
|
||||
parseInt(this.root.elements['time_limit'].value, 10) - 30);
|
||||
update_time_limit();
|
||||
}),
|
||||
mk_button("+30s", () => {
|
||||
this.root.elements['time_limit'].value = Math.min(999,
|
||||
parseInt(this.root.elements['time_limit'].value, 10) + 30);
|
||||
update_time_limit();
|
||||
}),
|
||||
mk_button("Max", () => {
|
||||
this.root.elements['time_limit'].value = 999;
|
||||
update_time_limit();
|
||||
}),
|
||||
),
|
||||
),
|
||||
mk('dt', "Size"),
|
||||
mk('dd.-with-buttons',
|
||||
mk('div.-left', make_size_input('size_x'), " × ", make_size_input('size_y')),
|
||||
mk('div.-right', ...[10, 32, 50, 100].map(size =>
|
||||
mk_button(`${size}²`, () => {
|
||||
this.root.elements['size_x'].value = size;
|
||||
this.root.elements['size_y'].value = size;
|
||||
}),
|
||||
)),
|
||||
),
|
||||
mk('dt', "Viewport"),
|
||||
mk('dd',
|
||||
mk('label',
|
||||
mk('input', {name: 'viewport', type: 'radio', value: '10'}),
|
||||
" 10×10 (Chip's Challenge 2 size)"),
|
||||
mk('br'),
|
||||
mk('label',
|
||||
mk('input', {name: 'viewport', type: 'radio', value: '9'}),
|
||||
" 9×9 (Chip's Challenge 1 size)"),
|
||||
mk('br'),
|
||||
mk('label',
|
||||
mk('input', {name: 'viewport', type: 'radio', value: '', disabled: 'disabled'}),
|
||||
" Split 10×10 (not yet supported)"),
|
||||
),
|
||||
mk('dt', "Blob behavior"),
|
||||
mk('dd',
|
||||
mk('label',
|
||||
mk('input', {name: 'blob_behavior', type: 'radio', value: '0'}),
|
||||
" Deterministic (PRNG + simple convolution)"),
|
||||
mk('br'),
|
||||
mk('label',
|
||||
mk('input', {name: 'blob_behavior', type: 'radio', value: '1'}),
|
||||
" 4 patterns (CC2 default; PRNG + rotating offset)"),
|
||||
mk('br'),
|
||||
mk('label',
|
||||
mk('input', {name: 'blob_behavior', type: 'radio', value: '2'}),
|
||||
" Extra random (LL default; initial seed is truly random)"),
|
||||
),
|
||||
mk('dt', "Options"),
|
||||
mk('dd', mk('label',
|
||||
mk('input', {name: 'hide_logic', type: 'checkbox'}),
|
||||
" Hide wires and logic gates (warning: CC2 also hides pink/black buttons!)")),
|
||||
mk('dd', mk('label',
|
||||
mk('input', {name: 'use_cc1_boots', type: 'checkbox'}),
|
||||
" Use CC1-style inventory (can only pick up the four classic boots; can't drop or cycle)")),
|
||||
);
|
||||
this.root.elements['viewport'].value = stored_level.viewport_size;
|
||||
this.root.elements['blob_behavior'].value = stored_level.blob_behavior;
|
||||
this.root.elements['hide_logic'].checked = stored_level.hide_logic;
|
||||
this.root.elements['use_cc1_boots'].checked = stored_level.use_cc1_boots;
|
||||
// TODO:
|
||||
// - chips?
|
||||
// - password???
|
||||
// - comment
|
||||
// - use CC1 tools
|
||||
// - hide logic
|
||||
// - "unviewable", "read only"
|
||||
|
||||
this.add_button("save", () => {
|
||||
let els = this.root.elements;
|
||||
|
||||
let title = els.title.value;
|
||||
if (title !== stored_level.title) {
|
||||
stored_level.title = title;
|
||||
this.conductor.stored_game.level_metadata[this.conductor.level_index].title = title;
|
||||
this.conductor.update_level_title();
|
||||
}
|
||||
let author = els.author.value;
|
||||
if (author !== stored_level.author) {
|
||||
stored_level.author = author;
|
||||
}
|
||||
|
||||
// FIXME gotta deal with NaNs here too, sigh, might just need a teeny tiny form library
|
||||
stored_level.time_limit = Math.max(0, Math.min(65535, parseInt(els.time_limit.value, 10)));
|
||||
|
||||
let size_x = Math.max(1, Math.min(100, parseInt(els.size_x.value, 10)));
|
||||
let size_y = Math.max(1, Math.min(100, parseInt(els.size_y.value, 10)));
|
||||
if (size_x !== stored_level.size_x || size_y !== stored_level.size_y) {
|
||||
this.conductor.editor.crop_level(0, 0, size_x, size_y);
|
||||
}
|
||||
|
||||
stored_level.blob_behavior = parseInt(els.blob_behavior.value, 10);
|
||||
stored_level.hide_logic = els.hide_logic.checked;
|
||||
stored_level.use_cc1_boots = els.use_cc1_boots.checked;
|
||||
let viewport_size = parseInt(els.viewport.value, 10);
|
||||
if (viewport_size !== 9 && viewport_size !== 10) {
|
||||
viewport_size = 10;
|
||||
}
|
||||
stored_level.viewport_size = viewport_size;
|
||||
this.conductor.player.update_viewport_size();
|
||||
|
||||
this.close();
|
||||
});
|
||||
this.add_button("nevermind", () => {
|
||||
this.close();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// List of levels, used in the player
|
||||
export class EditorLevelBrowserOverlay extends DialogOverlay {
|
||||
constructor(conductor) {
|
||||
super(conductor);
|
||||
this.set_title("choose a level");
|
||||
|
||||
// Set up some infrastructure to lazily display level renders
|
||||
// FIXME should this use the tileset appropriate for the particular level?
|
||||
this.renderer = new CanvasRenderer(this.conductor.tilesets['ll'], 32);
|
||||
this.awaiting_renders = [];
|
||||
this.observer = new IntersectionObserver((entries, _observer) => {
|
||||
let any_new = false;
|
||||
let to_remove = new Set;
|
||||
for (let entry of entries) {
|
||||
if (entry.target.classList.contains('--rendered'))
|
||||
continue;
|
||||
|
||||
let index = this._get_index(entry.target);
|
||||
if (entry.isIntersecting) {
|
||||
this.awaiting_renders.push(index);
|
||||
any_new = true;
|
||||
}
|
||||
else {
|
||||
to_remove.add(index);
|
||||
}
|
||||
}
|
||||
|
||||
this.awaiting_renders = this.awaiting_renders.filter(index => ! to_remove.has(index));
|
||||
if (any_new) {
|
||||
this.schedule_level_render();
|
||||
}
|
||||
},
|
||||
{ root: this.main },
|
||||
);
|
||||
this.list = mk('ol.editor-level-browser');
|
||||
this.selection = this.conductor.level_index;
|
||||
for (let [i, meta] of conductor.stored_game.level_metadata.entries()) {
|
||||
this.list.append(this._make_list_item(i, meta));
|
||||
}
|
||||
this.list.childNodes[this.selection].classList.add('--selected');
|
||||
this.main.append(
|
||||
mk('p', "Drag to rearrange. Changes are immediate!"),
|
||||
this.list,
|
||||
);
|
||||
|
||||
this.list.addEventListener('click', ev => {
|
||||
let index = this._get_index(ev.target);
|
||||
if (index === null)
|
||||
return;
|
||||
this._select(index);
|
||||
});
|
||||
this.list.addEventListener('dblclick', ev => {
|
||||
let index = this._get_index(ev.target);
|
||||
if (index !== null && this.conductor.change_level(index)) {
|
||||
this.close();
|
||||
}
|
||||
});
|
||||
|
||||
this.sortable = new Sortable(this.list, {
|
||||
group: 'editor-levels',
|
||||
onEnd: ev => {
|
||||
if (ev.oldIndex === ev.newIndex)
|
||||
return;
|
||||
|
||||
this._move_level(ev.oldIndex, ev.newIndex);
|
||||
|
||||
this.undo_stack.push(() => {
|
||||
this.list.insertBefore(
|
||||
this.list.childNodes[ev.newIndex],
|
||||
this.list.childNodes[ev.oldIndex + (ev.oldIndex < ev.newIndex ? 0 : 1)]);
|
||||
this._move_level(ev.newIndex, ev.oldIndex);
|
||||
});
|
||||
this.undo_button.disabled = false;
|
||||
},
|
||||
});
|
||||
|
||||
// FIXME ring buffer?
|
||||
this.undo_stack = [];
|
||||
|
||||
// Left buttons
|
||||
this.undo_button = this.add_button("undo", () => {
|
||||
if (! this.undo_stack.length)
|
||||
return;
|
||||
|
||||
let undo = this.undo_stack.pop();
|
||||
undo();
|
||||
this.undo_button.disabled = ! this.undo_stack.length;
|
||||
});
|
||||
this.undo_button.disabled = true;
|
||||
this.add_button("create", () => {
|
||||
let index = this.selection + 1;
|
||||
let stored_level = this.conductor.editor._make_empty_level(index + 1, 32, 32);
|
||||
this.conductor.editor.move_level(stored_level, index);
|
||||
this._after_insert_level(stored_level, index);
|
||||
|
||||
this.undo_stack.push(() => {
|
||||
this._delete_level(index);
|
||||
});
|
||||
this.undo_button.disabled = false;
|
||||
});
|
||||
this.add_button("duplicate", () => {
|
||||
let index = this.selection + 1;
|
||||
let stored_level = this.conductor.editor.duplicate_level(this.selection);
|
||||
this._after_insert_level(stored_level, index);
|
||||
|
||||
this.undo_stack.push(() => {
|
||||
this._delete_level(index);
|
||||
});
|
||||
this.undo_button.disabled = false;
|
||||
});
|
||||
this.delete_button = this.add_button("delete", () => {
|
||||
let index = this.selection;
|
||||
if (index === this.conductor.level_index) {
|
||||
new AlertOverlay(this.conductor, "You can't delete the level you have open.").open();
|
||||
return;
|
||||
}
|
||||
|
||||
// Snag a copy of the serialized level for undo purposes
|
||||
// FIXME can't undo deleting a corrupt level
|
||||
let meta = this.conductor.stored_game.level_metadata[index];
|
||||
let serialized_level = window.localStorage.getItem(meta.key);
|
||||
|
||||
this._delete_level(index);
|
||||
|
||||
this.undo_stack.push(() => {
|
||||
let stored_level = meta.stored_level ?? c2g.parse_level(
|
||||
util.bytestring_to_buffer(serialized_level), index + 1);
|
||||
this.conductor.editor.move_level(stored_level, index);
|
||||
if (this.selection >= index) {
|
||||
this.selection += 1;
|
||||
}
|
||||
this._after_insert_level(stored_level, index);
|
||||
});
|
||||
this.undo_button.disabled = false;
|
||||
});
|
||||
this._update_delete_button();
|
||||
|
||||
// Right buttons
|
||||
this.add_button_gap();
|
||||
this.add_button("open", () => {
|
||||
if (this.selection === this.conductor.level_index || this.conductor.change_level(this.selection)) {
|
||||
this.close();
|
||||
}
|
||||
});
|
||||
this.add_button("nevermind", () => {
|
||||
this.close();
|
||||
});
|
||||
}
|
||||
|
||||
_make_list_item(index, meta) {
|
||||
let li = mk('li',
|
||||
{'data-index': index},
|
||||
mk('div.-preview'),
|
||||
mk('div.-number', {}, meta.number),
|
||||
mk('div.-title', {}, meta.error ? "(error!)" : meta.title),
|
||||
);
|
||||
|
||||
if (meta.error) {
|
||||
li.classList.add('--error');
|
||||
}
|
||||
else {
|
||||
this.observer.observe(li);
|
||||
}
|
||||
|
||||
return li;
|
||||
}
|
||||
|
||||
renumber_levels(start_index, end_index = null) {
|
||||
end_index = end_index ?? this.conductor.stored_game.level_metadata.length - 1;
|
||||
for (let i = start_index; i <= end_index; i++) {
|
||||
let li = this.list.childNodes[i];
|
||||
let meta = this.conductor.stored_game.level_metadata[i];
|
||||
li.setAttribute('data-index', i);
|
||||
li.querySelector('.-number').textContent = meta.number;
|
||||
}
|
||||
}
|
||||
|
||||
_get_index(element) {
|
||||
let li = element.closest('li');
|
||||
if (! li)
|
||||
return null;
|
||||
|
||||
return parseInt(li.getAttribute('data-index'), 10);
|
||||
}
|
||||
|
||||
_select(index) {
|
||||
this.list.childNodes[this.selection].classList.remove('--selected');
|
||||
this.selection = index;
|
||||
this.list.childNodes[this.selection].classList.add('--selected');
|
||||
this._update_delete_button();
|
||||
}
|
||||
|
||||
_update_delete_button() {
|
||||
this.delete_button.disabled = !! (this.selection === this.conductor.level_index);
|
||||
}
|
||||
|
||||
schedule_level_render() {
|
||||
if (this._handle)
|
||||
return;
|
||||
this._handle = setTimeout(() => { this.render_level() }, 50);
|
||||
}
|
||||
|
||||
render_level() {
|
||||
this._handle = null;
|
||||
|
||||
let t0 = performance.now();
|
||||
while (true) {
|
||||
if (this.awaiting_renders.length === 0)
|
||||
return;
|
||||
|
||||
let index = this.awaiting_renders.shift();
|
||||
let element = this.list.childNodes[index];
|
||||
// FIXME levels may have been renumbered since this was queued, whoops
|
||||
let stored_level = this.conductor.stored_game.load_level(index);
|
||||
this.renderer.set_level(stored_level);
|
||||
this.renderer.set_viewport_size(stored_level.size_x, stored_level.size_y);
|
||||
this.renderer.draw_static_region(0, 0, stored_level.size_x, stored_level.size_y);
|
||||
let canvas = mk('canvas', {
|
||||
width: stored_level.size_x * this.renderer.tileset.size_x / 4,
|
||||
height: stored_level.size_y * this.renderer.tileset.size_y / 4,
|
||||
});
|
||||
canvas.getContext('2d').drawImage(this.renderer.canvas, 0, 0, canvas.width, canvas.height);
|
||||
element.querySelector('.-preview').append(canvas);
|
||||
element.classList.add('--rendered');
|
||||
|
||||
if (performance.now() - t0 > 10)
|
||||
break;
|
||||
}
|
||||
|
||||
this.schedule_level_render();
|
||||
}
|
||||
|
||||
expire(index) {
|
||||
let li = this.list.childNodes[index];
|
||||
li.classList.remove('--rendered');
|
||||
li.querySelector('.-preview').textContent = '';
|
||||
}
|
||||
|
||||
_after_insert_level(stored_level, index) {
|
||||
this.list.insertBefore(
|
||||
this._make_list_item(index, this.conductor.stored_game.level_metadata[index]),
|
||||
this.list.childNodes[index]);
|
||||
this._select(index);
|
||||
this.renumber_levels(index + 1);
|
||||
}
|
||||
|
||||
_delete_level(index) {
|
||||
let num_levels = this.conductor.stored_game.level_metadata.length;
|
||||
this.conductor.editor.move_level(index, null);
|
||||
|
||||
this.list.childNodes[this.selection].classList.remove('--selected');
|
||||
this.list.childNodes[index].remove();
|
||||
if (index === num_levels - 1) {
|
||||
this.selection -= 1;
|
||||
}
|
||||
else {
|
||||
this.renumber_levels(index);
|
||||
}
|
||||
this.list.childNodes[this.selection].classList.add('--selected');
|
||||
}
|
||||
|
||||
_move_level(from_index, to_index) {
|
||||
this.conductor.editor.move_level(from_index, to_index);
|
||||
|
||||
let selection = this.selection;
|
||||
if (from_index < to_index) {
|
||||
this.renumber_levels(from_index, to_index);
|
||||
if (from_index < selection && selection <= to_index) {
|
||||
selection -= 1;
|
||||
}
|
||||
}
|
||||
else {
|
||||
this.renumber_levels(to_index, from_index);
|
||||
if (to_index <= selection && selection < from_index) {
|
||||
selection += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.selection === from_index) {
|
||||
this.selection = to_index;
|
||||
}
|
||||
else {
|
||||
this.selection = selection;
|
||||
}
|
||||
this._update_delete_button();
|
||||
}
|
||||
}
|
||||
|
||||
export class EditorShareOverlay extends DialogOverlay {
|
||||
constructor(conductor, url) {
|
||||
super(conductor);
|
||||
this.set_title("give this to friends");
|
||||
this.main.append(mk('p', "Give this URL out to let others try your level:"));
|
||||
this.main.append(mk('p.editor-share-url', {}, url));
|
||||
let copy_button = mk('button', {type: 'button'}, "Copy to clipboard");
|
||||
copy_button.addEventListener('click', ev => {
|
||||
flash_button(ev.target);
|
||||
navigator.clipboard.writeText(url);
|
||||
});
|
||||
this.main.append(copy_button);
|
||||
|
||||
let ok = mk('button', {type: 'button'}, "neato");
|
||||
ok.addEventListener('click', () => {
|
||||
this.close();
|
||||
});
|
||||
this.footer.append(ok);
|
||||
}
|
||||
}
|
||||
|
||||
export class EditorExportFailedOverlay extends DialogOverlay {
|
||||
constructor(conductor, errors, _warnings) {
|
||||
// TODO support warnings i guess
|
||||
super(conductor);
|
||||
this.set_title("export didn't go so well");
|
||||
this.main.append(mk('p', "Whoops! I tried very hard to export your level, but it didn't work out. Sorry."));
|
||||
let ul = mk('ul.editor-export-errors');
|
||||
// TODO structure the errors better and give them names out here, also reduce duplication,
|
||||
// also be clear about which are recoverable or not
|
||||
for (let error of errors) {
|
||||
ul.append(mk('li', error));
|
||||
}
|
||||
this.main.append(ul);
|
||||
this.add_button("oh well", () => {
|
||||
this.close();
|
||||
});
|
||||
}
|
||||
}
|
||||
1589
js/editor/editordefs.js
Normal file
656
js/editor/helpers.js
Normal file
@ -0,0 +1,656 @@
|
||||
// Small helper classes used by the editor, often with their own UI for the SVG overlay.
|
||||
import { DIRECTIONS } from '../defs.js';
|
||||
import { BitVector, mk, mk_svg } from '../util.js';
|
||||
|
||||
export class SVGConnection {
|
||||
constructor(sx, sy, dx, dy) {
|
||||
this.source = mk_svg('circle.-source', {r: 0.5, cx: sx + 0.5, cy: sy + 0.5});
|
||||
this.line = mk_svg('line.-arrow', {});
|
||||
this.dest = mk_svg('rect.-dest', {x: dx, y: dy, width: 1, height: 1});
|
||||
this.element = mk_svg('g.overlay-connection', this.source, this.line, this.dest);
|
||||
this.sx = sx;
|
||||
this.sy = sy;
|
||||
this.dx = dx;
|
||||
this.dy = dy;
|
||||
this._update_line_endpoints();
|
||||
}
|
||||
|
||||
set_source(sx, sy) {
|
||||
this.sx = sx;
|
||||
this.sy = sy;
|
||||
this.source.setAttribute('cx', sx + 0.5);
|
||||
this.source.setAttribute('cy', sy + 0.5);
|
||||
this._update_line_endpoints();
|
||||
}
|
||||
|
||||
set_dest(dx, dy) {
|
||||
this.dx = dx;
|
||||
this.dy = dy;
|
||||
this.dest.setAttribute('x', dx);
|
||||
this.dest.setAttribute('y', dy);
|
||||
this._update_line_endpoints();
|
||||
}
|
||||
|
||||
_update_line_endpoints() {
|
||||
// Start the line at the edge of the circle, so, add 0.5 in the direction of the line
|
||||
let vx = this.dx - this.sx;
|
||||
let vy = this.dy - this.sy;
|
||||
let line_length = Math.sqrt(vx*vx + vy*vy);
|
||||
let trim_x = 0;
|
||||
let trim_y = 0;
|
||||
if (line_length >= 1) {
|
||||
trim_x = 0.5 * vx / line_length;
|
||||
trim_y = 0.5 * vy / line_length;
|
||||
}
|
||||
this.line.setAttribute('x1', this.sx + 0.5 + trim_x);
|
||||
this.line.setAttribute('y1', this.sy + 0.5 + trim_y);
|
||||
// Technically this isn't quite right, since the ending is a square and the arrowhead will
|
||||
// poke into it a bit from angles near 45°, but that requires a bit more trig than seems
|
||||
// worth it, and it looks kinda neat anyway.
|
||||
// Also, one nicety: if the cells are adjacent, don't trim the endpoint, or we won't have
|
||||
// an arrow at all.
|
||||
if (line_length < 2) {
|
||||
this.line.setAttribute('x2', this.dx + 0.5);
|
||||
this.line.setAttribute('y2', this.dy + 0.5);
|
||||
}
|
||||
else {
|
||||
this.line.setAttribute('x2', this.dx + 0.5 - trim_x);
|
||||
this.line.setAttribute('y2', this.dy + 0.5 - trim_y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export class PendingRectangularSelection {
|
||||
constructor(owner, mode) {
|
||||
this.owner = owner;
|
||||
this.mode = mode ?? 'new'; // new, add, subtract
|
||||
this.element = mk_svg('rect.overlay-pending-selection');
|
||||
this.size_text = mk_svg('text.overlay-edit-tip');
|
||||
this.owner.svg_group.append(this.element, this.size_text);
|
||||
this.rect = null;
|
||||
}
|
||||
|
||||
set_extrema(x0, y0, x1, y1) {
|
||||
this.rect = new DOMRect(Math.min(x0, x1), Math.min(y0, y1), Math.abs(x0 - x1) + 1, Math.abs(y0 - y1) + 1);
|
||||
this.element.classList.add('--visible');
|
||||
this.element.setAttribute('x', this.rect.x);
|
||||
this.element.setAttribute('y', this.rect.y);
|
||||
this.element.setAttribute('width', this.rect.width);
|
||||
this.element.setAttribute('height', this.rect.height);
|
||||
this.size_text.textContent = `${this.rect.width} × ${this.rect.height}`;
|
||||
this.size_text.setAttribute('x', this.rect.x + this.rect.width / 2);
|
||||
this.size_text.setAttribute('y', this.rect.y + this.rect.height / 2);
|
||||
}
|
||||
|
||||
commit() {
|
||||
if (this.mode === 'new') {
|
||||
this.owner.clear();
|
||||
this.owner.add_rect(this.rect);
|
||||
}
|
||||
else if (this.mode === 'add') {
|
||||
this.owner.add_rect(this.rect);
|
||||
}
|
||||
else if (this.mode === 'subtract') {
|
||||
this.owner.subtract_rect(this.rect);
|
||||
}
|
||||
this.element.remove();
|
||||
this.size_text.remove();
|
||||
}
|
||||
|
||||
discard() {
|
||||
this.element.remove();
|
||||
this.size_text.remove();
|
||||
}
|
||||
}
|
||||
|
||||
export class Selection {
|
||||
constructor(editor) {
|
||||
this.editor = editor;
|
||||
|
||||
this.svg_group = mk_svg('g');
|
||||
this.editor.svg_overlay.append(this.svg_group);
|
||||
// Used for the floating preview and selection rings, which should all move together
|
||||
this.selection_group = mk_svg('g');
|
||||
this.svg_group.append(this.selection_group);
|
||||
|
||||
// Note that this is a set of the ORIGINAL coordinates of the selected cells. Moving a
|
||||
// floated selection doesn't change this; instead it updates floated_offset
|
||||
this.cells = new Set;
|
||||
this.bbox = null;
|
||||
// I want a black-and-white outline ring so it shows against any background, but the only
|
||||
// way to do that in SVG is apparently to just duplicate the path
|
||||
this.ring_bg_element = mk_svg('path.overlay-selection-background.overlay-transient');
|
||||
this.ring_element = mk_svg('path.overlay-selection.overlay-transient');
|
||||
this.selection_group.append(this.ring_bg_element, this.ring_element);
|
||||
|
||||
this.floated_cells = null;
|
||||
this.floated_element = null;
|
||||
this.floated_canvas = null;
|
||||
this.floated_offset = null;
|
||||
}
|
||||
|
||||
get is_empty() {
|
||||
return this.cells.size === 0;
|
||||
}
|
||||
|
||||
get is_floating() {
|
||||
return !! this.floated_cells;
|
||||
}
|
||||
|
||||
get has_moved() {
|
||||
return !! (this.floated_offset && (this.floated_offset[0] || this.floated_offset[0]));
|
||||
}
|
||||
|
||||
contains(x, y) {
|
||||
// Empty selection means everything is selected?
|
||||
if (this.is_empty)
|
||||
return true;
|
||||
|
||||
if (this.floated_offset) {
|
||||
x -= this.floated_offset[0];
|
||||
y -= this.floated_offset[1];
|
||||
}
|
||||
|
||||
return this.cells.has(this.editor.stored_level.coords_to_scalar(x, y));
|
||||
}
|
||||
|
||||
create_pending(mode) {
|
||||
return new PendingRectangularSelection(this, mode);
|
||||
}
|
||||
|
||||
add_rect(rect) {
|
||||
let old_cells = this.cells;
|
||||
// TODO would be nice to only store the difference between the old/new sets of cells?
|
||||
this.cells = new Set(this.cells);
|
||||
|
||||
this.editor._do(
|
||||
() => this._add_rect(rect),
|
||||
() => {
|
||||
this._set_from_set(old_cells);
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
_add_rect(rect) {
|
||||
let stored_level = this.editor.stored_level;
|
||||
for (let y = rect.top; y < rect.bottom; y++) {
|
||||
for (let x = rect.left; x < rect.right; x++) {
|
||||
this.cells.add(stored_level.coords_to_scalar(x, y));
|
||||
}
|
||||
}
|
||||
|
||||
if (! this.bbox) {
|
||||
this.bbox = rect;
|
||||
}
|
||||
else {
|
||||
// Just recreate it from scratch to avoid mixing old and new properties
|
||||
let new_x = Math.min(this.bbox.x, rect.x);
|
||||
let new_y = Math.min(this.bbox.y, rect.y);
|
||||
this.bbox = new DOMRect(
|
||||
new_x, new_y,
|
||||
Math.max(this.bbox.right, rect.right) - new_x,
|
||||
Math.max(this.bbox.bottom, rect.bottom) - new_y);
|
||||
}
|
||||
|
||||
this._update_outline();
|
||||
}
|
||||
|
||||
subtract_rect(rect) {
|
||||
let old_cells = this.cells;
|
||||
this.cells = new Set(this.cells);
|
||||
|
||||
this.editor._do(
|
||||
() => this._subtract_rect(rect),
|
||||
() => {
|
||||
this._set_from_set(old_cells);
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
_subtract_rect(rect) {
|
||||
if (this.is_empty)
|
||||
// Nothing to do
|
||||
return;
|
||||
|
||||
let stored_level = this.editor.stored_level;
|
||||
for (let y = rect.top; y < rect.bottom; y++) {
|
||||
for (let x = rect.left; x < rect.right; x++) {
|
||||
this.cells.delete(stored_level.coords_to_scalar(x, y));
|
||||
}
|
||||
}
|
||||
|
||||
// TODO shrink bbox? i guess i only have to check along the edges that the rect intersects?
|
||||
|
||||
this._update_outline();
|
||||
}
|
||||
|
||||
_set_from_set(cells) {
|
||||
this.cells = cells;
|
||||
|
||||
// Recompute bbox
|
||||
if (cells.size === 0) {
|
||||
this.bbox = null;
|
||||
}
|
||||
else {
|
||||
let min_x = null;
|
||||
let min_y = null;
|
||||
let max_x = null;
|
||||
let max_y = null;
|
||||
for (let n of cells) {
|
||||
let [x, y] = this.editor.stored_level.scalar_to_coords(n);
|
||||
if (min_x === null) {
|
||||
min_x = x;
|
||||
min_y = y;
|
||||
max_x = x;
|
||||
max_y = y;
|
||||
}
|
||||
else {
|
||||
min_x = Math.min(min_x, x);
|
||||
max_x = Math.max(max_x, x);
|
||||
min_y = Math.min(min_y, y);
|
||||
max_y = Math.max(max_y, y);
|
||||
}
|
||||
}
|
||||
|
||||
this.bbox = new DOMRect(min_x, min_y, max_x - min_x + 1, max_y - min_y + 1);
|
||||
}
|
||||
|
||||
// XXX ??? if (this.floated_element) {
|
||||
|
||||
this._update_outline();
|
||||
}
|
||||
|
||||
// Faster internal version of contains() that ignores the floating offset
|
||||
_contains(x, y) {
|
||||
let stored_level = this.editor.stored_level;
|
||||
return stored_level.is_point_within_bounds(x, y) &&
|
||||
this.cells.has(stored_level.coords_to_scalar(x, y));
|
||||
}
|
||||
|
||||
_update_outline() {
|
||||
if (this.is_empty) {
|
||||
this.ring_bg_element.classList.remove('--visible');
|
||||
this.ring_element.classList.remove('--visible');
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert the borders between cells to an SVG path.
|
||||
// I don't know an especially clever way to do this so I guess I'll just make it up. The
|
||||
// basic idea is to start with the top-left highlighted cell, start tracing from its top
|
||||
// left corner towards the right (which must be a border, because this is the top left
|
||||
// selected cell, so nothing above it is selected), then just keep going until we get back
|
||||
// to where we started. Then we... repeat.
|
||||
// But how do we repeat? My tiny insight is that every island (including holes) must cross
|
||||
// the top of at least one cell; the only alternatives are for it to be zero width or only
|
||||
// exist in the bottom row, and either way that makes it zero area, which isn't allowed. So
|
||||
// we only have to track and check the top edges of cells, and run through every cell in the
|
||||
// grid in order, stopping to draw a new outline when we find a cell whose top edge we
|
||||
// haven't yet examined (and whose top edge is in fact a border). We unfortunately need to
|
||||
// examine cells outside the selection, too, so that we can identify holes. But we can
|
||||
// restrict all of this to within the bbox, so that's nice.
|
||||
// Also, note that we concern ourselves with /grid points/ here, which are intersections of
|
||||
// grid lines, whereas the grid cells are the spaces between grid lines.
|
||||
// TODO might be more efficient to store a list of horizontal spans instead of just cells,
|
||||
// but of course this would be more complicated
|
||||
let seen_tops = new BitVector(this.bbox.width * this.bbox.height);
|
||||
// In clockwise order for ease of rotation, starting with right
|
||||
let directions = [
|
||||
[1, 0],
|
||||
[0, 1],
|
||||
[-1, 0],
|
||||
[0, -1],
|
||||
];
|
||||
|
||||
let segments = [];
|
||||
for (let y = this.bbox.top; y < this.bbox.bottom; y++) {
|
||||
for (let x = this.bbox.left; x < this.bbox.right; x++) {
|
||||
if (seen_tops.get((x - this.bbox.left) + this.bbox.width * (y - this.bbox.top)))
|
||||
// Already traced
|
||||
continue;
|
||||
if (this._contains(x, y) === this._contains(x, y - 1))
|
||||
// Not a top border
|
||||
continue;
|
||||
|
||||
// Start a new segment!
|
||||
let gx = x;
|
||||
let gy = y;
|
||||
let dx = 1;
|
||||
let dy = 0;
|
||||
let d = 0;
|
||||
|
||||
let segment = [];
|
||||
segments.push(segment);
|
||||
segment.push([gx, gy]);
|
||||
while (segment.length < 100) {
|
||||
// At this point we know that d is a valid direction and we've just traced it
|
||||
if (dx === 1) {
|
||||
seen_tops.set((gx - this.bbox.left) + this.bbox.width * (gy - this.bbox.top));
|
||||
}
|
||||
else if (dx === -1) {
|
||||
seen_tops.set((gx - 1 - this.bbox.left) + this.bbox.width * (gy - this.bbox.top));
|
||||
}
|
||||
gx += dx;
|
||||
gy += dy;
|
||||
|
||||
if (gx === x && gy === y)
|
||||
break;
|
||||
|
||||
// Now we're at a new point, so search for the next direction, starting from the left
|
||||
// Again, this is clockwise order (tr, br, bl, tl), arranged so that direction D goes
|
||||
// between cells D and D + 1
|
||||
let neighbors = [
|
||||
this._contains(gx, gy - 1),
|
||||
this._contains(gx, gy),
|
||||
this._contains(gx - 1, gy),
|
||||
this._contains(gx - 1, gy - 1),
|
||||
];
|
||||
let new_d = (d + 1) % 4;
|
||||
for (let i = 3; i <= 4; i++) {
|
||||
let sd = (d + i) % 4;
|
||||
if (neighbors[sd] !== neighbors[(sd + 1) % 4]) {
|
||||
new_d = sd;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (new_d !== d) {
|
||||
// We're turning, so this is a new point
|
||||
segment.push([gx, gy]);
|
||||
d = new_d;
|
||||
[dx, dy] = directions[d];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// TODO do it again for the next region... but how do i tell where the next region is?
|
||||
|
||||
let pathdata = [];
|
||||
for (let subpath of segments) {
|
||||
let first = true;
|
||||
for (let [x, y] of subpath) {
|
||||
if (first) {
|
||||
first = false;
|
||||
pathdata.push(`M${x},${y}`);
|
||||
}
|
||||
else {
|
||||
pathdata.push(`L${x},${y}`);
|
||||
}
|
||||
}
|
||||
pathdata.push('z');
|
||||
}
|
||||
this.ring_bg_element.classList.add('--visible');
|
||||
this.ring_bg_element.setAttribute('d', pathdata.join(' '));
|
||||
this.ring_element.classList.add('--visible');
|
||||
this.ring_element.setAttribute('d', pathdata.join(' '));
|
||||
}
|
||||
|
||||
move_by(dx, dy) {
|
||||
if (this.is_empty)
|
||||
return;
|
||||
|
||||
if (! this.floated_cells) {
|
||||
console.error("Can't move a non-floating selection");
|
||||
return;
|
||||
}
|
||||
|
||||
this.floated_offset[0] += dx;
|
||||
this.floated_offset[1] += dy;
|
||||
this._update_floating_transform();
|
||||
}
|
||||
|
||||
_update_floating_transform() {
|
||||
let transform = `translate(${this.floated_offset[0]} ${this.floated_offset[1]})`;
|
||||
this.selection_group.setAttribute('transform', transform);
|
||||
}
|
||||
|
||||
clear() {
|
||||
// FIXME behavior when floating is undefined
|
||||
if (this.is_empty)
|
||||
return;
|
||||
|
||||
let old_cells = this.cells;
|
||||
|
||||
this.editor._do(
|
||||
() => this._clear(),
|
||||
() => {
|
||||
this._set_from_set(old_cells);
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
_clear() {
|
||||
this.cells = new Set;
|
||||
this.bbox = null;
|
||||
this.ring_bg_element.classList.remove('--visible');
|
||||
this.ring_element.classList.remove('--visible');
|
||||
}
|
||||
|
||||
// Convert this selection into a floating selection, plucking all the selected cells from the
|
||||
// level and replacing them with blank cells.
|
||||
enfloat(copy = false) {
|
||||
if (this.floated_cells) {
|
||||
console.error("Trying to float a selection that's already floating");
|
||||
return;
|
||||
}
|
||||
|
||||
let floated_cells = new Map;
|
||||
let stored_level = this.editor.stored_level;
|
||||
for (let n of this.cells) {
|
||||
let [x, y] = stored_level.scalar_to_coords(n);
|
||||
let cell = stored_level.linear_cells[n];
|
||||
if (copy) {
|
||||
floated_cells.set(n, cell.map(tile => tile ? {...tile} : null));
|
||||
}
|
||||
else {
|
||||
floated_cells.set(n, cell);
|
||||
this.editor.replace_cell(cell, this.editor.make_blank_cell(x, y));
|
||||
}
|
||||
}
|
||||
|
||||
this.editor._do(
|
||||
() => {
|
||||
this.floated_cells = floated_cells;
|
||||
this.floated_offset = [0, 0];
|
||||
this._init_floated_canvas();
|
||||
this.ring_element.classList.add('--floating');
|
||||
},
|
||||
() => this._delete_floating(),
|
||||
);
|
||||
}
|
||||
|
||||
// Create floated_canvas and floated_element, based on floated_cells, or update them if they
|
||||
// already exist
|
||||
_init_floated_canvas() {
|
||||
let tileset = this.editor.renderer.tileset;
|
||||
if (! this.floated_canvas) {
|
||||
this.floated_canvas = mk('canvas');
|
||||
}
|
||||
this.floated_canvas.width = this.bbox.width * tileset.size_x;
|
||||
this.floated_canvas.height = this.bbox.height * tileset.size_y;
|
||||
this.redraw();
|
||||
|
||||
if (! this.floated_element) {
|
||||
this.floated_element = mk_svg('g', mk_svg('foreignObject', {
|
||||
x: 0,
|
||||
y: 0,
|
||||
transform: `scale(${1/tileset.size_x} ${1/tileset.size_y})`,
|
||||
}, this.floated_canvas));
|
||||
// This goes first, so the selection ring still appears on top
|
||||
this.selection_group.prepend(this.floated_element);
|
||||
}
|
||||
let foreign = this.floated_element.querySelector('foreignObject');
|
||||
foreign.setAttribute('width', this.floated_canvas.width);
|
||||
foreign.setAttribute('height', this.floated_canvas.height);
|
||||
|
||||
// The canvas only covers our bbox, so it needs to start where the bbox does
|
||||
this.floated_element.setAttribute('transform', `translate(${this.bbox.x} ${this.bbox.y})`);
|
||||
}
|
||||
|
||||
stamp_float(copy = false) {
|
||||
if (! this.floated_element)
|
||||
return;
|
||||
|
||||
let stored_level = this.editor.stored_level;
|
||||
for (let n of this.cells) {
|
||||
let [x, y] = stored_level.scalar_to_coords(n);
|
||||
x += this.floated_offset[0];
|
||||
y += this.floated_offset[1];
|
||||
// If the selection is moved so that part of it is outside the level, skip that bit
|
||||
if (! stored_level.is_point_within_bounds(x, y))
|
||||
continue;
|
||||
|
||||
let cell = this.floated_cells.get(n);
|
||||
if (copy) {
|
||||
cell = cell.map(tile => tile ? {...tile} : null);
|
||||
}
|
||||
cell.x = x;
|
||||
cell.y = y;
|
||||
|
||||
let n2 = stored_level.coords_to_scalar(x, y);
|
||||
this.editor.replace_cell(stored_level.linear_cells[n2], cell);
|
||||
}
|
||||
}
|
||||
|
||||
// Converts a floating selection back to a regular selection, including stamping it in place
|
||||
commit_floating() {
|
||||
// This is OK; we're idempotent
|
||||
if (! this.floated_element)
|
||||
return;
|
||||
|
||||
this.stamp_float();
|
||||
|
||||
// Actually apply the offset, so we can be a regular selection again
|
||||
let old_cells = this.cells;
|
||||
let old_bbox = DOMRect.fromRect(this.bbox);
|
||||
let new_cells = new Set;
|
||||
let stored_level = this.editor.stored_level;
|
||||
for (let n of old_cells) {
|
||||
let [x, y] = stored_level.scalar_to_coords(n);
|
||||
x += this.floated_offset[0];
|
||||
y += this.floated_offset[1];
|
||||
|
||||
if (stored_level.is_point_within_bounds(x, y)) {
|
||||
new_cells.add(stored_level.coords_to_scalar(x, y));
|
||||
}
|
||||
}
|
||||
|
||||
let old_floated_cells = this.floated_cells;
|
||||
let old_floated_offset = this.floated_offset;
|
||||
this.editor._do(
|
||||
() => {
|
||||
this._delete_floating();
|
||||
this._set_from_set(new_cells);
|
||||
},
|
||||
() => {
|
||||
// Don't use _set_from_set here; it's not designed for an offset float
|
||||
this.cells = old_cells;
|
||||
this.bbox = old_bbox;
|
||||
this._update_outline();
|
||||
|
||||
this.floated_cells = old_floated_cells;
|
||||
this.floated_offset = old_floated_offset;
|
||||
this._init_floated_canvas();
|
||||
this._update_floating_transform();
|
||||
this.ring_element.classList.add('--floating');
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
// Modifies the cells (and their arrangement) within a floating selection
|
||||
_rearrange_cells(original_width, convert_coords, upgrade_tile) {
|
||||
if (! this.floated_cells)
|
||||
return;
|
||||
|
||||
let new_cells = new Set;
|
||||
let new_floated_cells = new Map;
|
||||
let w = this.editor.stored_level.size_x;
|
||||
let h = this.editor.stored_level.size_y;
|
||||
for (let n of this.cells) {
|
||||
// Alas this needs manually computing since the level may have changed size
|
||||
let x = n % original_width;
|
||||
let y = Math.floor(n / original_width);
|
||||
let [x2, y2] = convert_coords(x, y, w, h);
|
||||
let n2 = x2 + w * y2;
|
||||
let cell = this.floated_cells.get(n);
|
||||
cell.x = x2;
|
||||
cell.y = y2;
|
||||
for (let tile of cell) {
|
||||
if (tile) {
|
||||
upgrade_tile(tile);
|
||||
}
|
||||
}
|
||||
new_cells.add(n2);
|
||||
new_floated_cells.set(n2, cell);
|
||||
}
|
||||
|
||||
// Track the old and new centers of the bboxes so the transform can be center-relative
|
||||
let [cx0, cy0] = convert_coords(
|
||||
Math.floor(this.bbox.x + this.bbox.width / 2),
|
||||
Math.floor(this.bbox.y + this.bbox.height / 2),
|
||||
w, h);
|
||||
|
||||
// Alter the bbox by just transforming two opposite corners
|
||||
let [x1, y1] = convert_coords(this.bbox.left, this.bbox.top, w, h);
|
||||
let [x2, y2] = convert_coords(this.bbox.right - 1, this.bbox.bottom - 1, w, h);
|
||||
let xs = [x1, x2];
|
||||
let ys = [y1, y2];
|
||||
xs.sort((a, b) => a - b);
|
||||
ys.sort((a, b) => a - b);
|
||||
this.bbox = new DOMRect(xs[0], ys[0], xs[1] - xs[0] + 1, ys[1] - ys[0] + 1);
|
||||
|
||||
// Now make it center-relative by shifting the offsets
|
||||
let [cx1, cy1] = convert_coords(
|
||||
Math.floor(this.bbox.x + this.bbox.width / 2),
|
||||
Math.floor(this.bbox.y + this.bbox.height / 2),
|
||||
w, h);
|
||||
this.floated_offset[0] += cx1 - cx0;
|
||||
this.floated_offset[1] += cy1 - cy0;
|
||||
this._update_floating_transform();
|
||||
|
||||
// No need for undo; this is undone by performing the reverse operation
|
||||
this.cells = new_cells;
|
||||
this.floated_cells = new_floated_cells;
|
||||
this._init_floated_canvas();
|
||||
|
||||
this._update_outline();
|
||||
}
|
||||
|
||||
_delete_floating() {
|
||||
this.selection_group.removeAttribute('transform');
|
||||
this.ring_element.classList.remove('--floating');
|
||||
this.floated_element.remove();
|
||||
|
||||
this.floated_cells = null;
|
||||
this.floated_offset = null;
|
||||
this.floated_element = null;
|
||||
this.floated_canvas = null;
|
||||
}
|
||||
|
||||
// Redraw the selection canvas from scratch
|
||||
redraw() {
|
||||
if (! this.floated_canvas)
|
||||
return;
|
||||
|
||||
let ctx = this.floated_canvas.getContext('2d');
|
||||
for (let n of this.cells) {
|
||||
let [x, y] = this.editor.stored_level.scalar_to_coords(n);
|
||||
this.editor.renderer.draw_static_generic({
|
||||
// Incredibly stupid hack for just drawing one cell
|
||||
x0: 0, x1: 0,
|
||||
y0: 0, y1: 0,
|
||||
width: 1,
|
||||
cells: [this.floated_cells.get(n)],
|
||||
ctx: ctx,
|
||||
destx: x - this.bbox.left,
|
||||
desty: y - this.bbox.top,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// TODO make more stuff respect this (more things should go through Editor for undo reasons anyway)
|
||||
}
|
||||
|
||||
2314
js/editor/main.js
Normal file
2699
js/editor/mouseops.js
Normal file
290
js/editor/tile-overlays.js
Normal file
@ -0,0 +1,290 @@
|
||||
import { TransientOverlay } from '../main-base.js';
|
||||
import { mk, mk_svg } from '../util.js';
|
||||
|
||||
// FIXME could very much stand to have a little animation when appearing
|
||||
class TileEditorOverlay extends TransientOverlay {
|
||||
constructor(conductor) {
|
||||
let root = mk('form.editor-popup-tile-editor');
|
||||
super(conductor, root);
|
||||
this.editor = conductor.editor;
|
||||
this.tile = null;
|
||||
}
|
||||
|
||||
edit_tile(tile, cell) {
|
||||
this.tile = tile;
|
||||
this.cell = cell;
|
||||
|
||||
this.needs_undo_entry = false;
|
||||
}
|
||||
|
||||
// Please call this BEFORE actually modifying the tile; it's important for undo!
|
||||
mark_dirty() {
|
||||
if (this.cell) {
|
||||
if (! this.needs_undo_entry) {
|
||||
// We are ABOUT to mutate this tile for the first time; swap it out with a clone in
|
||||
// preparation for making an undo entry when this overlay closes
|
||||
this.pristine_tile = this.tile;
|
||||
this.tile = {...this.tile};
|
||||
this.cell[this.tile.type.layer] = this.tile;
|
||||
this.needs_undo_entry = true;
|
||||
}
|
||||
this.editor.mark_cell_dirty(this.cell);
|
||||
}
|
||||
else if (this.tile === this.editor.fg_tile) {
|
||||
// The change hasn't happened yet! Don't redraw until we return to the event loop
|
||||
setTimeout(() => this.editor.redraw_foreground_tile(), 0);
|
||||
}
|
||||
else if (this.tile === this.editor.bg_tile) {
|
||||
setTimeout(() => this.editor.redraw_background_tile(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.needs_undo_entry) {
|
||||
// This will be a no-op the first time since the tile was already swapped, but it's
|
||||
// important for redo
|
||||
this.editor._assign_tile(this.cell, this.tile.type.layer, this.tile, this.pristine_tile);
|
||||
this.editor.commit_undo();
|
||||
}
|
||||
super.close();
|
||||
}
|
||||
|
||||
static configure_tile_defaults(tile) {
|
||||
// FIXME maybe this should be on the tile type, so it functions as documentation there?
|
||||
}
|
||||
}
|
||||
|
||||
class LetterTileEditor extends TileEditorOverlay {
|
||||
constructor(conductor) {
|
||||
super(conductor);
|
||||
|
||||
this.root.append(mk('h3', "Letter tile"));
|
||||
let list = mk('ol.editor-letter-tile-picker');
|
||||
this.root.append(list);
|
||||
this.glyph_elements = {};
|
||||
let add = glyph => {
|
||||
let input = mk('input', {type: 'radio', name: 'glyph', value: glyph});
|
||||
this.glyph_elements[glyph] = input;
|
||||
let item = mk('li', mk('label', input, mk('span.-glyph', glyph)));
|
||||
list.append(item);
|
||||
};
|
||||
let arrows = ["⬆", "➡", "⬇", "⬅"];
|
||||
for (let c = 32; c < 96; c++) {
|
||||
let glyph = String.fromCharCode(c);
|
||||
add(glyph);
|
||||
// Add the arrows to the ends of the rows
|
||||
if (c % 16 === 15) {
|
||||
add(arrows[(c - 47) / 16]);
|
||||
}
|
||||
}
|
||||
|
||||
list.addEventListener('change', ev => {
|
||||
if (this.tile) {
|
||||
this.mark_dirty();
|
||||
this.tile.overlaid_glyph = this.root.elements['glyph'].value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
edit_tile(tile, cell) {
|
||||
super.edit_tile(tile, cell);
|
||||
this.root.elements['glyph'].value = tile.overlaid_glyph;
|
||||
}
|
||||
|
||||
static configure_tile_defaults(tile) {
|
||||
tile.type.populate_defaults(tile);
|
||||
}
|
||||
}
|
||||
|
||||
class HintTileEditor extends TileEditorOverlay {
|
||||
constructor(conductor) {
|
||||
super(conductor);
|
||||
|
||||
this.root.append(mk('h3', "Hint text"));
|
||||
this.text = mk('textarea.editor-hint-tile-text');
|
||||
this.root.append(this.text);
|
||||
this.text.addEventListener('change', ev => {
|
||||
if (this.tile && this.text.value !== this.tile.hint_text) {
|
||||
this.mark_dirty();
|
||||
this.tile.hint_text = this.text.value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
edit_tile(tile, cell) {
|
||||
super.edit_tile(tile, cell);
|
||||
this.text.value = tile.hint_text ?? "";
|
||||
}
|
||||
|
||||
static configure_tile_defaults(tile) {
|
||||
tile.hint_text = "";
|
||||
}
|
||||
}
|
||||
|
||||
class FrameBlockTileEditor extends TileEditorOverlay {
|
||||
constructor(conductor) {
|
||||
super(conductor);
|
||||
|
||||
let svg_icons = [];
|
||||
for (let center of [[16, 0], [16, 16], [0, 16], [0, 0]]) {
|
||||
let symbol = mk_svg('svg', {viewBox: '0 0 16 16'},
|
||||
mk_svg('circle', {cx: center[0], cy: center[1], r: 3}),
|
||||
mk_svg('circle', {cx: center[0], cy: center[1], r: 13}),
|
||||
);
|
||||
svg_icons.push(symbol);
|
||||
}
|
||||
svg_icons.push(mk_svg('svg', {viewBox: '0 0 16 16'},
|
||||
mk_svg('rect', {x: -2, y: 3, width: 20, height: 10}),
|
||||
));
|
||||
svg_icons.push(mk_svg('svg', {viewBox: '0 0 16 16'},
|
||||
mk_svg('rect', {x: 3, y: -2, width: 10, height: 20}),
|
||||
));
|
||||
|
||||
this.root.append(mk('h3', "Arrows"));
|
||||
let arrow_list = mk('ol.editor-directional-block-tile-arrows.editor-tile-editor-svg-parts');
|
||||
// Arrange the arrows in a grid
|
||||
for (let [direction, icon] of [
|
||||
[null, mk_svg('path', {d: 'M 8,16 v -8 h 8'})],
|
||||
['north', mk_svg('path', {d: 'M 0,12 h 16 l -8,-8 z'})],
|
||||
[null, mk_svg('path', {d: 'M 0,8 h 8 v 8'})],
|
||||
['west', mk_svg('path', {d: 'M 12,16 v -16 l -8,8 z'})],
|
||||
[null, null],
|
||||
['east', mk_svg('path', {d: 'M 4,0 v 16 l 8,-8 z'})],
|
||||
[null, mk_svg('path', {d: 'M 16,8 h -8 v -8'})],
|
||||
['south', mk_svg('path', {d: 'M 16,4 h -16 l 8,8 z'})],
|
||||
[null, mk_svg('path', {d: 'M 8,0 v 8 h -8'})],
|
||||
]) {
|
||||
let li = mk('li');
|
||||
let svg;
|
||||
if (icon) {
|
||||
svg = mk_svg('svg', {viewBox: '0 0 16 16'}, icon);
|
||||
}
|
||||
if (direction === null) {
|
||||
if (svg) {
|
||||
li.append(svg);
|
||||
}
|
||||
}
|
||||
else {
|
||||
let input = mk('input', {type: 'checkbox', name: 'direction', value: direction});
|
||||
li.append(mk('label', input, svg));
|
||||
}
|
||||
arrow_list.append(li);
|
||||
}
|
||||
arrow_list.addEventListener('change', ev => {
|
||||
if (! this.tile)
|
||||
return;
|
||||
|
||||
this.mark_dirty();
|
||||
if (ev.target.checked) {
|
||||
this.tile.arrows.add(ev.target.value);
|
||||
}
|
||||
else {
|
||||
this.tile.arrows.delete(ev.target.value);
|
||||
}
|
||||
});
|
||||
this.root.append(arrow_list);
|
||||
}
|
||||
|
||||
edit_tile(tile, cell) {
|
||||
super.edit_tile(tile, cell);
|
||||
|
||||
for (let input of this.root.elements['direction']) {
|
||||
input.checked = tile.arrows.has(input.value);
|
||||
}
|
||||
}
|
||||
|
||||
static configure_tile_defaults(tile) {
|
||||
}
|
||||
}
|
||||
|
||||
class RailroadTileEditor extends TileEditorOverlay {
|
||||
constructor(conductor) {
|
||||
super(conductor);
|
||||
|
||||
let svg_icons = [];
|
||||
for (let center of [[16, 0], [16, 16], [0, 16], [0, 0]]) {
|
||||
let symbol = mk_svg('svg', {viewBox: '0 0 16 16'},
|
||||
mk_svg('circle', {cx: center[0], cy: center[1], r: 3}),
|
||||
mk_svg('circle', {cx: center[0], cy: center[1], r: 13}),
|
||||
);
|
||||
svg_icons.push(symbol);
|
||||
}
|
||||
svg_icons.push(mk_svg('svg', {viewBox: '0 0 16 16'},
|
||||
mk_svg('rect', {x: -2, y: 3, width: 20, height: 10}),
|
||||
));
|
||||
svg_icons.push(mk_svg('svg', {viewBox: '0 0 16 16'},
|
||||
mk_svg('rect', {x: 3, y: -2, width: 10, height: 20}),
|
||||
));
|
||||
|
||||
this.root.append(mk('h3', "Tracks"));
|
||||
let track_list = mk('ul.editor-railroad-tile-tracks.editor-tile-editor-svg-parts');
|
||||
// Shown as two rows, this puts the straight parts first and the rest in a circle
|
||||
let track_order = [4, 1, 2, 5, 0, 3];
|
||||
for (let i of track_order) {
|
||||
let input = mk('input', {type: 'checkbox', name: 'track', value: i});
|
||||
track_list.append(mk('li', mk('label', input, svg_icons[i])));
|
||||
}
|
||||
track_list.addEventListener('change', ev => {
|
||||
if (! this.tile)
|
||||
return;
|
||||
|
||||
this.mark_dirty();
|
||||
let bit = 1 << ev.target.value;
|
||||
if (ev.target.checked) {
|
||||
this.tile.tracks |= bit;
|
||||
}
|
||||
else {
|
||||
this.tile.tracks &= ~bit;
|
||||
}
|
||||
});
|
||||
this.root.append(track_list);
|
||||
|
||||
this.root.append(mk('h3', "Switch"));
|
||||
let switch_list = mk('ul.editor-railroad-tile-tracks.--switch.editor-tile-editor-svg-parts');
|
||||
for (let i of track_order) {
|
||||
let input = mk('input', {type: 'radio', name: 'switch', value: i});
|
||||
switch_list.append(mk('li', mk('label', input, svg_icons[i].cloneNode(true))));
|
||||
}
|
||||
// TODO if they remove a track it should change the switch
|
||||
// TODO if they pick a track that's missing it should add it
|
||||
switch_list.addEventListener('change', ev => {
|
||||
if (this.tile) {
|
||||
this.mark_dirty();
|
||||
this.tile.track_switch = parseInt(ev.target.value, 10);
|
||||
}
|
||||
});
|
||||
this.root.append(switch_list);
|
||||
|
||||
// TODO need a way to set no actor at all
|
||||
// TODO initial actor facing (maybe only if there's an actor in the cell)
|
||||
}
|
||||
|
||||
edit_tile(tile, cell) {
|
||||
super.edit_tile(tile, cell);
|
||||
|
||||
for (let input of this.root.elements['track']) {
|
||||
input.checked = !! (tile.tracks & (1 << input.value));
|
||||
}
|
||||
|
||||
if (tile.track_switch === null) {
|
||||
this.root.elements['switch'].value = '';
|
||||
}
|
||||
else {
|
||||
this.root.elements['switch'].value = tile.track_switch;
|
||||
}
|
||||
}
|
||||
|
||||
static configure_tile_defaults(tile) {
|
||||
}
|
||||
}
|
||||
|
||||
export const TILES_WITH_PROPS = {
|
||||
floor_letter: LetterTileEditor,
|
||||
hint: HintTileEditor,
|
||||
frame_block: FrameBlockTileEditor,
|
||||
railroad: RailroadTileEditor,
|
||||
// TODO various wireable tiles (hmm not sure how that ui works)
|
||||
// TODO initial value of counter
|
||||
// TODO cloner arrows (should this be automatic unless you set them explicitly?)
|
||||
// TODO later, custom floor/wall selection
|
||||
};
|
||||
214
js/format-base.js
Normal file
@ -0,0 +1,214 @@
|
||||
import { DIRECTIONS, LAYERS } from './defs.js';
|
||||
import * as util from './util.js';
|
||||
|
||||
export class StoredCell extends Array {
|
||||
constructor() {
|
||||
super(LAYERS.MAX);
|
||||
}
|
||||
|
||||
get_terrain() {
|
||||
return this[LAYERS.terrain] ?? null;
|
||||
}
|
||||
|
||||
get_actor() {
|
||||
return this[LAYERS.actor] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
export class Replay {
|
||||
constructor(initial_force_floor_direction, blob_seed, inputs = null, step_parity = null, tw_seed = 0) {
|
||||
this.initial_force_floor_direction = initial_force_floor_direction;
|
||||
this.blob_seed = blob_seed;
|
||||
this.step_parity = step_parity;
|
||||
this.tw_seed = tw_seed;
|
||||
this.inputs = inputs ?? new Uint8Array;
|
||||
this.duration = this.inputs.length;
|
||||
this.cursor = 0;
|
||||
}
|
||||
|
||||
configure_level(level) {
|
||||
level.force_floor_direction = this.initial_force_floor_direction;
|
||||
level._blob_modifier = this.blob_seed;
|
||||
level._tw_rng = this.tw_seed;
|
||||
if (this.step_parity !== null) {
|
||||
level.step_parity = this.step_parity;
|
||||
}
|
||||
}
|
||||
|
||||
get(t) {
|
||||
if (this.duration <= 0) {
|
||||
return 0;
|
||||
}
|
||||
else if (t < this.duration) {
|
||||
return this.inputs[t];
|
||||
}
|
||||
else {
|
||||
// Last input is implicitly repeated indefinitely
|
||||
return this.inputs[this.duration - 1];
|
||||
}
|
||||
}
|
||||
|
||||
set(t, input) {
|
||||
if (t >= this.inputs.length) {
|
||||
let new_inputs = new Uint8Array(this.inputs.length + 1024);
|
||||
for (let i = 0; i < this.inputs.length; i++) {
|
||||
new_inputs[i] = this.inputs[i];
|
||||
}
|
||||
this.inputs = new_inputs;
|
||||
}
|
||||
this.inputs[t] = input;
|
||||
if (t >= this.duration) {
|
||||
this.duration = t + 1;
|
||||
}
|
||||
}
|
||||
|
||||
clone() {
|
||||
let new_inputs = new Uint8Array(this.duration);
|
||||
for (let i = 0; i < this.duration; i++) {
|
||||
new_inputs[i] = this.inputs[i];
|
||||
}
|
||||
return new this.constructor(this.initial_force_floor_direction, this.blob_seed, new_inputs);
|
||||
}
|
||||
}
|
||||
|
||||
// Small shared helper methods for navigating a StoredLevel or Level
|
||||
export class LevelInterface {
|
||||
// Expected attributes:
|
||||
// .size_x
|
||||
// .size_y
|
||||
// .linear_cells
|
||||
scalar_to_coords(n) {
|
||||
return [n % this.size_x, Math.floor(n / this.size_x)];
|
||||
}
|
||||
|
||||
coords_to_scalar(x, y) {
|
||||
return x + y * this.size_x;
|
||||
}
|
||||
|
||||
cell_to_scalar(cell) {
|
||||
return this.coords_to_scalar(cell.x, cell.y);
|
||||
}
|
||||
|
||||
is_point_within_bounds(x, y) {
|
||||
return (x >= 0 && x < this.size_x && y >= 0 && y < this.size_y);
|
||||
}
|
||||
|
||||
cell(x, y) {
|
||||
if (this.is_point_within_bounds(x, y)) {
|
||||
return this.linear_cells[this.coords_to_scalar(x, y)];
|
||||
}
|
||||
else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
get_neighboring_cell(cell, direction) {
|
||||
let move = DIRECTIONS[direction].movement;
|
||||
return this.cell(cell.x + move[0], cell.y + move[1]);
|
||||
}
|
||||
}
|
||||
|
||||
export class StoredLevel extends LevelInterface {
|
||||
constructor(number) {
|
||||
super();
|
||||
// TODO still not sure this belongs here
|
||||
this.number = number; // one-based
|
||||
this.title = '';
|
||||
this.author = '';
|
||||
this.password = null;
|
||||
this.comment = '';
|
||||
// A number is a specified count; the default of null means that the chips are counted on
|
||||
// level init, as in CC2
|
||||
this.chips_required = null;
|
||||
this.time_limit = 0;
|
||||
this.viewport_size = 9;
|
||||
this.extra_chunks = [];
|
||||
this.use_cc1_boots = false;
|
||||
// What we were parsed from: 'ccl', 'c2m', or null
|
||||
this.format = null;
|
||||
// Whether we use LL features that don't exist in CC2; null means we don't know
|
||||
this.uses_ll_extensions = null;
|
||||
// 0 - deterministic (PRNG + simple convolution)
|
||||
// 1 - 4 patterns (default; PRNG + rotating through 0-3)
|
||||
// 2 - extra random (like deterministic, but initial seed is "actually" random)
|
||||
this.blob_behavior = 1;
|
||||
this.hide_logic = false;
|
||||
|
||||
// Lazy-loading that allows for checking existence (see methods below)
|
||||
// TODO this needs a better interface, these get accessed too much atm
|
||||
this._replay = null;
|
||||
this._replay_data = null;
|
||||
this._replay_decoder = null;
|
||||
|
||||
this.size_x = 0;
|
||||
this.size_y = 0;
|
||||
this.linear_cells = [];
|
||||
|
||||
// Maps of button positions to trap/cloner positions, as scalars
|
||||
// Not supported by Steam CC2, but supported by Tile World even in Lynx mode
|
||||
this.custom_connections = new Map;
|
||||
// If true, Lynx-style implicit connections don't work at all
|
||||
this.only_custom_connections = false;
|
||||
|
||||
// New LL feature: custom camera regions, as lists of {x, y, width, height}
|
||||
this.camera_regions = [];
|
||||
}
|
||||
|
||||
check() {
|
||||
}
|
||||
|
||||
get has_replay() {
|
||||
return this._replay || (this._replay_data && this._replay_decoder);
|
||||
}
|
||||
|
||||
get replay() {
|
||||
if (! this._replay) {
|
||||
this._replay = this._replay_decoder(this._replay_data);
|
||||
}
|
||||
return this._replay;
|
||||
}
|
||||
}
|
||||
|
||||
export class StoredPack {
|
||||
constructor(identifier, level_loader) {
|
||||
// This isn't very strongly defined, but it's used to distinguish scores for packs and URLs
|
||||
this.identifier = identifier;
|
||||
this.title = "";
|
||||
this._level_loader = level_loader;
|
||||
|
||||
// Simple objects containing keys that are usually:
|
||||
// title: level title
|
||||
// index: level index, used internally only
|
||||
// number: level number (may not match index due to C2G shenanigans)
|
||||
// error: any error received while loading the level
|
||||
// bytes: Uint8Array of the encoded level data
|
||||
this.level_metadata = [];
|
||||
|
||||
// Sparse/optional array of Replays, generally from an ancillary file like a TWS
|
||||
// TODO unclear if this is a good API for this
|
||||
this.level_replays = [];
|
||||
}
|
||||
|
||||
// TODO this may or may not work sensibly when correctly following a c2g
|
||||
load_level(index) {
|
||||
let meta = this.level_metadata[index];
|
||||
if (! meta)
|
||||
throw new util.LLError(`No such level number ${index}`);
|
||||
if (meta.error)
|
||||
throw meta.error;
|
||||
|
||||
if (meta.stored_level) {
|
||||
// The editor stores inflated levels at times, so respect that
|
||||
return meta.stored_level;
|
||||
}
|
||||
|
||||
// Otherwise, attempt to load the level
|
||||
let stored_level = this._level_loader(meta);
|
||||
if (! stored_level.has_replay && this.level_replays[index]) {
|
||||
stored_level._replay = this.level_replays[index];
|
||||
}
|
||||
return stored_level;
|
||||
}
|
||||
}
|
||||
|
||||
export const StoredGame = StoredPack;
|
||||
2236
js/format-c2g.js
Normal file
1139
js/format-c2m.js
470
js/format-dat.js
@ -1,5 +1,7 @@
|
||||
import * as util from './format-util.js';
|
||||
import { DIRECTIONS, LAYERS } from './defs.js';
|
||||
import * as format_base from './format-base.js';
|
||||
import TILE_TYPES from './tiletypes.js';
|
||||
import * as util from './util.js';
|
||||
|
||||
const TILE_ENCODING = {
|
||||
0x00: 'floor',
|
||||
@ -8,10 +10,10 @@ const TILE_ENCODING = {
|
||||
0x03: 'water',
|
||||
0x04: 'fire',
|
||||
0x05: 'wall_invisible',
|
||||
0x06: 'thinwall_n',
|
||||
0x07: 'thinwall_w',
|
||||
0x08: 'thinwall_s',
|
||||
0x09: 'thinwall_e',
|
||||
0x06: ['thin_walls', {edges: DIRECTIONS['north'].bit}],
|
||||
0x07: ['thin_walls', {edges: DIRECTIONS['west'].bit}],
|
||||
0x08: ['thin_walls', {edges: DIRECTIONS['south'].bit}],
|
||||
0x09: ['thin_walls', {edges: DIRECTIONS['east'].bit}],
|
||||
// This is MSCC's incomprehensible non-directional dirt block, which needs a direction for Lynx
|
||||
// purposes; Tile World defaults it to north
|
||||
0x0a: ['dirt_block', 'north'],
|
||||
@ -53,7 +55,7 @@ const TILE_ENCODING = {
|
||||
0x2d: 'gravel',
|
||||
0x2e: 'popwall',
|
||||
0x2f: 'hint',
|
||||
0x30: 'thinwall_se',
|
||||
0x30: ['thin_walls', {edges: DIRECTIONS['south'].bit | DIRECTIONS['east'].bit}],
|
||||
0x31: 'cloner',
|
||||
0x32: 'force_floor_all',
|
||||
0x33: 'bogus_player_drowned',
|
||||
@ -61,7 +63,7 @@ const TILE_ENCODING = {
|
||||
0x35: 'bogus_player_burned',
|
||||
0x36: 'wall_invisible', // unused
|
||||
0x37: 'wall_invisible', // unused
|
||||
0x38: 'wall_invisible', // unused
|
||||
0x38: 'ice_block', // unused, but co-opted by pgchip
|
||||
0x39: 'bogus_player_win',
|
||||
0x3a: 'bogus_player_win',
|
||||
0x3b: 'bogus_player_win',
|
||||
@ -118,19 +120,108 @@ const TILE_ENCODING = {
|
||||
0x6e: ['player', 'south'],
|
||||
0x6f: ['player', 'east'],
|
||||
};
|
||||
|
||||
function parse_level(buf, number) {
|
||||
let level = new util.StoredLevel(number);
|
||||
|
||||
const REVERSE_TILE_ENCODING = {};
|
||||
for (let [tile_byte, spec] of Object.entries(TILE_ENCODING)) {
|
||||
tile_byte = parseInt(tile_byte, 10); // these are keys so they get stringified ugh
|
||||
if (0x36 <= tile_byte && tile_byte <= 0x37) {
|
||||
// These are unused tiles which get turned into invisible walls; don't encode invisible
|
||||
// walls as them! (0x38 is also "unused", but pgchip turns it into ice block.)
|
||||
continue;
|
||||
}
|
||||
|
||||
let name, arg;
|
||||
if (spec instanceof Array) {
|
||||
[name, arg] = spec;
|
||||
}
|
||||
else {
|
||||
name = spec;
|
||||
arg = null;
|
||||
}
|
||||
|
||||
let rev_spec = REVERSE_TILE_ENCODING[name];
|
||||
if (! rev_spec) {
|
||||
rev_spec = {};
|
||||
REVERSE_TILE_ENCODING[name] = rev_spec;
|
||||
}
|
||||
|
||||
if (arg === null || tile_byte === 0x0a) {
|
||||
// Special case: 0x0a is MSCC's undirected dirt block, which needs to coexist with the
|
||||
// directed "clone" blocks
|
||||
rev_spec['all'] = tile_byte;
|
||||
}
|
||||
else if (typeof arg === 'string') {
|
||||
// This is a direction
|
||||
rev_spec[arg] = tile_byte;
|
||||
}
|
||||
else {
|
||||
// This is the thin_walls argument structure
|
||||
rev_spec[arg.edges] = tile_byte;
|
||||
}
|
||||
}
|
||||
|
||||
function decode_password(bytes, start, len) {
|
||||
let password = [];
|
||||
for (let i = 0; i < len; i++) {
|
||||
password.push(bytes[start + i] ^ 0x99);
|
||||
}
|
||||
return String.fromCharCode.apply(null, password);
|
||||
}
|
||||
|
||||
export function parse_level_metadata(bytes) {
|
||||
let meta = {};
|
||||
|
||||
let view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
|
||||
// Level number; rest of level header is unused
|
||||
meta.number = view.getUint16(0, true);
|
||||
|
||||
// Map layout
|
||||
// Same structure twice, for the two layers
|
||||
let p = 8;
|
||||
for (let l = 0; l < 2; l++) {
|
||||
let layer_length = view.getUint16(p, true);
|
||||
p += 2 + layer_length;
|
||||
}
|
||||
|
||||
// Optional metadata fields
|
||||
let meta_length = view.getUint16(p, true);
|
||||
p += 2;
|
||||
let end = p + meta_length;
|
||||
while (p < end) {
|
||||
// Common header
|
||||
let field_type = view.getUint8(p, true);
|
||||
let field_length = view.getUint8(p + 1, true);
|
||||
p += 2;
|
||||
if (field_type === 0x03) {
|
||||
// Title, including trailing NUL
|
||||
meta.title = util.string_from_buffer_ascii(bytes, p, field_length - 1);
|
||||
}
|
||||
else if (field_type === 0x06) {
|
||||
// Password, with trailing NUL, and XORed with 0x99 (???)
|
||||
meta.password = decode_password(bytes, p, field_length - 1);
|
||||
}
|
||||
p += field_length;
|
||||
}
|
||||
|
||||
return meta;
|
||||
}
|
||||
|
||||
function parse_level(bytes, number) {
|
||||
let level = new format_base.StoredLevel(number);
|
||||
level.only_custom_connections = true;
|
||||
level.format = 'ccl';
|
||||
level.uses_ll_extensions = false;
|
||||
level.chips_required = 0;
|
||||
// Map size is always fixed as 32x32 in CC1
|
||||
level.size_x = 32;
|
||||
level.size_y = 32;
|
||||
for (let i = 0; i < 1024; i++) {
|
||||
level.linear_cells.push(new util.StoredCell);
|
||||
level.linear_cells.push(new format_base.StoredCell);
|
||||
}
|
||||
level.use_cc1_boots = true;
|
||||
|
||||
let view = new DataView(buf);
|
||||
let bytes = new Uint8Array(buf);
|
||||
let view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
|
||||
// Header
|
||||
let level_number = view.getUint16(0, true);
|
||||
@ -141,6 +232,7 @@ function parse_level(buf, number) {
|
||||
let unknown = view.getUint16(6, true);
|
||||
// Same structure twice, for the two layers
|
||||
let p = 8;
|
||||
let hint_tiles = [];
|
||||
for (let l = 0; l < 2; l++) {
|
||||
let layer_length = view.getUint16(p, true);
|
||||
p += 2;
|
||||
@ -161,17 +253,21 @@ function parse_level(buf, number) {
|
||||
// TODO could be more forgiving for goofy levels doing goofy things
|
||||
if (! spec) {
|
||||
let [x, y] = level.scalar_to_coords(c);
|
||||
throw new Error(`Invalid tile byte 0x${tile_byte.toString(16)} at (${x}, ${y}) in level ${number}`);
|
||||
throw new Error(`Invalid tile byte 0x${tile_byte.toString(16)} at (${x}, ${y})`);
|
||||
}
|
||||
|
||||
let name, direction;
|
||||
let name, extra;
|
||||
if (spec instanceof Array) {
|
||||
[name, direction] = spec;
|
||||
[name, extra] = spec;
|
||||
if (typeof extra === 'string') {
|
||||
extra = {direction: extra};
|
||||
}
|
||||
}
|
||||
else {
|
||||
name = spec;
|
||||
extra = {};
|
||||
}
|
||||
let type = TILE_TYPES[name];
|
||||
let tile = {type: TILE_TYPES[name], ...extra};
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (c >= 1024)
|
||||
@ -189,7 +285,22 @@ function parse_level(buf, number) {
|
||||
continue;
|
||||
}
|
||||
|
||||
cell.unshift({type, direction});
|
||||
// pgchip grants directions to ice blocks on cloners by putting a clone block
|
||||
// beneath them instead
|
||||
if (l === 1 && 0x0e <= tile_byte && tile_byte <= 0x11 &&
|
||||
cell[LAYERS.actor] && cell[LAYERS.actor].type.name === 'ice_block')
|
||||
{
|
||||
cell[LAYERS.actor].direction = extra.direction;
|
||||
let type = TILE_TYPES['cloner'];
|
||||
cell[type.layer] = {type};
|
||||
continue;
|
||||
}
|
||||
|
||||
let new_tile = {...tile};
|
||||
cell[tile.type.layer] = new_tile;
|
||||
if (new_tile.type.name === 'hint') {
|
||||
hint_tiles.push(new_tile);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (c !== 1024)
|
||||
@ -198,9 +309,8 @@ function parse_level(buf, number) {
|
||||
|
||||
// Fix the "floor/empty" nonsense here by adding floor to any cell with no terrain on bottom
|
||||
for (let cell of level.linear_cells) {
|
||||
if (cell.length === 0 || cell[0].type.draw_layer !== 0) {
|
||||
// No terrain; insert a floor
|
||||
cell.unshift({ type: TILE_TYPES['floor'] });
|
||||
if (! cell[LAYERS.terrain]) {
|
||||
cell[LAYERS.terrain] = { type: TILE_TYPES['floor'] };
|
||||
}
|
||||
// TODO we could also deal with weird cases where there's terrain /on top of/ something
|
||||
// else: things underwater, the quirk where a glider will erase the item underneath...
|
||||
@ -225,11 +335,11 @@ function parse_level(buf, number) {
|
||||
}
|
||||
else if (field_type === 0x03) {
|
||||
// Title, including trailing NUL
|
||||
level.title = util.string_from_buffer_ascii(buf.slice(p, p + field_length - 1));
|
||||
level.title = util.string_from_buffer_ascii(bytes, p, field_length - 1);
|
||||
}
|
||||
else if (field_type === 0x04) {
|
||||
// Trap linkages (MSCC only, not in Lynx or CC2)
|
||||
let field_view = new DataView(buf.slice(p, p + field_length));
|
||||
let field_view = new DataView(bytes.buffer, bytes.byteOffset + p, field_length);
|
||||
let q = 0;
|
||||
while (q < field_length) {
|
||||
let button_x = field_view.getUint16(q + 0, true);
|
||||
@ -238,12 +348,18 @@ function parse_level(buf, number) {
|
||||
let trap_y = field_view.getUint16(q + 6, true);
|
||||
// Fifth u16 is always zero, possibly live game state
|
||||
q += 10;
|
||||
level.custom_trap_wiring[button_x + button_y * level.size_x] = trap_x + trap_y * level.size_x;
|
||||
// Connections are ignored if they're on the wrong tiles anyway, and we use a single
|
||||
// mapping that's a bit more flexible, so only store valid connections
|
||||
let s = level.coords_to_scalar(button_x, button_y);
|
||||
let d = level.coords_to_scalar(trap_x, trap_y);
|
||||
if (level.linear_cells[s][LAYERS.terrain].type.name === 'button_brown') {
|
||||
level.custom_connections.set(s, d);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (field_type === 0x05) {
|
||||
// Cloner linkages (MSCC only, not in Lynx or CC2)
|
||||
let field_view = new DataView(buf.slice(p, p + field_length));
|
||||
let field_view = new DataView(bytes.buffer, bytes.byteOffset + p, field_length);
|
||||
let q = 0;
|
||||
while (q < field_length) {
|
||||
let button_x = field_view.getUint16(q + 0, true);
|
||||
@ -251,25 +367,34 @@ function parse_level(buf, number) {
|
||||
let cloner_x = field_view.getUint16(q + 4, true);
|
||||
let cloner_y = field_view.getUint16(q + 6, true);
|
||||
q += 8;
|
||||
level.custom_cloner_wiring[button_x + button_y * level.size_x] = cloner_x + cloner_y * level.size_x;
|
||||
// Connections are ignored if they're on the wrong tiles anyway, and we use a single
|
||||
// mapping that's a bit more flexible, so only store valid connections
|
||||
let s = level.coords_to_scalar(button_x, button_y);
|
||||
let d = level.coords_to_scalar(cloner_x, cloner_y);
|
||||
if (level.linear_cells[s][LAYERS.terrain].type.name === 'button_red') {
|
||||
level.custom_connections.set(s, d);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (field_type === 0x06) {
|
||||
// Password, with trailing NUL, and otherwise XORed with 0x99 (?!)
|
||||
let password = [];
|
||||
for (let i = 0; i < field_length - 1; i++) {
|
||||
password.push(view.getUint8(p + i, true) ^ 0x99);
|
||||
}
|
||||
level.password = String.fromCharCode.apply(null, password);
|
||||
// Password, with trailing NUL, and otherwise XORed with 0x99 (???)
|
||||
level.password = decode_password(bytes, p, field_length - 1);
|
||||
}
|
||||
else if (field_type === 0x07) {
|
||||
// Hint, including trailing NUL, of course
|
||||
level.hint = util.string_from_buffer_ascii(buf.slice(p, p + field_length - 1));
|
||||
let hint = util.string_from_buffer_ascii(bytes, p, field_length - 1);
|
||||
for (let tile of hint_tiles) {
|
||||
tile.hint_text = hint;
|
||||
}
|
||||
}
|
||||
else if (field_type === 0x08) {
|
||||
// Password, but not encoded
|
||||
// TODO ???
|
||||
}
|
||||
else if (field_type === 0x09) {
|
||||
// EXTENSION: Author, including trailing NUL
|
||||
level.author = util.string_from_buffer_ascii(bytes, p, field_length - 1);
|
||||
}
|
||||
else if (field_type === 0x0a) {
|
||||
// Initial actor order
|
||||
// TODO ??? should i... trust this...
|
||||
@ -280,8 +405,13 @@ function parse_level(buf, number) {
|
||||
return level;
|
||||
}
|
||||
|
||||
// This thin wrapper is passed to StoredGame as the parser function
|
||||
function _parse_level_from_stored_meta(meta) {
|
||||
return parse_level(meta.bytes, meta.number);
|
||||
}
|
||||
|
||||
export function parse_game(buf) {
|
||||
let game = new util.StoredGame;
|
||||
let game = new format_base.StoredGame(null, _parse_level_from_stored_meta);
|
||||
|
||||
let full_view = new DataView(buf);
|
||||
let magic = full_view.getUint32(0, true);
|
||||
@ -293,6 +423,10 @@ export function parse_game(buf) {
|
||||
// OK
|
||||
// TODO tile world convention, use lynx rules
|
||||
}
|
||||
else if (magic === 0x0003aaac) {
|
||||
// OK
|
||||
// TODO add in ice block i guess???
|
||||
}
|
||||
else {
|
||||
throw new Error(`Unrecognized magic number ${magic.toString(16)}`);
|
||||
}
|
||||
@ -303,12 +437,274 @@ export function parse_game(buf) {
|
||||
let p = 6;
|
||||
for (let l = 1; l <= level_count; l++) {
|
||||
let length = full_view.getUint16(p, true);
|
||||
let level_buf = buf.slice(p + 2, p + 2 + length);
|
||||
let bytes = new Uint8Array(buf, p + 2, length);
|
||||
p += 2 + length;
|
||||
|
||||
let level = parse_level(level_buf, l);
|
||||
game.levels.push(level);
|
||||
let meta;
|
||||
try {
|
||||
meta = parse_level_metadata(bytes);
|
||||
}
|
||||
catch (e) {
|
||||
meta = {error: e};
|
||||
}
|
||||
meta.index = l - 1;
|
||||
meta.bytes = bytes;
|
||||
game.level_metadata.push(meta);
|
||||
}
|
||||
|
||||
return game;
|
||||
}
|
||||
|
||||
export class CCLEncodingErrors extends util.LLError {
|
||||
constructor(errors) {
|
||||
super("Failed to encode level as CCL");
|
||||
this.errors = errors;
|
||||
}
|
||||
}
|
||||
|
||||
export function synthesize_level(stored_level) {
|
||||
let errors = [];
|
||||
if (stored_level.size_x !== 32) {
|
||||
errors.push(`Level width must be 32, not ${stored_level.size_x}`);
|
||||
}
|
||||
if (stored_level.size_y !== 32) {
|
||||
errors.push(`Level width must be 32, not ${stored_level.size_y}`);
|
||||
}
|
||||
|
||||
// TODO might also want the tile world "lynx mode" magic number, or pgchip's ice block rules
|
||||
let magic = 0x0002aaac;
|
||||
let top_layer = [];
|
||||
let bottom_layer = [];
|
||||
let hint_text = null;
|
||||
let trap_cxns = [];
|
||||
let cloner_cxns = [];
|
||||
let monster_coords = [];
|
||||
let error_found_wires = false;
|
||||
// TODO i could be a little kinder and support, say, items on terrain; do those work in mscc? tw lynx?
|
||||
for (let [i, cell] of stored_level.linear_cells.entries()) {
|
||||
let [x, y] = stored_level.scalar_to_coords(i);
|
||||
let actor = null;
|
||||
let other = null;
|
||||
for (let tile of cell) {
|
||||
if (! tile)
|
||||
continue;
|
||||
if (tile.wire_directions || tile.wire_tunnel_directions) {
|
||||
error_found_wires = true;
|
||||
}
|
||||
|
||||
if (tile.type.layer === LAYERS.actor) {
|
||||
actor = tile;
|
||||
}
|
||||
else if (tile.type.name === 'floor') {
|
||||
// This is the default anyway, so don't count it against the number of tiles
|
||||
continue;
|
||||
}
|
||||
else if (other) {
|
||||
errors.push(`A cell can only contain one static tile, but cell (${x}, ${y}) has both ${other.type.name} and ${tile.type.name}`);
|
||||
}
|
||||
else {
|
||||
other = tile;
|
||||
}
|
||||
|
||||
if (tile.type.is_monster) {
|
||||
monster_coords.push(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
let actor_byte = null;
|
||||
let other_byte = null;
|
||||
if (actor) {
|
||||
let rev_spec = REVERSE_TILE_ENCODING[actor.type.name];
|
||||
if (rev_spec) {
|
||||
// Special case: dirt blocks only have a direction when on a cloner
|
||||
if (actor.type.name === 'dirt_block' && ! (other && other.type.name === 'cloner')) {
|
||||
actor_byte = rev_spec['all'];
|
||||
}
|
||||
else {
|
||||
actor_byte = rev_spec[actor.direction];
|
||||
}
|
||||
}
|
||||
else {
|
||||
errors.push(`Can't encode tile: ${actor.type.name}`);
|
||||
}
|
||||
}
|
||||
if (other) {
|
||||
let rev_spec = REVERSE_TILE_ENCODING[other.type.name];
|
||||
if (rev_spec) {
|
||||
// Special case: thin walls only come in one of a few configurations
|
||||
if (other.type.name === 'thin_walls') {
|
||||
if (other.edges in rev_spec) {
|
||||
other_byte = rev_spec[other.edges];
|
||||
}
|
||||
else {
|
||||
errors.push(`Thin walls may only have one edge, or be a lower-right corner`);
|
||||
}
|
||||
}
|
||||
else {
|
||||
other_byte = rev_spec['all'];
|
||||
}
|
||||
|
||||
if (other.type.name === 'hint') {
|
||||
if (hint_text === null) {
|
||||
hint_text = other.hint_text;
|
||||
}
|
||||
else if (hint_text !== other.hint_text) {
|
||||
errors.push(`All hints must contain the same text`);
|
||||
}
|
||||
}
|
||||
|
||||
let cxn_target;
|
||||
// FIXME one begins to wonder if the lady doth repeat herself
|
||||
if (other.type.name === 'button_red') {
|
||||
cxn_target = 'cloner';
|
||||
}
|
||||
else if (other.type.name === 'button_brown') {
|
||||
cxn_target = 'trap';
|
||||
}
|
||||
if (cxn_target && stored_level.custom_connections.has(i)) {
|
||||
let dest = stored_level.custom_connections.get(i);
|
||||
let dest_cell = stored_level.linear_cells[dest];
|
||||
// FIXME these need to be sorted by destination actually
|
||||
if (dest_cell && dest_cell[LAYERS.terrain].type.name === cxn_target) {
|
||||
if (other.type.name === 'button_red') {
|
||||
cloner_cxns.push(x, y, ...stored_level.scalar_to_coords(dest));
|
||||
}
|
||||
else {
|
||||
// Traps have an extra zero!
|
||||
trap_cxns.push(x, y, ...stored_level.scalar_to_coords(dest), 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
errors.push(`Can't encode tile: ${other.type.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (other_byte === null) {
|
||||
other_byte = 0x00; // floor
|
||||
}
|
||||
|
||||
if (actor_byte === null) {
|
||||
top_layer.push(other_byte);
|
||||
bottom_layer.push(0x00);
|
||||
}
|
||||
else {
|
||||
top_layer.push(actor_byte);
|
||||
bottom_layer.push(other_byte);
|
||||
}
|
||||
}
|
||||
if (error_found_wires) {
|
||||
errors.push(`Wires are not supported`);
|
||||
}
|
||||
|
||||
// TODO RLE
|
||||
let top_layer_bytes = top_layer;
|
||||
let bottom_layer_bytes = bottom_layer;
|
||||
|
||||
// Assemble metadata fields. You'd think this would deserve a little wrapper like I have for
|
||||
// the C2M sections, but you're wrong!
|
||||
let metadata_blocks = [];
|
||||
let metadata_length = 0;
|
||||
function add_block(type, contents) {
|
||||
let len = 2 + contents.byteLength;
|
||||
let bytes = new Uint8Array(len);
|
||||
// TODO this copy is annoying
|
||||
bytes[0] = type;
|
||||
bytes[1] = contents.byteLength;
|
||||
bytes.set(new Uint8Array(contents), 2);
|
||||
metadata_blocks.push(bytes);
|
||||
metadata_length += len;
|
||||
}
|
||||
|
||||
// Level name
|
||||
// TODO do something with not-ascii; does TW support utf8 or latin1 or anything?
|
||||
add_block(3, util.bytestring_to_buffer(stored_level.title.substring(0, 63) + "\0"));
|
||||
// Trap and cloner connections
|
||||
function encode_connections(cxns) {
|
||||
let words = new ArrayBuffer(cxns.length * 2);
|
||||
let view = new DataView(words);
|
||||
for (let [i, val] of cxns.entries()) {
|
||||
view.setUint16(i * 2, val, true);
|
||||
}
|
||||
return words;
|
||||
}
|
||||
if (trap_cxns.length > 0) {
|
||||
add_block(4, encode_connections(trap_cxns));
|
||||
}
|
||||
if (cloner_cxns.length > 0) {
|
||||
add_block(5, encode_connections(cloner_cxns));
|
||||
}
|
||||
// Password
|
||||
// TODO support this for real lol
|
||||
add_block(6, util.bytestring_to_buffer("XXXX\0"));
|
||||
// Hint
|
||||
// TODO tile world seems to do latin-1 (just sort of, inherently); this will do modulo on
|
||||
// anything outside it (yyyyikes!), probably should sub with ? or something
|
||||
if (hint_text !== null) {
|
||||
add_block(7, util.bytestring_to_buffer(hint_text.substring(0, 127) + "\0"));
|
||||
}
|
||||
// EXTENSION: Author's name, if present
|
||||
if (stored_level.author) {
|
||||
add_block(9, util.bytestring_to_buffer(stored_level.author.substring(0, 255) + "\0"));
|
||||
}
|
||||
// Monster positions (dumb as hell and only used in MS mode)
|
||||
if (monster_coords.length > 0) {
|
||||
if (monster_coords.length > 256) {
|
||||
errors.push(`Level has ${monster_coords.length >> 1} monsters, but MS only supports up to 128`);
|
||||
monster_coords.length = 256;
|
||||
}
|
||||
add_block(10, new Uint8Array(monster_coords).buffer);
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new CCLEncodingErrors(errors);
|
||||
}
|
||||
|
||||
// OK, almost done, serialize for real
|
||||
let level_length = (
|
||||
10 + // level header
|
||||
2 + top_layer_bytes.length +
|
||||
2 + bottom_layer_bytes.length +
|
||||
2 + metadata_length
|
||||
);
|
||||
let total_length = (
|
||||
6 + // game header
|
||||
level_length
|
||||
);
|
||||
let ret = new ArrayBuffer(total_length);
|
||||
let array = new Uint8Array(ret);
|
||||
let view = new DataView(ret);
|
||||
view.setUint32(0, magic, true);
|
||||
view.setUint16(4, 1, true); // level count, teehee
|
||||
|
||||
let p = 6;
|
||||
view.setUint16(p, level_length - 2, true); // doesn't include this field
|
||||
view.setUint16(p + 2, 1, true); // level number
|
||||
view.setUint16(p + 4, stored_level.time_limit, true);
|
||||
view.setUint16(p + 6, stored_level.chips_required || 0, true); // FIXME
|
||||
view.setUint16(p + 8, 1, true); // always 1? indicates compressed map data?
|
||||
p += 10;
|
||||
|
||||
// Map data
|
||||
view.setUint16(p, top_layer_bytes.length, true);
|
||||
array.set(new Uint8Array(top_layer_bytes), p + 2);
|
||||
p += 2 + top_layer_bytes.length;
|
||||
view.setUint16(p, bottom_layer_bytes.length, true);
|
||||
array.set(new Uint8Array(bottom_layer_bytes), p + 2);
|
||||
p += 2 + bottom_layer_bytes.length;
|
||||
|
||||
// Metadata
|
||||
view.setUint16(p, metadata_length, true);
|
||||
p += 2;
|
||||
for (let block of metadata_blocks) {
|
||||
array.set(block, p);
|
||||
p += block.byteLength;
|
||||
}
|
||||
|
||||
if (p !== total_length) {
|
||||
console.error("Something has gone very awry:", total_length, p);
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
164
js/format-tws.js
Normal file
@ -0,0 +1,164 @@
|
||||
import { DIRECTIONS, INPUT_BITS } from './defs.js';
|
||||
import * as format_base from './format-base.js';
|
||||
|
||||
|
||||
const TW_DIRECTION_TO_INPUT_BITS = [
|
||||
INPUT_BITS.up,
|
||||
INPUT_BITS.left,
|
||||
INPUT_BITS.down,
|
||||
INPUT_BITS.right,
|
||||
INPUT_BITS.up | INPUT_BITS.left,
|
||||
INPUT_BITS.down | INPUT_BITS.left,
|
||||
INPUT_BITS.up | INPUT_BITS.right,
|
||||
INPUT_BITS.down | INPUT_BITS.right,
|
||||
];
|
||||
|
||||
// doc: http://www.muppetlabs.com/~breadbox/software/tworld/tworldff.html#3
|
||||
export function parse_solutions(bytes) {
|
||||
let buf;
|
||||
if (bytes.buffer) {
|
||||
buf = bytes.buffer;
|
||||
}
|
||||
else {
|
||||
buf = bytes;
|
||||
bytes = new Uint8Array(buf);
|
||||
}
|
||||
let view = new DataView(buf);
|
||||
let magic = view.getUint32(0, true);
|
||||
if (magic !== 0x999b3335)
|
||||
return;
|
||||
|
||||
// 1 for lynx, 2 for ms; also extended to 3 for cc2, 4 for ll
|
||||
let ruleset = bytes[4];
|
||||
let extra_bytes = bytes[7];
|
||||
|
||||
let ret = {
|
||||
ruleset: ruleset,
|
||||
levels: [],
|
||||
};
|
||||
|
||||
let p = 8 + extra_bytes;
|
||||
let is_first = true;
|
||||
while (p < buf.byteLength) {
|
||||
let len = view.getUint32(p, true);
|
||||
p += 4;
|
||||
if (len === 0xffffffff)
|
||||
break;
|
||||
|
||||
if (len === 0) {
|
||||
// Empty, do nothing
|
||||
}
|
||||
else if (len < 6) {
|
||||
// This should never happen
|
||||
// TODO gripe?
|
||||
}
|
||||
else if (bytes[p] === 0 && bytes[p + 1] === 0 && bytes[p + 2] === 0 &&
|
||||
bytes[p + 3] === 0 && bytes[p + 5] === 0 && bytes[p + 6] === 0)
|
||||
{
|
||||
// This record is special and contains the name of the set; it's optional but, if present, must be first
|
||||
if (! is_first) {
|
||||
// TODO gripe?
|
||||
}
|
||||
}
|
||||
else if (len === 6) {
|
||||
// Short record; password only, no replay
|
||||
}
|
||||
else {
|
||||
// Long record
|
||||
let number = view.getUint16(p, true);
|
||||
// 2-5: password, don't care
|
||||
// 6: flags, always zero
|
||||
let initial_state = bytes[p + 7];
|
||||
let step_parity = initial_state >> 3;
|
||||
let initial_rff = ['north', 'west', 'south', 'east'][initial_state & 0x7];
|
||||
// In CC2 replays, the initial RFF direction is the one you'll actually start with;
|
||||
// however, in Lynx, the direction is rotated BEFORE it takes effect, so to compensate
|
||||
// we have to rotate this once ahead of time
|
||||
initial_rff = DIRECTIONS[initial_rff].right;
|
||||
let initial_rng = view.getUint32(p + 8, true);
|
||||
let total_duration = view.getUint32(p + 12, true);
|
||||
|
||||
// TODO split this off though
|
||||
let inputs = [];
|
||||
let q = p + 16;
|
||||
while (q < p + len) {
|
||||
// There are four formats for packing solutions, identified by the lowest two bits,
|
||||
// except that format 3 is actually two formats. Be aware that the documentation
|
||||
// refers to them in a different order than suggested by the identifying nybble.
|
||||
let fmt = bytes[q] & 0x3;
|
||||
let fmt2 = (bytes[q] >> 4) & 0x1;
|
||||
if (fmt === 0) {
|
||||
// "Third format": three consecutive moves packed into one byte
|
||||
let val = bytes[q];
|
||||
q += 1;
|
||||
let input1 = TW_DIRECTION_TO_INPUT_BITS[(val >> 2) & 0x3];
|
||||
let input2 = TW_DIRECTION_TO_INPUT_BITS[(val >> 4) & 0x3];
|
||||
let input3 = TW_DIRECTION_TO_INPUT_BITS[(val >> 6) & 0x3];
|
||||
inputs.push(
|
||||
0, 0, 0, input1,
|
||||
0, 0, 0, input2,
|
||||
0, 0, 0, input3,
|
||||
);
|
||||
}
|
||||
else if (fmt === 1 || fmt === 2 || (fmt === 3 && fmt2 === 0)) {
|
||||
// "First format" and "second format": one, two, or four bytes containing a
|
||||
// direction and a number of tics
|
||||
let val;
|
||||
if (fmt === 1) {
|
||||
val = bytes[q];
|
||||
q += 1;
|
||||
}
|
||||
else if (fmt === 2) {
|
||||
val = view.getUint16(q, true);
|
||||
q += 2;
|
||||
}
|
||||
else {
|
||||
val = view.getUint32(q, true);
|
||||
q += 4;
|
||||
}
|
||||
let input = TW_DIRECTION_TO_INPUT_BITS[(val >> 2) & 0x7];
|
||||
let duration = val >> 5;
|
||||
for (let i = 0; i < duration; i++) {
|
||||
inputs.push(0);
|
||||
}
|
||||
inputs.push(input);
|
||||
}
|
||||
else { // low nybble is 3, and bit 4 is set
|
||||
// "Fourth format": 2 to 5 bytes, containing an exceptionally long direction
|
||||
// field and time field, mostly used for MSCC mouse moves
|
||||
let n = ((bytes[q] >> 2) & 0x3) + 2;
|
||||
if (q + n - 1 >= bytes.length)
|
||||
throw new Error(`Malformed TWS file: expected ${n} bytes starting at ${q}, but only found ${bytes.length - q}`);
|
||||
|
||||
// Up to 5 bytes is an annoying amount, but we can cut it down to 1-4 by
|
||||
// extracting the direction first
|
||||
let input = (bytes[q] >> 5) | ((bytes[q + 1] & 0x3f) << 3);
|
||||
let duration = bytes[q + 1] >> 6;
|
||||
for (let i = 3; i <= n; i++) {
|
||||
duration |= bytes[q + i - 1] << (2 + (i - 3) * 8);
|
||||
}
|
||||
|
||||
// Mouse moves are encoded as 16 + ((y + 9) * 19) + (x + 9), but I extremely do
|
||||
// not support them at the moment (and may never), so replace them with blank
|
||||
// input for now (and possibly forever)
|
||||
if (input >= 16) {
|
||||
input = 0;
|
||||
}
|
||||
|
||||
// And now queue it up
|
||||
for (let i = 0; i < duration; i++) {
|
||||
inputs.push(input);
|
||||
}
|
||||
|
||||
q += n;
|
||||
}
|
||||
}
|
||||
|
||||
ret.levels[number - 1] = new format_base.Replay(initial_rff, 0, inputs, step_parity, initial_rng);
|
||||
}
|
||||
|
||||
is_first = false;
|
||||
p += len;
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
@ -1,47 +0,0 @@
|
||||
export function string_from_buffer_ascii(buf) {
|
||||
return String.fromCharCode.apply(null, new Uint8Array(buf));
|
||||
}
|
||||
|
||||
export class StoredCell extends Array {
|
||||
}
|
||||
|
||||
export class StoredLevel {
|
||||
constructor(number) {
|
||||
this.number = number; // one-based
|
||||
this.title = '';
|
||||
this.password = null;
|
||||
this.hint = '';
|
||||
this.chips_required = 0;
|
||||
this.time_limit = 0;
|
||||
this.viewport_size = 9;
|
||||
this.extra_chunks = [];
|
||||
this.use_cc1_boots = false;
|
||||
|
||||
this.size_x = 0;
|
||||
this.size_y = 0;
|
||||
this.linear_cells = [];
|
||||
|
||||
// Maps of button positions to trap/cloner positions, as scalar indexes
|
||||
// in the linear cell list
|
||||
this.custom_trap_wiring = {};
|
||||
this.custom_cloner_wiring = {};
|
||||
}
|
||||
|
||||
scalar_to_coords(n) {
|
||||
return [n % this.size_x, Math.floor(n / this.size_x)];
|
||||
}
|
||||
|
||||
coords_to_scalar(x, y) {
|
||||
return x + y * this.size_x;
|
||||
}
|
||||
|
||||
check() {
|
||||
}
|
||||
}
|
||||
|
||||
export class StoredGame {
|
||||
constructor(identifier) {
|
||||
this.identifier = identifier;
|
||||
this.levels = [];
|
||||
}
|
||||
}
|
||||
3429
js/game.js
592
js/headless/bulktest.mjs
Normal file
@ -0,0 +1,592 @@
|
||||
import { readFile, stat } from 'fs/promises';
|
||||
import { performance } from 'perf_hooks';
|
||||
import { argv, exit, stderr, stdout } from 'process';
|
||||
import { Worker, isMainThread, parentPort, workerData } from 'worker_threads';
|
||||
|
||||
import { compat_flags_for_ruleset } from '../defs.js';
|
||||
import { Level } from '../game.js';
|
||||
import * as format_c2g from '../format-c2g.js';
|
||||
import * as format_dat from '../format-dat.js';
|
||||
import * as format_tws from '../format-tws.js';
|
||||
import * as util from '../util.js';
|
||||
import { LocalDirectorySource } from './lib.js';
|
||||
|
||||
// TODO arguments:
|
||||
// - custom pack to test, possibly its solutions, possibly its ruleset (or default to steam-strict/lynx)
|
||||
// - filter existing packs
|
||||
// - verbose: ?
|
||||
// - quiet: hide failure reasons
|
||||
// - support for xfails somehow?
|
||||
// TODO use this for a test suite
|
||||
|
||||
|
||||
function pad(s, n) {
|
||||
return s.substring(0, n).padEnd(n, " ");
|
||||
}
|
||||
|
||||
const RESULT_TYPES = {
|
||||
pending: {
|
||||
// not a real result type, but used for the initial display
|
||||
color: "\x1b[90m",
|
||||
symbol: "?",
|
||||
},
|
||||
skipped: {
|
||||
color: "\x1b[90m",
|
||||
symbol: "-",
|
||||
},
|
||||
'no-replay': {
|
||||
color: "\x1b[90m",
|
||||
symbol: "0",
|
||||
},
|
||||
success: {
|
||||
color: "\x1b[92m",
|
||||
symbol: ".",
|
||||
},
|
||||
early: {
|
||||
color: "\x1b[96m",
|
||||
symbol: "?",
|
||||
},
|
||||
failure: {
|
||||
color: "\x1b[91m",
|
||||
symbol: "#",
|
||||
},
|
||||
'short': {
|
||||
color: "\x1b[93m",
|
||||
symbol: "#",
|
||||
},
|
||||
error: {
|
||||
color: "\x1b[95m",
|
||||
symbol: "X",
|
||||
},
|
||||
};
|
||||
const ANSI_RESET = "\x1b[39m";
|
||||
function ansi_cursor_move(dx, dy) {
|
||||
if (dx > 0) {
|
||||
stdout.write(`\x1b[${dx}C`);
|
||||
}
|
||||
else if (dx < 0) {
|
||||
stdout.write(`\x1b[${-dx}D`);
|
||||
}
|
||||
|
||||
if (dy > 0) {
|
||||
stdout.write(`\x1b[${dy}B`);
|
||||
}
|
||||
else if (dy < 0) {
|
||||
stdout.write(`\x1b[${-dy}A`);
|
||||
}
|
||||
}
|
||||
|
||||
const dummy_sfx = {
|
||||
play() {},
|
||||
play_once() {},
|
||||
};
|
||||
|
||||
function test_level(stored_level, compat) {
|
||||
let level;
|
||||
let level_start_time = performance.now();
|
||||
let make_result = (type, short_status, include_canvas) => {
|
||||
//let result_stuff = RESULT_TYPES[type];
|
||||
// XXX stdout.write(result_stuff.color + result_stuff.symbol);
|
||||
return {
|
||||
type,
|
||||
short_status,
|
||||
fail_reason: level ? level.fail_reason : null,
|
||||
time_elapsed: performance.now() - level_start_time,
|
||||
time_simulated: level ? level.tic_counter / 20 : null,
|
||||
tics_simulated: level ? level.tic_counter : null,
|
||||
};
|
||||
|
||||
// FIXME allegedly it's possible to get a canvas working in node...
|
||||
/*
|
||||
if (include_canvas && level) {
|
||||
try {
|
||||
let tileset = this.conductor.choose_tileset_for_level(level.stored_level);
|
||||
this.renderer.set_tileset(tileset);
|
||||
let canvas = mk('canvas', {
|
||||
width: Math.min(this.renderer.canvas.width, level.size_x * tileset.size_x),
|
||||
height: Math.min(this.renderer.canvas.height, level.size_y * tileset.size_y),
|
||||
});
|
||||
this.renderer.set_level(level);
|
||||
this.renderer.set_active_player(level.player);
|
||||
this.renderer.draw();
|
||||
canvas.getContext('2d').drawImage(
|
||||
this.renderer.canvas, 0, 0,
|
||||
this.renderer.canvas.width, this.renderer.canvas.height);
|
||||
tbody.append(mk('tr', mk('td.-full', {colspan: 5}, canvas)));
|
||||
}
|
||||
catch (e) {
|
||||
console.error(e);
|
||||
tbody.append(mk('tr', mk('td.-full', {colspan: 5},
|
||||
`Internal error while trying to capture screenshot: ${e}`)));
|
||||
}
|
||||
}
|
||||
*/
|
||||
};
|
||||
|
||||
let replay = stored_level.replay;
|
||||
level = new Level(stored_level, compat);
|
||||
level.sfx = dummy_sfx;
|
||||
level.undo_enabled = false; // slight performance boost
|
||||
replay.configure_level(level);
|
||||
|
||||
while (true) {
|
||||
let input = replay.get(level.tic_counter);
|
||||
level.advance_tic(input);
|
||||
|
||||
if (level.state === 'success') {
|
||||
if (level.tic_counter < replay.duration - 10) {
|
||||
// Early exit is dubious (e.g. this happened sometimes before multiple
|
||||
// players were implemented correctly)
|
||||
return make_result('early', "Won early", true);
|
||||
}
|
||||
else {
|
||||
return make_result('success', "Won");
|
||||
}
|
||||
}
|
||||
else if (level.state === 'failure') {
|
||||
return make_result('failure', "Lost", true);
|
||||
}
|
||||
else if (level.tic_counter >= replay.duration + 220) {
|
||||
// This threshold of 11 seconds was scientifically calculated by noticing that
|
||||
// the TWS of Southpole runs 11 seconds past its last input
|
||||
return make_result('short', "Out of input", true);
|
||||
}
|
||||
|
||||
if (level.tic_counter % 20 === 1) {
|
||||
// XXX
|
||||
/*
|
||||
if (handle.cancel) {
|
||||
return make_result('interrupted', "Interrupted");
|
||||
this.current_status.textContent = `Interrupted on level ${i + 1}/${num_levels}; ${num_passed} passed`;
|
||||
return;
|
||||
}
|
||||
*/
|
||||
|
||||
// Don't run for more than 100ms at a time, to avoid janking the browser...
|
||||
// TOO much. I mean, we still want it to reflow the stuff we've added, but
|
||||
// we also want to be pretty aggressive so this finishes quickly
|
||||
// XXX unnecessary headless
|
||||
/*
|
||||
let now = performance.now();
|
||||
if (now - last_pause > 100) {
|
||||
await util.sleep(4);
|
||||
last_pause = now;
|
||||
}
|
||||
*/
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stuff that's related to testing a level, but is not actually testing a level
|
||||
function test_level_wrapper(pack, level_index, compat) {
|
||||
let result;
|
||||
let stored_level;
|
||||
try {
|
||||
stored_level = pack.load_level(level_index);
|
||||
if (! stored_level.has_replay) {
|
||||
result = { type: 'no-replay', short_status: "No replay" };
|
||||
}
|
||||
else {
|
||||
result = test_level(stored_level, compat);
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
console.error(e);
|
||||
result = {
|
||||
type: 'error',
|
||||
short_status: "Error",
|
||||
time_simulated: null,
|
||||
tics_simulated: null,
|
||||
exception: e,
|
||||
};
|
||||
}
|
||||
result.level_index = level_index;
|
||||
result.time_expected = stored_level && stored_level.has_replay ? stored_level.replay.duration / 20 : null;
|
||||
result.title = stored_level ? stored_level.title : "[load error]";
|
||||
return result;
|
||||
}
|
||||
|
||||
async function _scan_source(source) {
|
||||
// FIXME copied wholesale from Splash.search_multi_source; need a real filesystem + searching api!
|
||||
|
||||
// TODO not entiiirely kosher, but not sure if we should have an api for this or what
|
||||
if (source._loaded_promise) {
|
||||
await source._loaded_promise;
|
||||
}
|
||||
|
||||
let paths = Object.keys(source.files);
|
||||
// TODO should handle having multiple candidates, but this is good enough for now
|
||||
paths.sort((a, b) => a.length - b.length);
|
||||
for (let path of paths) {
|
||||
let m = path.match(/[.]([^./]+)$/);
|
||||
if (! m)
|
||||
continue;
|
||||
|
||||
let ext = m[1];
|
||||
// TODO this can't load an individual c2m, hmmm
|
||||
if (ext === 'c2g') {
|
||||
let buf = await source.get(path);
|
||||
//await this.conductor.parse_and_load_game(buf, source, path);
|
||||
// FIXME and this is from parse_and_load_game!!
|
||||
let dir;
|
||||
if (! path.match(/[/]/)) {
|
||||
dir = '';
|
||||
}
|
||||
else {
|
||||
dir = path.replace(/[/][^/]+$/, '');
|
||||
}
|
||||
return await format_c2g.parse_game(buf, source, dir);
|
||||
}
|
||||
}
|
||||
// TODO else...? complain we couldn't find anything? list what we did find?? idk
|
||||
}
|
||||
|
||||
async function load_pack(testdef) {
|
||||
let pack;
|
||||
if ((await stat(testdef.pack_path)).isDirectory()) {
|
||||
let source = new LocalDirectorySource(testdef.pack_path);
|
||||
pack = await _scan_source(source);
|
||||
}
|
||||
else {
|
||||
let pack_data = await readFile(testdef.pack_path);
|
||||
if (testdef.pack_path.match(/[.]zip$/)) {
|
||||
let source = new util.ZipFileSource(pack_data.buffer);
|
||||
pack = await _scan_source(source);
|
||||
}
|
||||
else {
|
||||
pack = format_dat.parse_game(pack_data.buffer);
|
||||
|
||||
let solutions_data = await readFile(testdef.solutions_path);
|
||||
let solutions = format_tws.parse_solutions(solutions_data.buffer);
|
||||
pack.level_replays = solutions.levels;
|
||||
}
|
||||
}
|
||||
|
||||
if (! pack.title) {
|
||||
let match = testdef.pack_path.match(/(?:^|\/)([^/.]+)(?:\..*)?\/?$/);
|
||||
if (match) {
|
||||
pack.title = match[1];
|
||||
}
|
||||
else {
|
||||
pack.title = testdef.pack_path;
|
||||
}
|
||||
}
|
||||
|
||||
return pack;
|
||||
}
|
||||
|
||||
async function main_worker(testdef) {
|
||||
// We have to load the pack separately in every thread
|
||||
let pack = await load_pack(testdef);
|
||||
let ruleset = testdef.ruleset;
|
||||
let compat = compat_flags_for_ruleset(ruleset);
|
||||
|
||||
let t = performance.now();
|
||||
parentPort.on('message', level_index => {
|
||||
//console.log("idled for", (performance.now() - t) / 1000);
|
||||
parentPort.postMessage(test_level_wrapper(pack, level_index, compat));
|
||||
t = performance.now();
|
||||
});
|
||||
}
|
||||
|
||||
// the simplest pool in the world
|
||||
async function* run_in_thread_pool(num_workers, worker_data, items) {
|
||||
let next_index = 0;
|
||||
let workers = [];
|
||||
let result_available_resolve;
|
||||
let result_available = new Promise(resolve => {
|
||||
result_available_resolve = resolve;
|
||||
});
|
||||
for (let i = 0; i < num_workers; i++) {
|
||||
let worker = new Worker(new URL(import.meta.url), {
|
||||
workerData: worker_data,
|
||||
});
|
||||
|
||||
let waiting_on_index = null;
|
||||
let process_next = () => {
|
||||
if (next_index < items.length) {
|
||||
let item = items[next_index];
|
||||
next_index += 1;
|
||||
worker.postMessage(item);
|
||||
}
|
||||
};
|
||||
worker.on('message', result => {
|
||||
result_available_resolve(result);
|
||||
process_next();
|
||||
});
|
||||
process_next();
|
||||
|
||||
workers.push(worker);
|
||||
}
|
||||
|
||||
try {
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
let result = await result_available;
|
||||
result_available = new Promise(resolve => {
|
||||
result_available_resolve = resolve;
|
||||
});
|
||||
|
||||
yield result;
|
||||
}
|
||||
}
|
||||
finally {
|
||||
for (let worker of workers) {
|
||||
worker.terminate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// well maybe this is simpler
|
||||
async function* dont_run_in_thread_pool(num_workers, testdef, items) {
|
||||
let pack = await load_pack(testdef);
|
||||
let ruleset = testdef.ruleset;
|
||||
let compat = compat_flags_for_ruleset(ruleset);
|
||||
|
||||
for (let level_index of items) {
|
||||
yield test_level_wrapper(pack, level_index, compat);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function test_pack(testdef) {
|
||||
let pack = await load_pack(testdef);
|
||||
let ruleset = testdef.ruleset;
|
||||
let level_filter = testdef.level_filter;
|
||||
|
||||
let num_levels = pack.level_metadata.length;
|
||||
let columns = stdout.columns || 80;
|
||||
// 20 for title, 1 for space, the dots, 1 for space, 9 for succeeded/total, 1 for padding
|
||||
let title_width = 20;
|
||||
let dots_per_row = columns - title_width - 1 - 1 - 9 - 1;
|
||||
// TODO factor out the common parts maybe?
|
||||
stdout.write(pad(`${pack.title} (${ruleset})`, title_width) + " ");
|
||||
let indices = [];
|
||||
let num_dot_lines = 1;
|
||||
let previous_type = null;
|
||||
for (let i = 0; i < num_levels; i++) {
|
||||
if (i > 0 && i % dots_per_row === 0) {
|
||||
stdout.write("\n");
|
||||
stdout.write(" ".repeat(title_width + 1));
|
||||
num_dot_lines += 1;
|
||||
}
|
||||
|
||||
let type = (level_filter && ! level_filter.has(i + 1)) ? 'skipped' : 'pending';
|
||||
if (type !== previous_type) {
|
||||
stdout.write(RESULT_TYPES[type].color);
|
||||
}
|
||||
stdout.write(RESULT_TYPES[type].symbol);
|
||||
previous_type = type;
|
||||
|
||||
if (type === 'pending') {
|
||||
indices.push(i);
|
||||
}
|
||||
}
|
||||
ansi_cursor_move(0, -(num_dot_lines - 1));
|
||||
stdout.write(`\x1b[${title_width + 2}G`);
|
||||
|
||||
// We really really don't want to have only a single thread left running at the end on a single
|
||||
// remaining especially-long replay, so it would be nice to run the levels in reverse order of
|
||||
// complexity. But that sounds hard so instead just run them backwards, since the earlier
|
||||
// levels in any given pack tend to be easier.
|
||||
indices.reverse();
|
||||
|
||||
let num_passed = 0;
|
||||
let num_missing = 0;
|
||||
let total_tics = 0;
|
||||
let t0 = performance.now();
|
||||
let last_pause = t0;
|
||||
let failures = [];
|
||||
for await (let result of run_in_thread_pool(4, testdef, indices)) {
|
||||
let result_stuff = RESULT_TYPES[result.type];
|
||||
let col = result.level_index % dots_per_row;
|
||||
let row = Math.floor(result.level_index / dots_per_row);
|
||||
ansi_cursor_move(col, row);
|
||||
stdout.write(result_stuff.color + result_stuff.symbol);
|
||||
ansi_cursor_move(-(col + 1), -row);
|
||||
|
||||
if (result.tics_simulated) {
|
||||
total_tics += result.tics_simulated;
|
||||
}
|
||||
|
||||
if (result.type === 'no-replay') {
|
||||
num_missing += 1;
|
||||
}
|
||||
else if (result.type === 'success' || result.type === 'early') {
|
||||
num_passed += 1;
|
||||
}
|
||||
else {
|
||||
failures.push(result);
|
||||
}
|
||||
}
|
||||
|
||||
let total_real_elapsed = (performance.now() - t0) / 1000;
|
||||
|
||||
ansi_cursor_move(dots_per_row + 1, 0);
|
||||
stdout.write(`${ANSI_RESET} ${num_passed}/${num_levels - num_missing}`);
|
||||
ansi_cursor_move(0, num_dot_lines - 1);
|
||||
stdout.write("\n");
|
||||
failures.sort((a, b) => a.level_index - b.level_index);
|
||||
for (let failure of failures) {
|
||||
let short_status = failure.short_status;
|
||||
if (failure.type === 'failure') {
|
||||
short_status += ": ";
|
||||
short_status += failure.fail_reason;
|
||||
}
|
||||
|
||||
let parts = [
|
||||
String(failure.level_index + 1).padStart(5),
|
||||
pad(failure.title.replace(/[\r\n]+/, " "), 32),
|
||||
RESULT_TYPES[failure.type].color + pad(short_status, 20) + ANSI_RESET,
|
||||
];
|
||||
if (failure.time_simulated !== null) {
|
||||
parts.push("ran for" + util.format_duration(failure.time_simulated).padStart(6, " "));
|
||||
}
|
||||
if (failure.type === 'failure') {
|
||||
parts.push(" with" + util.format_duration(failure.time_expected - failure.time_simulated).padStart(6, " ") + " still to go");
|
||||
}
|
||||
stdout.write(parts.join(" ") + "\n");
|
||||
}
|
||||
|
||||
return {
|
||||
num_passed,
|
||||
num_missing,
|
||||
num_failed: failures.length,
|
||||
// FIXME should maybe count the thread time if we care about actual game speedup
|
||||
time_elapsed: total_real_elapsed,
|
||||
time_simulated: total_tics / 20,
|
||||
};
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
const USAGE = `\
|
||||
Usage: bulktest.mjs [OPTION]... [FILE]...
|
||||
Runs replays for the given level packs and report results.
|
||||
With no FILE given, default to the built-in copy of CC2LP1.
|
||||
|
||||
Arguments may be repeated, and apply to any subsequent pack, so different packs
|
||||
may be run with different compat modes.
|
||||
-c compatibility mode; one of
|
||||
lexy (default), steam, steam-strict, lynx, ms
|
||||
-r path to a file containing replays; for CCL/DAT packs, which
|
||||
don't support built-in replays, this must be a TWS file
|
||||
-l level range to play back; either 'all' or a string like '1-4,10'
|
||||
-f force the next argument to be interpreted as a file path, if for
|
||||
some perverse reason you have a level file named '-c'
|
||||
-h, --help ignore other arguments and show this message
|
||||
|
||||
Supports the same filetypes as Lexy's Labyrinth: DAT/CCL, C2M, or a directory
|
||||
containing a C2G.
|
||||
`;
|
||||
class ArgParseError extends Error {}
|
||||
function parse_level_range(string) {
|
||||
if (string === 'all') {
|
||||
return null;
|
||||
}
|
||||
|
||||
let res = new Set;
|
||||
let parts = string.split(/,/);
|
||||
for (let part of parts) {
|
||||
let endpoints = part.match(/^(\d+)(?:-(\d+))?$/);
|
||||
if (endpoints === null)
|
||||
throw new ArgParseError(`Bad syntax in level range: ${part}`);
|
||||
let a = parseInt(endpoints[1], 10);
|
||||
let b = endpoints[2] === undefined ? a : parseInt(endpoints[2], 10);
|
||||
if (a > b)
|
||||
throw new ArgParseError(`Backwards span in level range: ${part}`);
|
||||
for (let n = a; n <= b; n++) {
|
||||
res.add(n);
|
||||
}
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
function parse_args() {
|
||||
// Parse arguments
|
||||
let test_template = {
|
||||
ruleset: 'lexy',
|
||||
solutions_path: null,
|
||||
level_filter: null,
|
||||
};
|
||||
let tests = [];
|
||||
|
||||
try {
|
||||
let i;
|
||||
let next_arg = () => {
|
||||
i += 1;
|
||||
if (i >= argv.length)
|
||||
throw new ArgParseError(`Missing argument after ${argv[i - 1]}`);
|
||||
return argv[i];
|
||||
};
|
||||
for (i = 2; i < argv.length; i++) {
|
||||
let arg = argv[i];
|
||||
if (arg === '-h' || arg === '--help') {
|
||||
stdout.write(USAGE);
|
||||
exit(0);
|
||||
}
|
||||
|
||||
if (arg === '-c') {
|
||||
let ruleset = next_arg();
|
||||
if (['lexy', 'steam', 'steam-strict', 'lynx', 'ms'].indexOf(ruleset) === -1)
|
||||
throw new ArgParseError(`Unrecognized compat mode: ${ruleset}`);
|
||||
test_template.ruleset = ruleset;
|
||||
}
|
||||
else if (arg === '-r') {
|
||||
test_template.solutions_path = next_arg();
|
||||
}
|
||||
else if (arg === '-l') {
|
||||
test_template.level_filter = parse_level_range(next_arg());
|
||||
}
|
||||
else if (arg === '-f') {
|
||||
tests.push({ pack_path: next_arg(), ...test_template });
|
||||
}
|
||||
else {
|
||||
tests.push({ pack_path: arg, ...test_template });
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
if (e instanceof ArgParseError) {
|
||||
stderr.write(e.message);
|
||||
stderr.write("\n");
|
||||
exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
if (tests.length === 0) {
|
||||
tests.push({ pack_path: 'levels/CC2LP1.zip', ...test_template });
|
||||
}
|
||||
|
||||
return tests;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let tests = parse_args();
|
||||
|
||||
let overall = {
|
||||
num_passed: 0,
|
||||
num_missing: 0,
|
||||
num_failed: 0,
|
||||
time_elapsed: 0,
|
||||
time_simulated: 0,
|
||||
};
|
||||
for (let testdef of tests) {
|
||||
let result = await test_pack(testdef);
|
||||
for (let key of Object.keys(overall)) {
|
||||
overall[key] += result[key];
|
||||
}
|
||||
}
|
||||
|
||||
let num_levels = overall.num_passed + overall.num_failed + overall.num_missing;
|
||||
stdout.write("\n");
|
||||
stdout.write(`${overall.num_passed}/${num_levels} = ${(overall.num_passed / num_levels * 100).toFixed(1)}% passed (${overall.num_failed} failed, ${overall.num_missing} missing replay)\n`);
|
||||
stdout.write(`Simulated ${util.format_duration(overall.time_simulated)} of game time in ${util.format_duration(overall.time_elapsed)}, speed of ${(overall.time_simulated / overall.time_elapsed).toFixed(1)}×\n`);
|
||||
|
||||
}
|
||||
|
||||
if (isMainThread) {
|
||||
main();
|
||||
}
|
||||
else {
|
||||
main_worker(workerData);
|
||||
}
|
||||
50
js/headless/lib.js
Normal file
@ -0,0 +1,50 @@
|
||||
import { opendir, readFile } from 'fs/promises';
|
||||
|
||||
//import canvas from 'canvas';
|
||||
|
||||
//import CanvasRenderer from '../renderer-canvas.js';
|
||||
import * as util from '../util.js';
|
||||
|
||||
/*
|
||||
export class NodeCanvasRenderer extends CanvasRenderer {
|
||||
static make_canvas(w, h) {
|
||||
return canvas.createCanvas(w, h);
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
export class LocalDirectorySource extends util.FileSource {
|
||||
constructor(root) {
|
||||
super();
|
||||
this.root = root;
|
||||
this.files = {};
|
||||
this._loaded_promise = this._scan_dir('/');
|
||||
}
|
||||
|
||||
async _scan_dir(path) {
|
||||
let dir = await opendir(this.root + path);
|
||||
for await (let dirent of dir) {
|
||||
if (dirent.isDirectory()) {
|
||||
await this._scan_dir(path + dirent.name + '/');
|
||||
}
|
||||
else {
|
||||
let filepath = path + dirent.name;
|
||||
this.files[filepath.toLowerCase()] = filepath;
|
||||
if (this.files.size > 2000)
|
||||
throw `way, way too many files in local directory source ${this.root}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async get(path) {
|
||||
let realpath = this.files[path.toLowerCase()];
|
||||
if (realpath) {
|
||||
return (await readFile(this.root + realpath)).buffer;
|
||||
}
|
||||
else {
|
||||
throw new Error(`No such file: ${path}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
70
js/headless/render.mjs
Normal file
@ -0,0 +1,70 @@
|
||||
import { readFile, writeFile } from 'fs/promises';
|
||||
import * as process from 'process';
|
||||
|
||||
import canvas from 'canvas';
|
||||
import minimist from 'minimist';
|
||||
|
||||
import * as format_c2g from '../format-c2g.js';
|
||||
import { infer_tileset_from_image } from '../tileset.js';
|
||||
import { NodeCanvasRenderer } from './lib.js';
|
||||
|
||||
|
||||
const USAGE = `\
|
||||
Usage: render.mjs [OPTION]... LEVELFILE OUTFILE
|
||||
Renders the level contained in LEVELFILE to a PNG and saves it to OUTFILE.
|
||||
|
||||
Arguments:
|
||||
-t FILE path to a tileset to use
|
||||
-e render in editor mode: use the revealed forms of tiles and
|
||||
show facing directions
|
||||
-l NUM choose the level number to render, if LEVELFILE is a pack
|
||||
[default: 1]
|
||||
-r REGION specify the region to render; see below
|
||||
|
||||
REGION may be one of:
|
||||
initial an area the size of the level's viewport, centered on the
|
||||
player's initial position
|
||||
all the entire level
|
||||
WxH an area W by H, centered on the player's initial position
|
||||
...etc...
|
||||
`;
|
||||
async function main() {
|
||||
let args = minimist(process.argv.slice(2), {
|
||||
alias: {
|
||||
tileset: ['t'],
|
||||
},
|
||||
});
|
||||
// assert _.length is 2
|
||||
let [pack_path, dest_path] = args._;
|
||||
|
||||
// TODO i need a more consistent and coherent way to turn a path into a level pack, currently
|
||||
// this is only a single c2m
|
||||
let pack_data = await readFile(pack_path);
|
||||
let stored_level = format_c2g.parse_level(pack_data.buffer);
|
||||
|
||||
let img = await canvas.loadImage(args.tileset ?? 'tileset-lexy.png');
|
||||
let tileset = infer_tileset_from_image(img);
|
||||
let renderer = new NodeCanvasRenderer(tileset);
|
||||
renderer.set_level(stored_level);
|
||||
|
||||
let i = stored_level.linear_cells.findIndex(cell => cell.some(tile => tile && tile.type.is_real_player));
|
||||
if (i < 0) {
|
||||
console.log("???");
|
||||
process.stderr.write("error: no players in this level\n");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let [x, y] = stored_level.scalar_to_coords(i);
|
||||
let w = stored_level.viewport_size;
|
||||
let h = w;
|
||||
|
||||
// TODO this is probably duplicated from the renderer, and could also be reused in the editor
|
||||
// TODO handle a map smaller than the viewport
|
||||
let x0 = Math.max(0, x - w / 2);
|
||||
let y0 = Math.max(0, y - h / 2);
|
||||
renderer.draw_static_region(x0, y0, x0 + w, y0 + h);
|
||||
|
||||
await writeFile(dest_path, renderer.canvas.toBuffer());
|
||||
}
|
||||
|
||||
main();
|
||||
304
js/main-base.js
Normal file
@ -0,0 +1,304 @@
|
||||
import { mk, mk_svg } from './util.js';
|
||||
|
||||
// Superclass for the main display modes: the player, the editor, and the splash screen
|
||||
export class PrimaryView {
|
||||
constructor(conductor, root) {
|
||||
this.conductor = conductor;
|
||||
this.root = root;
|
||||
this.active = false;
|
||||
this._done_setup = false;
|
||||
}
|
||||
|
||||
setup() {}
|
||||
|
||||
activate() {
|
||||
this.root.removeAttribute('hidden');
|
||||
this.active = true;
|
||||
if (! this._done_setup) {
|
||||
this.setup();
|
||||
this._done_setup = true;
|
||||
}
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
this.root.setAttribute('hidden', '');
|
||||
this.active = false;
|
||||
}
|
||||
|
||||
reload_options(options) {}
|
||||
}
|
||||
|
||||
// Stackable modal overlay of some kind, usually a dialog
|
||||
export class Overlay {
|
||||
constructor(conductor, root) {
|
||||
this.conductor = conductor;
|
||||
this.root = root;
|
||||
// Make the dialog itself focusable; this makes a lot of stuff easier, like ensuring that
|
||||
// pressing Esc always has a viable target
|
||||
this.root.tabIndex = 0;
|
||||
|
||||
// Don't propagate clicks on the root element, so they won't trigger a parent overlay's
|
||||
// automatic dismissal
|
||||
this.root.addEventListener('click', ev => {
|
||||
ev.stopPropagation();
|
||||
});
|
||||
// Don't propagate keys, either. This is only a partial solution (for when something within
|
||||
// the dialog has focus), but open() adds another handler to block keys more aggressively
|
||||
this.root.addEventListener('keydown', ev => {
|
||||
ev.stopPropagation();
|
||||
|
||||
if (ev.key === 'Escape') {
|
||||
this.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
open() {
|
||||
if (this.root.isConnected) {
|
||||
this.close();
|
||||
}
|
||||
|
||||
if (this.conductor.player.state === 'playing') {
|
||||
this.conductor.player.set_state('paused');
|
||||
}
|
||||
|
||||
let overlay = mk('div.overlay', this.root);
|
||||
document.body.append(overlay);
|
||||
|
||||
// Remove the overlay when clicking outside the element
|
||||
overlay.addEventListener('click', ev => {
|
||||
this.close();
|
||||
});
|
||||
|
||||
// Start with the overlay itself focused
|
||||
this.root.focus();
|
||||
|
||||
// While this dialog is open, keys should not reach the rest of the document, and you should
|
||||
// not be able to tab your way out of it. This is a rough implementation of that.
|
||||
// Note that focusin bubbles, but focus doesn't. Also, focusin happens /just before/ an
|
||||
// element receives focus, not afterwards, but that doesn't seem to affect this.
|
||||
this.focusin_handler = ev => {
|
||||
// If we're no longer visible at all, remove this event handler
|
||||
if (! this.root.isConnected) {
|
||||
this._remove_global_event_handlers();
|
||||
return;
|
||||
}
|
||||
// If we're not the topmost overlay, do nothing
|
||||
if (this.root.parentNode.nextElementSibling)
|
||||
return;
|
||||
|
||||
// No problem if the focus is within the dialog, OR on the root <html> element
|
||||
if (ev.target === document.documentElement || this.root.contains(ev.target)) {
|
||||
this.last_focused = ev.target;
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, focus is trying to escape! Put a stop to that.
|
||||
// Focus was probably moved with tab or shift-tab. We should be the last element in the
|
||||
// document, so tabbing off the end of us should go to browser UI. Shift-tabbing back
|
||||
// beyond the start of a document usually goes to the root (and after that, browser UI
|
||||
// again). Thus, there are only two common cases here: if the last valid focus was on
|
||||
// the document root, they must be tabbing forwards, so focus our first element; if the
|
||||
// last valid focus was within us, they must be tabbing backwards, so focus the root.
|
||||
if (this.last_focused === document.documentElement) {
|
||||
this.root.focus();
|
||||
this.last_focused = this.root;
|
||||
}
|
||||
else {
|
||||
document.documentElement.focus();
|
||||
this.last_focused = document.documentElement;
|
||||
}
|
||||
};
|
||||
window.addEventListener('focusin', this.focusin_handler);
|
||||
|
||||
// Block any keypresses attempting to go to an element outside the dialog
|
||||
this.keydown_handler = ev => {
|
||||
// If we're no longer visible at all, remove this event handler
|
||||
if (! this.root.isConnected) {
|
||||
this._remove_global_event_handlers();
|
||||
return;
|
||||
}
|
||||
// If we're not the topmost overlay, do nothing
|
||||
if (this.root.parentNode.nextElementSibling)
|
||||
return;
|
||||
|
||||
// Note that if the target is the window itself, contains() will explode
|
||||
if (! (ev.target instanceof Node && this.root.contains(ev.target))) {
|
||||
ev.stopPropagation();
|
||||
}
|
||||
};
|
||||
// Use capture, which runs before any other event handler
|
||||
window.addEventListener('keydown', this.keydown_handler, true);
|
||||
|
||||
// Block mouse movement as well
|
||||
overlay.addEventListener('mousemove', ev => {
|
||||
ev.stopPropagation();
|
||||
});
|
||||
|
||||
return overlay;
|
||||
}
|
||||
|
||||
_remove_global_event_handlers() {
|
||||
window.removeEventListener('focusin', this.focusin_handler);
|
||||
window.removeEventListener('keydown', this.keydown_handler, true);
|
||||
}
|
||||
|
||||
close() {
|
||||
this._remove_global_event_handlers();
|
||||
this.root.closest('.overlay').remove();
|
||||
if (document.activeElement) {
|
||||
// The active element is almost certainly either the dialog or a control within it,
|
||||
// which is useless as a focus target, so blur it and let the page have focus
|
||||
document.activeElement.blur();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Overlay styled like a popup of some sort
|
||||
export class TransientOverlay extends Overlay {
|
||||
open() {
|
||||
// TODO i don't like how vaguely arbitrary this feels.
|
||||
let overlay = super.open();
|
||||
overlay.classList.add('--transient');
|
||||
return overlay;
|
||||
}
|
||||
}
|
||||
|
||||
export class MenuOverlay extends TransientOverlay {
|
||||
constructor(conductor, items, make_label, onclick) {
|
||||
super(conductor, mk('ol.popup-menu'));
|
||||
for (let [i, item] of items.entries()) {
|
||||
this.root.append(mk('li', {'data-index': i}, make_label(item)));
|
||||
}
|
||||
|
||||
this.root.addEventListener('click', ev => {
|
||||
let li = ev.target.closest('li');
|
||||
if (! li || ! this.root.contains(li))
|
||||
return;
|
||||
|
||||
let i = parseInt(li.getAttribute('data-index'), 10);
|
||||
let item = items[i];
|
||||
onclick(item);
|
||||
this.close();
|
||||
});
|
||||
}
|
||||
|
||||
open(relto) {
|
||||
super.open();
|
||||
|
||||
let anchor = relto.getBoundingClientRect();
|
||||
let rect = this.root.getBoundingClientRect();
|
||||
|
||||
// Prefer left anchoring, but use right if that would go off the screen
|
||||
if (anchor.left + rect.width > document.body.clientWidth) {
|
||||
this.root.style.right = `${document.body.clientWidth - anchor.right}px`;
|
||||
}
|
||||
else {
|
||||
this.root.style.left = `${anchor.left}px`;
|
||||
}
|
||||
|
||||
// Open vertically in whichever direction has more space (with a slight bias towards opening
|
||||
// downwards). If we would then run off the screen, also set the other anchor to constrain
|
||||
// the height.
|
||||
let top_space = anchor.top - 0;
|
||||
let bottom_space = document.body.clientHeight - anchor.bottom;
|
||||
if (top_space > bottom_space) {
|
||||
this.root.style.bottom = `${document.body.clientHeight - anchor.top}px`;
|
||||
if (rect.height > top_space) {
|
||||
this.root.style.top = `${0}px`;
|
||||
}
|
||||
}
|
||||
else {
|
||||
this.root.style.top = `${anchor.bottom}px`;
|
||||
if (rect.height > bottom_space) {
|
||||
this.root.style.bottom = `${0}px`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Overlay styled like a dialog box
|
||||
export class DialogOverlay extends Overlay {
|
||||
constructor(conductor) {
|
||||
super(conductor, mk('form.dialog'));
|
||||
|
||||
this.root.append(
|
||||
this.header = mk('header'),
|
||||
this.main = mk('section'),
|
||||
this.footer = mk('footer'),
|
||||
);
|
||||
}
|
||||
|
||||
set_title(title) {
|
||||
this.header.textContent = '';
|
||||
this.header.append(mk('h1', {}, title));
|
||||
}
|
||||
|
||||
add_button(label, onclick, is_default) {
|
||||
let button = mk('button', {type: 'button'}, label);
|
||||
if (is_default) {
|
||||
button.classList.add('button-bright');
|
||||
}
|
||||
button.addEventListener('click', onclick);
|
||||
this.footer.append(button);
|
||||
return button;
|
||||
}
|
||||
|
||||
add_button_gap() {
|
||||
this.footer.append(mk('div.-spacer'));
|
||||
}
|
||||
}
|
||||
|
||||
// Informational popup dialog
|
||||
export class AlertOverlay extends DialogOverlay {
|
||||
constructor(conductor, message, title = "heads up") {
|
||||
super(conductor);
|
||||
this.set_title(title);
|
||||
this.main.append(mk('p', {}, message));
|
||||
this.add_button("a'ight", () => {
|
||||
this.close();
|
||||
}, true);
|
||||
}
|
||||
}
|
||||
|
||||
// Yes/no popup dialog
|
||||
export class ConfirmOverlay extends DialogOverlay {
|
||||
constructor(conductor, message, what) {
|
||||
super(conductor);
|
||||
this.set_title("just checking");
|
||||
this.main.append(mk('p', {}, message));
|
||||
this.add_button("yep", ev => {
|
||||
this.close();
|
||||
what();
|
||||
}, true);
|
||||
this.add_button("nope", ev => {
|
||||
this.close();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function flash_button(button) {
|
||||
button.classList.add('--button-glow-ok');
|
||||
window.setTimeout(() => {
|
||||
button.classList.add('--button-glow');
|
||||
button.classList.remove('--button-glow-ok');
|
||||
}, 10);
|
||||
window.setTimeout(() => {
|
||||
button.classList.remove('--button-glow');
|
||||
}, 500);
|
||||
}
|
||||
|
||||
export function svg_icon(name) {
|
||||
return mk_svg(
|
||||
'svg.svg-icon',
|
||||
{viewBox: '0 0 16 16'},
|
||||
mk_svg('use', {href: `#svg-icon-${name}`}));
|
||||
}
|
||||
|
||||
export function load_json_from_storage(key) {
|
||||
return JSON.parse(window.localStorage.getItem(key));
|
||||
}
|
||||
|
||||
export function save_json_to_storage(key, value) {
|
||||
window.localStorage.setItem(key, JSON.stringify(value));
|
||||
}
|
||||
4979
js/main.js
@ -1,7 +1,36 @@
|
||||
import { DIRECTIONS } from './defs.js';
|
||||
import { mk } from './util.js';
|
||||
import { DIRECTIONS, LAYERS } from './defs.js';
|
||||
import * as util from './util.js';
|
||||
import { DrawPacket } from './tileset.js';
|
||||
import TILE_TYPES from './tiletypes.js';
|
||||
|
||||
export class CanvasDrawPacket extends DrawPacket {
|
||||
constructor(tileset, ctx, perception, hide_logic, clock, update_progress, update_rate) {
|
||||
super(perception, hide_logic, clock, update_progress, update_rate);
|
||||
this.tileset = tileset;
|
||||
this.ctx = ctx;
|
||||
// Canvas position of the cell being drawn
|
||||
this.x = 0;
|
||||
this.y = 0;
|
||||
// Offset within the cell, for actors in motion
|
||||
this.offsetx = 0;
|
||||
this.offsety = 0;
|
||||
}
|
||||
|
||||
blit(tx, ty, mx = 0, my = 0, mw = 1, mh = mw, mdx = mx, mdy = my) {
|
||||
this.tileset.blit_to_canvas(this.ctx,
|
||||
tx + mx, ty + my,
|
||||
this.x + this.offsetx + mdx, this.y + this.offsety + mdy,
|
||||
mw, mh);
|
||||
}
|
||||
|
||||
blit_aligned(tx, ty, mx = 0, my = 0, mw = 1, mh = mw, mdx = mx, mdy = my) {
|
||||
this.tileset.blit_to_canvas(this.ctx,
|
||||
tx + mx, ty + my,
|
||||
this.x + mdx, this.y + mdy,
|
||||
mw, mh);
|
||||
}
|
||||
}
|
||||
|
||||
export class CanvasRenderer {
|
||||
constructor(tileset, fixed_size = null) {
|
||||
this.tileset = tileset;
|
||||
@ -11,7 +40,6 @@ export class CanvasRenderer {
|
||||
// to do so, but then we wouldn't make a canvas so it couldn't be
|
||||
// hooked, yadda yadda
|
||||
if (fixed_size) {
|
||||
this.viewport_is_fixed = true;
|
||||
this.viewport_size_x = fixed_size;
|
||||
this.viewport_size_y = fixed_size;
|
||||
}
|
||||
@ -19,13 +47,68 @@ export class CanvasRenderer {
|
||||
this.viewport_size_x = 9;
|
||||
this.viewport_size_y = 9;
|
||||
}
|
||||
this.canvas = mk('canvas', {width: tileset.size_x * this.viewport_size_x, height: tileset.size_y * this.viewport_size_y});
|
||||
this.canvas.style.setProperty('--viewport-width', this.viewport_size_x);
|
||||
this.canvas.style.setProperty('--viewport-height', this.viewport_size_y);
|
||||
this.canvas = this.constructor.make_canvas(
|
||||
tileset.size_x * this.viewport_size_x,
|
||||
tileset.size_y * this.viewport_size_y);
|
||||
if (this.canvas.style) {
|
||||
this.canvas.style.setProperty('--viewport-width', this.viewport_size_x);
|
||||
this.canvas.style.setProperty('--viewport-height', this.viewport_size_y);
|
||||
this.canvas.style.setProperty('--tile-width', `${tileset.size_x}px`);
|
||||
this.canvas.style.setProperty('--tile-height', `${tileset.size_y}px`);
|
||||
}
|
||||
this.ctx = this.canvas.getContext('2d');
|
||||
this.viewport_x = 0;
|
||||
this.viewport_y = 0;
|
||||
this.viewport_dirty = false;
|
||||
this.show_actor_bboxes = false;
|
||||
this.show_actor_order = false;
|
||||
this.show_facing = false;
|
||||
this.use_rewind_effect = false;
|
||||
this.perception = 'normal'; // normal, xray, editor, palette
|
||||
this.hide_logic = false;
|
||||
this.update_rate = 3;
|
||||
this.use_cc2_anim_speed = false;
|
||||
this.active_player = null;
|
||||
}
|
||||
|
||||
// This is here so command-line Node stuff can swap it out for the canvas package
|
||||
static make_canvas(w, h) {
|
||||
return util.mk('canvas', {width: w, height: h});
|
||||
}
|
||||
|
||||
// Draw a single tile, or even the name of a tile type. Either a canvas or a context may be given.
|
||||
// If neither is given, a new canvas is returned.
|
||||
static draw_single_tile(tileset, name_or_tile, canvas = null, x = 0, y = 0) {
|
||||
let ctx;
|
||||
if (! canvas) {
|
||||
canvas = this.make_canvas(tileset.size_x, tileset.size_y);
|
||||
ctx = canvas.getContext('2d');
|
||||
}
|
||||
else if (canvas instanceof CanvasRenderingContext2D) {
|
||||
ctx = canvas;
|
||||
canvas = ctx.canvas;
|
||||
}
|
||||
else {
|
||||
ctx = canvas.getContext('2d');
|
||||
}
|
||||
|
||||
let name, tile;
|
||||
if (typeof name_or_tile === 'string' || name_or_tile instanceof String) {
|
||||
name = name_or_tile;
|
||||
tile = null;
|
||||
}
|
||||
else {
|
||||
tile = name_or_tile;
|
||||
name = tile.type.name;
|
||||
}
|
||||
|
||||
// Individual tile types always reveal what they are
|
||||
let packet = new CanvasDrawPacket(tileset, ctx, 'palette');
|
||||
packet.x = x;
|
||||
packet.y = y;
|
||||
tileset.draw_type(name, tile, packet);
|
||||
|
||||
return canvas;
|
||||
}
|
||||
|
||||
set_level(level) {
|
||||
@ -33,6 +116,34 @@ export class CanvasRenderer {
|
||||
// TODO update viewport size... or maybe Game should do that since you might be cheating
|
||||
}
|
||||
|
||||
set_active_player(actor) {
|
||||
this.active_player = actor;
|
||||
}
|
||||
|
||||
// Change the viewport size. DOES NOT take effect until the next redraw!
|
||||
set_viewport_size(x, y) {
|
||||
this.viewport_size_x = x;
|
||||
this.viewport_size_y = y;
|
||||
this.viewport_dirty = true;
|
||||
}
|
||||
|
||||
set_tileset(tileset) {
|
||||
this.tileset = tileset;
|
||||
this.viewport_dirty = true;
|
||||
}
|
||||
|
||||
get_cell_rect(x, y) {
|
||||
let rect = this.canvas.getBoundingClientRect();
|
||||
let scale_x = rect.width / this.canvas.width;
|
||||
let scale_y = rect.height / this.canvas.height;
|
||||
let tile_w = scale_x * this.tileset.size_x;
|
||||
let tile_h = scale_y * this.tileset.size_y;
|
||||
return new DOMRect(
|
||||
rect.x + (x - this.viewport_x) * tile_w,
|
||||
rect.y + (y - this.viewport_y) * tile_h,
|
||||
tile_w, tile_h);
|
||||
}
|
||||
|
||||
cell_coords_from_event(ev) {
|
||||
let rect = this.canvas.getBoundingClientRect();
|
||||
let scale_x = rect.width / this.canvas.width;
|
||||
@ -42,23 +153,66 @@ export class CanvasRenderer {
|
||||
return [x, y];
|
||||
}
|
||||
|
||||
// Draw to a canvas using tile coordinates
|
||||
blit(ctx, sx, sy, dx, dy, w = 1, h = w) {
|
||||
let tw = this.tileset.size_x;
|
||||
let th = this.tileset.size_y;
|
||||
ctx.drawImage(
|
||||
this.tileset.image,
|
||||
sx * tw, sy * th, w * tw, h * th,
|
||||
dx * tw, dy * th, w * tw, h * th);
|
||||
point_to_cell_coords(client_x, client_y) {
|
||||
let rect = this.canvas.getBoundingClientRect();
|
||||
let scale_x = rect.width / this.canvas.width;
|
||||
let scale_y = rect.height / this.canvas.height;
|
||||
let x = Math.floor((client_x - rect.x) / scale_x / this.tileset.size_x + this.viewport_x);
|
||||
let y = Math.floor((client_y - rect.y) / scale_y / this.tileset.size_y + this.viewport_y);
|
||||
return [x, y];
|
||||
}
|
||||
|
||||
draw(tic_offset = 0) {
|
||||
real_cell_coords_from_event(ev) {
|
||||
let rect = this.canvas.getBoundingClientRect();
|
||||
let scale_x = rect.width / this.canvas.width;
|
||||
let scale_y = rect.height / this.canvas.height;
|
||||
let x = (ev.clientX - rect.x) / scale_x / this.tileset.size_x + this.viewport_x;
|
||||
let y = (ev.clientY - rect.y) / scale_y / this.tileset.size_y + this.viewport_y;
|
||||
return [x, y];
|
||||
}
|
||||
|
||||
point_to_real_cell_coords(client_x, client_y) {
|
||||
let rect = this.canvas.getBoundingClientRect();
|
||||
let scale_x = rect.width / this.canvas.width;
|
||||
let scale_y = rect.height / this.canvas.height;
|
||||
let x = (client_x - rect.x) / scale_x / this.tileset.size_x + this.viewport_x;
|
||||
let y = (client_y - rect.y) / scale_y / this.tileset.size_y + this.viewport_y;
|
||||
return [x, y];
|
||||
}
|
||||
|
||||
_adjust_viewport_if_dirty() {
|
||||
if (! this.viewport_dirty)
|
||||
return;
|
||||
|
||||
this.viewport_dirty = false;
|
||||
this.canvas.width = this.tileset.size_x * this.viewport_size_x;
|
||||
this.canvas.height = this.tileset.size_y * this.viewport_size_y;
|
||||
if (this.canvas.style) {
|
||||
this.canvas.style.setProperty('--viewport-width', this.viewport_size_x);
|
||||
this.canvas.style.setProperty('--viewport-height', this.viewport_size_y);
|
||||
this.canvas.style.setProperty('--tile-width', `${this.tileset.size_x}px`);
|
||||
this.canvas.style.setProperty('--tile-height', `${this.tileset.size_y}px`);
|
||||
}
|
||||
}
|
||||
|
||||
draw(update_progress = 0) {
|
||||
if (! this.level) {
|
||||
console.warn("CanvasRenderer.draw: No level to render");
|
||||
return;
|
||||
}
|
||||
|
||||
let tic = (this.level.tic_counter ?? 0) + tic_offset;
|
||||
this._adjust_viewport_if_dirty();
|
||||
|
||||
// Compute the effective current time. Note that this might come out negative before the
|
||||
// game starts, because we're trying to interpolate backwards from 0, hence the Math.max()
|
||||
let clock = (this.level.tic_counter ?? 0) + (
|
||||
(this.level.frame_offset ?? 0) + (update_progress - 1) * this.update_rate) / 3;
|
||||
let packet = new CanvasDrawPacket(
|
||||
this.tileset, this.ctx, this.perception, this.hide_logic,
|
||||
Math.max(0, clock), update_progress, this.update_rate);
|
||||
packet.use_cc2_anim_speed = this.use_cc2_anim_speed;
|
||||
packet.show_facing = this.show_facing;
|
||||
|
||||
let tw = this.tileset.size_x;
|
||||
let th = this.tileset.size_y;
|
||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
@ -67,17 +221,22 @@ export class CanvasRenderer {
|
||||
// TODO what about levels smaller than the viewport...? shrink the canvas in set_level?
|
||||
let xmargin = (this.viewport_size_x - 1) / 2;
|
||||
let ymargin = (this.viewport_size_y - 1) / 2;
|
||||
let px, py;
|
||||
// FIXME editor vs player
|
||||
if (this.level.player) {
|
||||
[px, py] = this.level.player.visual_position(tic_offset);
|
||||
}
|
||||
else {
|
||||
[px, py] = [0, 0];
|
||||
}
|
||||
let [px, py] = this.level.player.visual_position(update_progress, packet.update_rate);
|
||||
// Figure out where to start drawing
|
||||
let x0 = Math.max(0, Math.min(this.level.size_x - this.viewport_size_x, px - xmargin));
|
||||
let y0 = Math.max(0, Math.min(this.level.size_y - this.viewport_size_y, py - ymargin));
|
||||
// TODO support overlapping regions better
|
||||
let x0 = px - xmargin;
|
||||
let y0 = py - ymargin;
|
||||
for (let region of this.level.stored_level.camera_regions) {
|
||||
if (px >= region.left && px < region.right &&
|
||||
py >= region.top && py < region.bottom)
|
||||
{
|
||||
x0 = Math.max(region.left, Math.min(region.right - this.viewport_size_x, x0));
|
||||
y0 = Math.max(region.top, Math.min(region.bottom - this.viewport_size_y, y0));
|
||||
}
|
||||
}
|
||||
// Always keep us within the map bounds
|
||||
x0 = Math.max(0, Math.min(this.level.size_x - this.viewport_size_x, x0));
|
||||
y0 = Math.max(0, Math.min(this.level.size_y - this.viewport_size_y, y0));
|
||||
// Round to the pixel grid
|
||||
x0 = Math.floor(x0 * tw + 0.5) / tw;
|
||||
y0 = Math.floor(y0 * th + 0.5) / th;
|
||||
@ -86,58 +245,143 @@ export class CanvasRenderer {
|
||||
// The viewport might not be aligned to the grid, so split off any fractional part.
|
||||
let xf0 = Math.floor(x0);
|
||||
let yf0 = Math.floor(y0);
|
||||
// Note that when the viewport is exactly aligned to the grid, we need to draw the cells
|
||||
// just outside of it, or we'll miss objects partway through crossing the border
|
||||
if (xf0 === x0 && xf0 > 0) {
|
||||
// We need to draw one cell beyond the viewport, or we'll miss objects partway through
|
||||
// crossing the border moving away from us
|
||||
if (xf0 > 0) {
|
||||
xf0 -= 1;
|
||||
}
|
||||
if (yf0 === y0 && yf0 > 0) {
|
||||
if (yf0 > 0) {
|
||||
yf0 -= 1;
|
||||
}
|
||||
// Find where to stop drawing. As with above, if we're aligned to the grid, we need to
|
||||
// include the tiles just outside it, so we allow this fencepost problem to fly
|
||||
let x1 = Math.min(this.level.size_x - 1, Math.ceil(x0 + this.viewport_size_x));
|
||||
let y1 = Math.min(this.level.size_y - 1, Math.ceil(y0 + this.viewport_size_y));
|
||||
// Draw one layer at a time, so animated objects aren't overdrawn by
|
||||
// Tiles in motion (i.e., actors) don't want to be overdrawn by neighboring tiles' terrain,
|
||||
// so draw in three passes: everything below actors, actors, and everything above actors
|
||||
// neighboring terrain
|
||||
// XXX layer count hardcoded here
|
||||
for (let layer = 0; layer < 4; layer++) {
|
||||
for (let x = xf0; x <= x1; x++) {
|
||||
for (let y = yf0; y <= y1; y++) {
|
||||
for (let tile of this.level.cells[y][x]) {
|
||||
if (tile.type.draw_layer !== layer)
|
||||
continue;
|
||||
for (let x = xf0; x <= x1; x++) {
|
||||
for (let y = yf0; y <= y1; y++) {
|
||||
let cell = this.level.cell(x, y);
|
||||
for (let layer = 0; layer < LAYERS.actor; layer++) {
|
||||
let tile = cell[layer];
|
||||
if (! tile)
|
||||
continue;
|
||||
|
||||
if (tile.type.is_actor &&
|
||||
// FIXME kind of a hack for the editor, which uses bare tile objects
|
||||
tile.visual_position)
|
||||
{
|
||||
// Handle smooth scrolling
|
||||
let [vx, vy] = tile.visual_position(tic_offset);
|
||||
// Round this to the pixel grid too!
|
||||
vx = Math.floor(vx * tw + 0.5) / tw;
|
||||
vy = Math.floor(vy * th + 0.5) / th;
|
||||
this.tileset.draw(tile, tic, (sx, sy, dx = 0, dy = 0, w = 1, h = w) =>
|
||||
this.blit(this.ctx, sx, sy, vx - x0 + dx, vy - y0 + dy, w, h));
|
||||
}
|
||||
else {
|
||||
// Non-actors can't move
|
||||
this.tileset.draw(tile, tic, (sx, sy, dx = 0, dy = 0, w = 1, h = w) =>
|
||||
this.blit(this.ctx, sx, sy, x - x0 + dx, y - y0 + dy, w, h));
|
||||
}
|
||||
}
|
||||
packet.x = x - x0;
|
||||
packet.y = y - y0;
|
||||
this.tileset.draw(tile, packet);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let x = xf0; x <= x1; x++) {
|
||||
for (let y = yf0; y <= y1; y++) {
|
||||
let cell = this.level.cell(x, y);
|
||||
let actor = cell[LAYERS.actor];
|
||||
if (! actor)
|
||||
continue;
|
||||
|
||||
// Handle smooth scrolling
|
||||
let [vx, vy] = actor.visual_position(update_progress, packet.update_rate);
|
||||
// Round this to the pixel grid too!
|
||||
vx = Math.floor(vx * tw + 0.5) / tw;
|
||||
vy = Math.floor(vy * th + 0.5) / th;
|
||||
|
||||
// For blocks, perception only applies if there's something of interest underneath
|
||||
if (this.perception !== 'normal' && actor.type.is_block &&
|
||||
! cell.some(t => t && t.type.layer < LAYERS.actor && ! (
|
||||
t.type.name === 'floor' && (t.wire_directions | t.wire_tunnel_directions) === 0)))
|
||||
{
|
||||
packet.perception = 'normal';
|
||||
}
|
||||
else {
|
||||
packet.perception = this.perception;
|
||||
}
|
||||
|
||||
packet.x = x - x0;
|
||||
packet.y = y - y0;
|
||||
packet.offsetx = vx - x;
|
||||
packet.offsety = vy - y;
|
||||
|
||||
// Draw the active player background
|
||||
if (actor === this.active_player) {
|
||||
this.tileset.draw_type('#active-player-background', null, packet);
|
||||
}
|
||||
|
||||
this.tileset.draw(actor, packet);
|
||||
|
||||
// If they killed the player, indicate as such. The indicator has an arrow at the
|
||||
// bottom; align that about 3/4 up the killer
|
||||
if (actor.is_killer && '#killer-indicator' in this.tileset.layout) {
|
||||
this.tileset.draw_type('#killer-indicator', null, packet);
|
||||
}
|
||||
}
|
||||
}
|
||||
packet.perception = this.perception;
|
||||
packet.offsetx = 0;
|
||||
packet.offsety = 0;
|
||||
for (let x = xf0; x <= x1; x++) {
|
||||
for (let y = yf0; y <= y1; y++) {
|
||||
let cell = this.level.cell(x, y);
|
||||
for (let layer = LAYERS.actor + 1; layer < LAYERS.MAX; layer++) {
|
||||
let tile = cell[layer];
|
||||
if (! tile)
|
||||
continue;
|
||||
|
||||
packet.x = x - x0;
|
||||
packet.y = y - y0;
|
||||
this.tileset.draw(tile, packet);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.use_rewind_effect) {
|
||||
this.draw_rewind_effect(tic);
|
||||
this.draw_rewind_effect(packet.clock);
|
||||
}
|
||||
|
||||
// Debug overlays
|
||||
if (this.show_actor_bboxes) {
|
||||
this.ctx.fillStyle = '#f004';
|
||||
for (let x = xf0; x <= x1; x++) {
|
||||
for (let y = yf0; y <= y1; y++) {
|
||||
let actor = this.level.cell(x, y).get_actor();
|
||||
if (! actor)
|
||||
continue;
|
||||
let [vx, vy] = actor.visual_position(update_progress, packet.update_rate);
|
||||
// Don't round to the pixel grid; we want to know if the bbox is misaligned!
|
||||
this.ctx.fillRect((vx - x0) * tw, (vy - y0) * th, 1 * tw, 1 * th);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.show_actor_order) {
|
||||
this.ctx.fillStyle = '#fff';
|
||||
this.ctx.strokeStyle = '#000';
|
||||
this.ctx.lineWidth = 3;
|
||||
this.ctx.font = '16px monospace';
|
||||
this.ctx.textAlign = 'center';
|
||||
this.ctx.textBaseline = 'middle';
|
||||
for (let [n, actor] of this.level.actors.entries()) {
|
||||
let cell = actor.cell;
|
||||
if (! cell)
|
||||
continue;
|
||||
if (cell.x < xf0 || cell.x > x1 || cell.y < yf0 || cell.y > y1)
|
||||
continue;
|
||||
|
||||
let [vx, vy] = actor.visual_position(update_progress, packet.update_rate);
|
||||
let x = (vx + 0.5 - x0) * tw;
|
||||
let y = (vy + 0.5 - y0) * th;
|
||||
let label = String(this.level.actors.length - 1 - n);
|
||||
this.ctx.strokeText(label, x, y);
|
||||
this.ctx.fillText(label, x, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
draw_rewind_effect(tic) {
|
||||
draw_rewind_effect(clock) {
|
||||
// Shift several rows over in a recurring pattern, like a VHS, whatever that is
|
||||
let rewind_start = tic / 20 % 1;
|
||||
let rewind_start = clock / 20 % 1;
|
||||
// Draw noisy white stripes in there too
|
||||
this.ctx.save();
|
||||
for (let chunk = 0; chunk < 4; chunk++) {
|
||||
let y = Math.floor(this.canvas.height * (chunk + rewind_start) / 4);
|
||||
for (let dy = 1; dy < 5; dy++) {
|
||||
@ -145,15 +389,105 @@ export class CanvasRenderer {
|
||||
this.canvas,
|
||||
0, y + dy, this.canvas.width, 1,
|
||||
-dy * dy, y + dy, this.canvas.width, 1);
|
||||
|
||||
this.ctx.beginPath();
|
||||
this.ctx.moveTo(0, y + dy + 0.5);
|
||||
this.ctx.lineTo(this.canvas.width, y + dy + 0.5);
|
||||
let alpha = (0.9 - y / this.canvas.height * 0.25) * ((dy - 1) / 3);
|
||||
this.ctx.strokeStyle = `rgba(100%, 100%, 100%, ${alpha})`;
|
||||
this.ctx.setLineDash([
|
||||
util.random_range(4, 20),
|
||||
util.random_range(2, 6),
|
||||
util.random_range(4, 20),
|
||||
util.random_range(2, 6),
|
||||
]);
|
||||
this.ctx.stroke();
|
||||
}
|
||||
}
|
||||
this.ctx.restore();
|
||||
}
|
||||
|
||||
// Used by the editor and map previews. Draws a region of the level (probably a StoredLevel),
|
||||
// assuming nothing is moving.
|
||||
draw_static_region(x0, y0, x1, y1, destx = x0, desty = y0) {
|
||||
this.draw_static_generic({x0, y0, x1, y1, destx, desty});
|
||||
}
|
||||
|
||||
// Most generic possible form of drawing a static region; mainly useful if you want to use a
|
||||
// different canvas or draw a custom block of cells
|
||||
// TODO does this actually need any state at all? could it just be, dare i ask, a function?
|
||||
draw_static_generic({
|
||||
x0, y0, x1, y1, destx = x0, desty = y0, cells = null, width = null,
|
||||
ctx = this.ctx, perception = this.perception, show_facing = this.show_facing,
|
||||
}) {
|
||||
if (ctx === this.ctx) {
|
||||
this._adjust_viewport_if_dirty();
|
||||
}
|
||||
|
||||
width = width ?? this.level.size_x;
|
||||
cells = cells ?? this.level.linear_cells;
|
||||
|
||||
let packet = new CanvasDrawPacket(this.tileset, ctx, perception);
|
||||
packet.show_facing = show_facing;
|
||||
for (let x = x0; x <= x1; x++) {
|
||||
for (let y = y0; y <= y1; y++) {
|
||||
let cell = cells[y * width + x];
|
||||
if (! cell)
|
||||
continue;
|
||||
|
||||
let seen_anything_interesting;
|
||||
for (let tile of cell) {
|
||||
if (! tile)
|
||||
continue;
|
||||
|
||||
// For actors (i.e., blocks), perception only applies if there's something
|
||||
// of potential interest underneath
|
||||
if (perception !== 'normal' && tile.type.is_block && ! seen_anything_interesting) {
|
||||
packet.perception = 'normal';
|
||||
}
|
||||
else {
|
||||
packet.perception = perception;
|
||||
}
|
||||
|
||||
if (tile.type.layer < LAYERS.actor && ! (
|
||||
tile.type.name === 'floor' && (tile.wire_directions | tile.wire_tunnel_directions) === 0))
|
||||
{
|
||||
seen_anything_interesting = true;
|
||||
}
|
||||
|
||||
// Don't draw facing arrows atop blocks, unless they're on a cloner or trap
|
||||
// where it matters (it's distracting in large clumps and makes it hard to see
|
||||
// frame arrows)
|
||||
packet.show_facing = show_facing;
|
||||
if (show_facing && tile.type.is_block) {
|
||||
let terrain_name = cell[LAYERS.terrain].type.name;
|
||||
if (! (terrain_name === 'cloner' || terrain_name === 'trap')) {
|
||||
packet.show_facing = false;
|
||||
}
|
||||
}
|
||||
|
||||
packet.x = destx + x - x0;
|
||||
packet.y = desty + y - y0;
|
||||
this.tileset.draw(tile, packet);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
create_tile_type_canvas(name) {
|
||||
let canvas = mk('canvas', {width: this.tileset.size_x, height: this.tileset.size_y});
|
||||
// TODO one wonders why this operates on a separate canvas and we don't just make new renderers
|
||||
// or something, or maybe make this a tileset method
|
||||
draw_single_tile_type(name, tile = null, canvas = null, x = 0, y = 0) {
|
||||
if (! canvas) {
|
||||
canvas = this.constructor.make_canvas(this.tileset.size_x, this.tileset.size_y);
|
||||
}
|
||||
let ctx = canvas.getContext('2d');
|
||||
this.tileset.draw_type(name, null, 0, (sx, sy, dx = 0, dy = 0, w = 1, h = w) =>
|
||||
this.blit(ctx, sx, sy, dx, dy, w, h));
|
||||
|
||||
// Individual tile types always reveal what they are
|
||||
let packet = new CanvasDrawPacket(this.tileset, ctx, 'palette');
|
||||
packet.show_facing = this.show_facing;
|
||||
packet.x = x;
|
||||
packet.y = y;
|
||||
this.tileset.draw_type(name, tile, packet);
|
||||
return canvas;
|
||||
}
|
||||
}
|
||||
|
||||
@ -43,7 +43,7 @@ export default [{
|
||||
title: "conundrum",
|
||||
author: "fluffy",
|
||||
url: 'https://beesbuzz.biz/',
|
||||
beepbox: 'https://www.beepbox.co/#8n31sbk0l00e0rt2mm0a7g0rj07i0r1o3210T1v1L4u01q3d7fay3z6C0c1A5F4B0V1Q0248Pac74E0085T1v1L4u01q1d1f4y4z9C0c1A1F0B0V1Q200ePd593E0787T5v1L4u05q3d0f1y4z0C2c0h0H_--D-quSRIAJJST4v1L4u04q1z6666ji8k8k3jSBKSJJAArriiiiii07JCABrzrrrrrrr00YrkqHrsrrrrjr005zrAqzrjzrrqr1jRjrqGGrrzsrsA099ijrABJJJIAzrrtirqrqjqixzsrAjrqjiqaqqysttAJqjikikrizrHtBJJAzArzrIsRCITKSS099ijrAJS____Qg99habbCAYrDzh00bkzgid18Q4zgid0000000018Nwz55p88Nhnh5t000000ichgR5zkicg004hcx4O4jhkohd14z4isp25PFJvy1wqY58llp2GhKFLh_wFLh-czArdWfRuKrdQ_p13c5FKfZ_tRBPjub_aq_TYtjhZgtBdJlldtlldtlleKGGCKGGCK1wqoiGGiOdI3j1RlkOJBtdZllmG1wqoiGGCK1JFxaEm1STTQMBlldtlldtlJKoHrarqGGqWGGqWGGth7ihT4sChON7a4sIpqPhDkCz9FFOG8YwzOF8Wp8WFOHyfAVARQVB4ughVkAtcAtkVl97Op8Wd8Td6nl9EOaqSqcyIz9UzMbaXbAenShhAPvehhrBRApcVmnlsV5vaGysD9CKsyyLbH8OpOIKGVEGKttV56jdkV55uTnpeldpuptSltdRKpEPpFFOW8YwzOF8Wp8WFOGU0',
|
||||
beepbox: 'https://www.beepbox.co/#8n31sbk0l00e0rt2mm0a7g0rj07i0r1o3210T1v1L4u01q3d7fay3z6C0c1A5F4B0V1Q0248Pac74E0085T1v1L4u01q1d1f4y4z9C0c1A1F0B0V1Q200ePd593E0787T5v1L4u05q3d0f1y4z0C2c0h0H_--D-quSRIAJJST4v1L4u04q1z6666ji8k8k3jSBKSJJAArriiiiii07JCABrzrrrrrrr00YrkqHrsrrrrjr005zrAqzrjzrrqr1jRjrqGGrrzsrsA099ijrABJJJIAzrrtirqrqjqixzsrAjrqjiqaqqysttAJqjikikrizrHtBJJAzArzrIsRCITKSS099ijrAJS____Qg99habbCAYrDzh00b4zgid18Q4zgid0000000018Nwz55p88Nhnh5t000000ichgR5zkicg004hcx4O4jhkohd14z4isp25PFJvy1wqY58llp2GhKFLh_wFLh-czArdWfRuKrdQ_p13c5FKfZ_tRBPjub_aq_TYtjhZgtBdJlldtlldtlleKGGCKGGCK1wqoiGGiOdI3j1RlkOJBtdZllmG1wqoiGGCK1JFxaEm1STTQMBlldtlldtlJKoHrarqGGqWGGqWGGth7ihT4sChON7a4sIpqPhDkCz9FFOG8YwzOF8Wp8WFOHyfAVARQVB4ughVkAtcAtkVl97Op8Wd8Td6nl9EOaqSqcyIz9UzMbaXbAenShhAPvehhrBRApcVmnlsV5vaGysD9CKsyyLbH8OpOIKGVEGKttV56jdkV55uTnpeldpuptSltdRKpEPpFFOW8YwzOF8Wp8WFOGU0',
|
||||
path: 'music/conundrum.ogg',
|
||||
}, {
|
||||
title: "kinda song",
|
||||
@ -51,4 +51,29 @@ export default [{
|
||||
twitter: "glitchedpuppet",
|
||||
beepbox: 'https://jummbus.bitbucket.io/#j2N07Unnamedn310s0k0l00e0jt2mm0a7g0jj07i0r1O_U0000o3210T1v0wL0OD0Ou01q1d5f6y0z6C1c0A1F2B5V6Q20a0Pe64bE0171T1v0pL0OD0Ou92q1d4f7y2z1C0c2AbF6B6V9Q0490Pb976E0001T1v0pL0OD0Ou94q1d2f7y2z1C0c2A9F5B5V6Q290dPa883E0011T4v0pL0OD0Ouf0q1z6666ji8k8k3jSBKSJJAArriiiiii07JCABrzrrrrrrr00YrkqHrsrrrrjr005zrAqzrjzrrqr1jRjrqGGrrzsrsA099ijrABJJJIAzrrtirqrqjqixzsrAjrqjiqaqqysttAJqjikikrizrHtBJJAzArzrIsRCITKSS099ijrAJS____Qg99habbCAYrDzh00b4x8Qd000lBu7014x4i4Qd3gQlmoh4ia2cz8OcChA4h4y8w01cPhjl0p27hFCLwATnMkCKChW2ZcngbUxcnikQ7sQO_inQ5WCkXyW9jbZ9vlO_lODhdcDjQNtgJ0Gp7IpAukFczR2FyX2frIzQ4zOGOOfpauP9vainRihQPK4tClqoKMnaAzQQnQQnRknXx7pBnK2OFjSO_oELFAzFIOWa8WF8WpHW3nEdv26LgqZcLQQap7Iu6P9j5R2Q2Q2Q2VeRfbEbWGq2-DinQBZttkHUMRWPn9HFAuwzEe3E8W2ehFyUsNncLQThuCnW2_aGydcngbkO_rZdkPjdcSpvx9jbZ3cRZtcO_lipvFSFkO_lN4YlAjBpdp6hahFyWz5OXbWFbWF8YEmChy3wWiehcK0bWaoEIlChw3JHp5K5E5E5w1sPb5P2f9as0LVds0bkbukQni0JFyQ5c0bokRyXrbxqxr2CyWSOSAzw1qxqxrNrxqxrhtwJgJMJJhvmbibikO8J5JwJEJEJtE',
|
||||
path: 'music/kinda-song.ogg',
|
||||
}, {
|
||||
title: "learning has occurred",
|
||||
author: "jneen",
|
||||
twitter: "jneen_",
|
||||
path: 'music/learning-has-occurred.ogg',
|
||||
}, {
|
||||
title: "escape on star road",
|
||||
author: "jneen",
|
||||
twitter: "jneen_",
|
||||
path: 'music/escape-on-star-road.ogg',
|
||||
}, {
|
||||
title: "inner orbit",
|
||||
author: "notchris",
|
||||
twitter: "chrismcgrane",
|
||||
path: 'music/inner-orbit.ogg',
|
||||
}, {
|
||||
title: "canopy",
|
||||
author: "notchris",
|
||||
twitter: "chrismcgrane",
|
||||
path: 'music/canopy.ogg',
|
||||
}, {
|
||||
title: "asteroid prairie",
|
||||
author: "notchris",
|
||||
twitter: "chrismcgrane",
|
||||
path: 'music/asteroid-prairie.ogg',
|
||||
}];
|
||||
|
||||
3445
js/tileset.js
4004
js/tiletypes.js
493
js/util.js
@ -1,7 +1,42 @@
|
||||
import * as fflate from './vendor/fflate.js';
|
||||
|
||||
// Base class for custom errors
|
||||
export class LLError extends Error {}
|
||||
|
||||
// Random choice
|
||||
export function random_range(a, b = null) {
|
||||
if (b === null) {
|
||||
b = a;
|
||||
a = 0;
|
||||
}
|
||||
return a + Math.floor(Math.random() * (b - a));
|
||||
}
|
||||
|
||||
export function random_choice(list) {
|
||||
return list[Math.floor(Math.random() * list.length)];
|
||||
}
|
||||
|
||||
export function random_shuffle(list) {
|
||||
// Knuth–Fisher–Yates, of course
|
||||
for (let i = list.length - 1; i > 0; i--) {
|
||||
let j = Math.floor(Math.random() * (i + 1));
|
||||
[list[i], list[j]] = [list[j], list[i]];
|
||||
}
|
||||
}
|
||||
|
||||
export function setdefault(map, key, defaulter) {
|
||||
if (map.has(key)) {
|
||||
return map.get(key);
|
||||
}
|
||||
else {
|
||||
let value = defaulter();
|
||||
map.set(key, value);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// DOM stuff
|
||||
function _mk(el, children) {
|
||||
if (children.length > 0) {
|
||||
if (!(children[0] instanceof Node) && children[0] !== undefined && typeof(children[0]) !== "string" && typeof(children[0]) !== "number") {
|
||||
@ -18,17 +53,114 @@ function _mk(el, children) {
|
||||
export function mk(tag_selector, ...children) {
|
||||
let [tag, ...classes] = tag_selector.split('.');
|
||||
let el = document.createElement(tag);
|
||||
el.classList = classes.join(' ');
|
||||
if (classes.length > 0) {
|
||||
el.classList = classes.join(' ');
|
||||
}
|
||||
return _mk(el, children);
|
||||
}
|
||||
|
||||
export function mk_button(label, onclick) {
|
||||
let el = mk('button', {type: 'button'}, label);
|
||||
el.addEventListener('click', onclick);
|
||||
return el;
|
||||
}
|
||||
|
||||
export const SVG_NS = 'http://www.w3.org/2000/svg';
|
||||
export function mk_svg(tag_selector, ...children) {
|
||||
let [tag, ...classes] = tag_selector.split('.');
|
||||
let el = document.createElementNS('http://www.w3.org/2000/svg', tag);
|
||||
el.classList = classes.join(' ');
|
||||
let el = document.createElementNS(SVG_NS, tag);
|
||||
if (classes.length > 0) {
|
||||
el.classList = classes.join(' ');
|
||||
}
|
||||
return _mk(el, children);
|
||||
}
|
||||
|
||||
export function trigger_local_download(filename, blob) {
|
||||
let url = URL.createObjectURL(blob);
|
||||
// To download a file, um, make an <a> and click it. Not kidding
|
||||
let a = mk('a', {
|
||||
href: url,
|
||||
download: filename,
|
||||
});
|
||||
document.body.append(a);
|
||||
a.click();
|
||||
// Absolutely no idea when I'm allowed to revoke this, but surely a minute is safe
|
||||
window.setTimeout(() => {
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}, 60 * 1000);
|
||||
}
|
||||
|
||||
export function handle_drop(element, options) {
|
||||
let dropzone_class = options.dropzone_class ?? null;
|
||||
let on_drop = options.on_drop;
|
||||
|
||||
let require_file = options.require_file ?? false;
|
||||
let is_valid = ev => {
|
||||
// TODO this requires files, should make some args for this
|
||||
if (options.require_file) {
|
||||
let dt = ev.dataTransfer;
|
||||
if (! dt || dt.items.length === 0)
|
||||
return false;
|
||||
|
||||
// Only test the first item I guess? If it's a file then they should all be files
|
||||
if (dt.items[0].kind !== 'file')
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
let end_drop = () => {
|
||||
if (dropzone_class !== null) {
|
||||
element.classList.remove(dropzone_class);
|
||||
}
|
||||
};
|
||||
|
||||
// TODO should have a filter function for when a drag is valid but i forget which of these
|
||||
// should have that
|
||||
element.addEventListener('dragenter', ev => {
|
||||
if (! is_valid(ev))
|
||||
return;
|
||||
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
|
||||
if (dropzone_class !== null) {
|
||||
element.classList.add(dropzone_class);
|
||||
}
|
||||
});
|
||||
element.addEventListener('dragover', ev => {
|
||||
if (! is_valid(ev))
|
||||
return;
|
||||
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
});
|
||||
element.addEventListener('dragleave', ev => {
|
||||
if (ev.relatedTarget && element.contains(ev.relatedTarget))
|
||||
return;
|
||||
|
||||
end_drop();
|
||||
});
|
||||
element.addEventListener('drop', ev => {
|
||||
if (! is_valid(ev))
|
||||
return;
|
||||
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
|
||||
end_drop();
|
||||
on_drop(ev);
|
||||
});
|
||||
}
|
||||
|
||||
export function sleep(t) {
|
||||
return new Promise(res => {
|
||||
setTimeout(res, t);
|
||||
});
|
||||
}
|
||||
|
||||
export function promise_event(element, success_event, failure_event) {
|
||||
let resolve, reject;
|
||||
let promise = new Promise((res, rej) => {
|
||||
@ -36,21 +168,21 @@ export function promise_event(element, success_event, failure_event) {
|
||||
reject = rej;
|
||||
});
|
||||
|
||||
let success_handler = e => {
|
||||
let success_handler = ev => {
|
||||
element.removeEventListener(success_event, success_handler);
|
||||
if (failure_event) {
|
||||
element.removeEventListener(failure_event, failure_handler);
|
||||
}
|
||||
|
||||
resolve(e);
|
||||
resolve(ev);
|
||||
};
|
||||
let failure_handler = e => {
|
||||
let failure_handler = ev => {
|
||||
element.removeEventListener(success_event, success_handler);
|
||||
if (failure_event) {
|
||||
element.removeEventListener(failure_event, failure_handler);
|
||||
}
|
||||
|
||||
reject(e);
|
||||
reject(ev);
|
||||
};
|
||||
|
||||
element.addEventListener(success_event, success_handler);
|
||||
@ -61,18 +193,81 @@ export function promise_event(element, success_event, failure_event) {
|
||||
return promise;
|
||||
}
|
||||
|
||||
export async function fetch(url) {
|
||||
|
||||
export async function fetch(url, response_type = 'arraybuffer') {
|
||||
let xhr = new XMLHttpRequest;
|
||||
let promise = promise_event(xhr, 'load', 'error');
|
||||
xhr.open('GET', url);
|
||||
xhr.responseType = 'arraybuffer';
|
||||
xhr.responseType = response_type;
|
||||
xhr.send();
|
||||
await promise;
|
||||
if (xhr.status !== 200)
|
||||
throw new Error(`Failed to load ${url} -- ${xhr.status} ${xhr.statusText}`);
|
||||
return xhr.response;
|
||||
}
|
||||
|
||||
export function string_from_buffer_ascii(buf, start = 0, len) {
|
||||
if (ArrayBuffer.isView(buf)) {
|
||||
start += buf.byteOffset;
|
||||
buf = buf.buffer;
|
||||
}
|
||||
return String.fromCharCode.apply(null, new Uint8Array(buf, start, len));
|
||||
}
|
||||
|
||||
// Converts a string to a buffer, using NO ENCODING, assuming single-byte characters
|
||||
export function bytestring_to_buffer(bytestring) {
|
||||
return Uint8Array.from(bytestring, c => c.charCodeAt(0)).buffer;
|
||||
}
|
||||
|
||||
export function b64encode(value) {
|
||||
if (value instanceof ArrayBuffer || value instanceof Uint8Array) {
|
||||
value = string_from_buffer_ascii(value);
|
||||
}
|
||||
// Make URL-safe and strip trailing padding
|
||||
return btoa(value).replace(/[+]/g, '-').replace(/[/]/g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
export function b64decode(data) {
|
||||
return bytestring_to_buffer(atob(data.replace(/-/g, '+').replace(/_/g, '/')));
|
||||
}
|
||||
|
||||
export function format_duration(seconds, places = 0) {
|
||||
let sign = '';
|
||||
if (seconds < 0) {
|
||||
seconds = -seconds;
|
||||
sign = '-';
|
||||
}
|
||||
let mins = Math.floor(seconds / 60);
|
||||
let secs = seconds % 60;
|
||||
let rounded_secs = secs.toFixed(places);
|
||||
// TODO hours?
|
||||
return `${sign}${mins}:${parseFloat(rounded_secs) < 10 ? '0' : ''}${rounded_secs}`;
|
||||
}
|
||||
|
||||
export class DelayTimer {
|
||||
constructor() {
|
||||
this.active = false;
|
||||
this._handle = null;
|
||||
this._bound_alarm = this._alarm.bind(this);
|
||||
}
|
||||
|
||||
set(duration) {
|
||||
if (this._handle) {
|
||||
window.clearTimeout(this._handle);
|
||||
}
|
||||
|
||||
this.active = true;
|
||||
this._handle = window.setTimeout(this._bound_alarm, duration);
|
||||
}
|
||||
|
||||
_alarm() {
|
||||
this._handle = null;
|
||||
this.active = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Cast a line through a grid and yield every cell it touches
|
||||
export function* walk_grid(x0, y0, x1, y1) {
|
||||
export function* walk_grid(x0, y0, x1, y1, min_a, min_b, max_a, max_b) {
|
||||
// TODO if the ray starts outside the grid (extremely unlikely), we should
|
||||
// find the point where it ENTERS the grid, otherwise the 'while'
|
||||
// conditions below will stop immediately
|
||||
@ -90,55 +285,56 @@ export function* walk_grid(x0, y0, x1, y1) {
|
||||
let goal_x = Math.floor(x1);
|
||||
let goal_y = Math.floor(y1);
|
||||
|
||||
// Use a modified Bresenham. Use mirroring to move everything into the
|
||||
// first quadrant, then split it into two octants depending on whether dx
|
||||
// or dy increases faster, and call that the main axis. Track an "error"
|
||||
// value, which is the (negative) distance between the ray and the next
|
||||
// grid line parallel to the main axis, but scaled up by dx. Every
|
||||
// iteration, we move one cell along the main axis and increase the error
|
||||
// value by dy (the ray's slope, scaled up by dx); when it becomes
|
||||
// positive, we can subtract dx (1) and move one cell along the minor axis
|
||||
// as well. Since the main axis is the faster one, we'll never traverse
|
||||
// more than one cell on the minor axis for one cell on the main axis, and
|
||||
// this readily provides every cell the ray hits in order.
|
||||
// Use a modified Bresenham. Use mirroring to move everything into the first quadrant, then
|
||||
// split it into two octants depending on whether dx or dy increases faster, and call that the
|
||||
// main axis. Track an "error" value, which is the (negative) distance between the ray and the
|
||||
// next grid line parallel to the main axis, but scaled up by dx. Every iteration, we move one
|
||||
// cell along the main axis and increase the error value by dy (the ray's slope, scaled up by
|
||||
// dx); when it becomes positive, we can subtract dx (1) and move one cell along the minor axis
|
||||
// as well. Since the main axis is the faster one, we'll never traverse more than one cell on
|
||||
// the minor axis for one cell on the main axis, and this readily provides every cell the ray
|
||||
// hits in order.
|
||||
// Based on: http://www.idav.ucdavis.edu/education/GraphicsNotes/Bresenhams-Algorithm/Bresenhams-Algorithm.html
|
||||
|
||||
// Setup: map to the first quadrant. The "offsets" are the distance
|
||||
// between the starting point and the next grid point.
|
||||
// Setup: map to the first quadrant. The "offsets" are the distance between the starting point
|
||||
// and the next grid point.
|
||||
let step_a = 1;
|
||||
let offset_x = 1 - (x0 - a);
|
||||
if (offset_x === 0) {
|
||||
// Zero offset means we're on a grid line, so we're a full cell away from the next grid line
|
||||
offset_x = 1;
|
||||
}
|
||||
if (dx < 0) {
|
||||
dx = -dx;
|
||||
step_a = -step_a;
|
||||
offset_x = 1 - offset_x;
|
||||
}
|
||||
// Zero offset means we're on a grid line, so we're actually a full cell
|
||||
// away from the next grid line
|
||||
if (offset_x === 0) {
|
||||
offset_x = 1;
|
||||
}
|
||||
let step_b = 1;
|
||||
let offset_y = 1 - (y0 - b);
|
||||
let offset_y = 1 - (y0 - b);
|
||||
if (offset_y === 0) {
|
||||
offset_y = 1;
|
||||
}
|
||||
if (dy < 0) {
|
||||
dy = -dy;
|
||||
step_b = -step_b;
|
||||
offset_y = 1 - offset_y;
|
||||
}
|
||||
if (offset_y === 0) {
|
||||
offset_y = 1;
|
||||
}
|
||||
|
||||
let err = dy * offset_x - dx * offset_y;
|
||||
|
||||
let min_a = 0, min_b = 0;
|
||||
// TODO get these passed in fool
|
||||
let max_a = 31, max_b = 31;
|
||||
if (dx > dy) {
|
||||
// Main axis is x/a
|
||||
while (min_a <= a && a <= max_a && min_b <= b && b <= max_b) {
|
||||
yield [a, b];
|
||||
if (a === goal_x && b === goal_y)
|
||||
if (a === goal_x && b === goal_y) {
|
||||
yield [a, b];
|
||||
return;
|
||||
}
|
||||
// When we go exactly through a corner, we cross two grid lines, but between them we
|
||||
// enter a cell the line doesn't actually pass through. That happens here, when err ===
|
||||
// dx, because it was 0 last loop
|
||||
if (err !== dy) {
|
||||
yield [a, b];
|
||||
}
|
||||
|
||||
if (err > 0) {
|
||||
err -= dx;
|
||||
@ -155,9 +351,13 @@ export function* walk_grid(x0, y0, x1, y1) {
|
||||
err = -err;
|
||||
// Main axis is y/b
|
||||
while (min_a <= a && a <= max_a && min_b <= b && b <= max_b) {
|
||||
yield [a, b];
|
||||
if (a === goal_x && b === goal_y)
|
||||
if (a === goal_x && b === goal_y) {
|
||||
yield [a, b];
|
||||
return;
|
||||
}
|
||||
if (err !== dx) {
|
||||
yield [a, b];
|
||||
}
|
||||
|
||||
if (err > 0) {
|
||||
err -= dy;
|
||||
@ -171,3 +371,218 @@ export function* walk_grid(x0, y0, x1, y1) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Baby's first bit vector
|
||||
export class BitVector {
|
||||
constructor(size) {
|
||||
this.array = new Uint32Array(Math.ceil(size / 32));
|
||||
}
|
||||
|
||||
get(bit) {
|
||||
let i = Math.floor(bit / 32);
|
||||
let b = bit % 32;
|
||||
return (this.array[i] & (1 << b)) !== 0;
|
||||
}
|
||||
|
||||
set(bit) {
|
||||
let i = Math.floor(bit / 32);
|
||||
let b = bit % 32;
|
||||
this.array[i] |= (1 << b);
|
||||
}
|
||||
|
||||
clear(bit) {
|
||||
let i = Math.floor(bit / 32);
|
||||
let b = bit % 32;
|
||||
this.array[i] &= ~(1 << b);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Root class to indirect over where we might get files from
|
||||
// - a pool of uploaded in-memory files
|
||||
// - a single uploaded zip file
|
||||
// - a local directory provided via the webkit Entry api
|
||||
// - HTTP (but only for files we choose ourselves, not arbitrary ones, due to CORS)
|
||||
// Note that where possible, these classes lowercase all filenames, in keeping with C2G's implicit
|
||||
// requirement that filenames are case-insensitive :/
|
||||
export class FileSource {
|
||||
constructor() {}
|
||||
|
||||
// Get a file's contents as an ArrayBuffer
|
||||
async get(path) {}
|
||||
|
||||
// Get a list of all files under here, recursively
|
||||
// async *iter_all_files() {}
|
||||
}
|
||||
// Files we have had uploaded one at a time (note that each upload becomes its own source)
|
||||
export class FileFileSource extends FileSource {
|
||||
constructor(files) {
|
||||
super();
|
||||
this.files = {};
|
||||
for (let file of files) {
|
||||
this.files[(file.webkitRelativePath ?? file.name).toLowerCase()] = file;
|
||||
}
|
||||
}
|
||||
|
||||
get(path) {
|
||||
let file = this.files[path.toLowerCase()];
|
||||
if (file) {
|
||||
return file.arrayBuffer();
|
||||
}
|
||||
else {
|
||||
return Promise.reject(new Error(`No such file was provided: ${path}`));
|
||||
}
|
||||
}
|
||||
|
||||
iter_all_files() {
|
||||
return Object.keys(this.files);
|
||||
}
|
||||
}
|
||||
// Regular HTTP fetch
|
||||
export class HTTPFileSource extends FileSource {
|
||||
// Should be given a URL object as a root
|
||||
constructor(root) {
|
||||
super();
|
||||
this.root = root;
|
||||
}
|
||||
|
||||
get(path) {
|
||||
let url = new URL(path, this.root);
|
||||
return fetch(url);
|
||||
}
|
||||
}
|
||||
// Regular HTTP fetch, but for a directory structure from nginx's index module
|
||||
export class HTTPNginxDirectorySource extends FileSource {
|
||||
// Should be given a URL object as a root
|
||||
constructor(root) {
|
||||
super();
|
||||
this.root = root;
|
||||
if (! this.root.pathname.endsWith('/')) {
|
||||
this.root.pathname += '/';
|
||||
}
|
||||
}
|
||||
|
||||
get(path) {
|
||||
// TODO should strip off multiple of these
|
||||
// TODO and canonicalize, and disallow going upwards
|
||||
if (path.startsWith('/')) {
|
||||
path = path.substring(1);
|
||||
}
|
||||
let url = new URL(path, this.root);
|
||||
return fetch(url);
|
||||
}
|
||||
|
||||
async *iter_all_files() {
|
||||
let fetch_count = 0;
|
||||
let paths = [''];
|
||||
while (paths.length > 0) {
|
||||
let next_paths = [];
|
||||
for (let path of paths) {
|
||||
if (fetch_count >= 50) {
|
||||
throw new Error("Too many subdirectories to fetch one at a time; is this really a single CC2 set?");
|
||||
}
|
||||
let response = await fetch(new URL(path, this.root), 'text');
|
||||
fetch_count += 1;
|
||||
let doc = document.implementation.createHTMLDocument();
|
||||
doc.write(response);
|
||||
doc.close();
|
||||
for (let link of doc.querySelectorAll('a')) {
|
||||
let subpath = link.getAttribute('href');
|
||||
if (subpath === '../') {
|
||||
continue;
|
||||
}
|
||||
else if (subpath.endsWith('/')) {
|
||||
next_paths.push(path + subpath);
|
||||
}
|
||||
else {
|
||||
yield path + subpath;
|
||||
}
|
||||
}
|
||||
}
|
||||
paths = next_paths;
|
||||
}
|
||||
}
|
||||
}
|
||||
// WebKit Entry interface
|
||||
// XXX this does not appear to work if you drag in a link to a directory but that is probably beyond
|
||||
// my powers to fix
|
||||
export class EntryFileSource extends FileSource {
|
||||
constructor(entries) {
|
||||
super();
|
||||
this.files = {};
|
||||
let file_count = 0;
|
||||
|
||||
let read_directory = async (directory_entry, dir_prefix) => {
|
||||
let reader = directory_entry.createReader();
|
||||
let all_entries = [];
|
||||
while (true) {
|
||||
let entries = await new Promise((res, rej) => reader.readEntries(res, rej));
|
||||
all_entries.push.apply(all_entries, entries);
|
||||
if (entries.length === 0)
|
||||
break;
|
||||
}
|
||||
|
||||
await handle_entries(all_entries, dir_prefix);
|
||||
};
|
||||
let handle_entries = (entries, dir_prefix) => {
|
||||
file_count += entries.length;
|
||||
if (file_count > 4096)
|
||||
throw new LLError("Found way too many files; did you drag in the wrong directory?");
|
||||
|
||||
let dir_promises = [];
|
||||
for (let entry of entries) {
|
||||
if (entry.isDirectory) {
|
||||
dir_promises.push(read_directory(entry, dir_prefix + entry.name + '/'));
|
||||
}
|
||||
else {
|
||||
this.files[(dir_prefix + entry.name).toLowerCase()] = entry;
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.all(dir_promises);
|
||||
};
|
||||
|
||||
this._loaded_promise = handle_entries(entries, '');
|
||||
}
|
||||
|
||||
async get(path) {
|
||||
let entry = this.files[path.toLowerCase()];
|
||||
if (! entry)
|
||||
throw new LLError(`No such file in local directory: ${path}`);
|
||||
|
||||
let file = await new Promise((res, rej) => entry.file(res, rej));
|
||||
return await file.arrayBuffer();
|
||||
}
|
||||
|
||||
async iter_all_files() {
|
||||
await this._loaded_promise;
|
||||
return Object.keys(this.files);
|
||||
}
|
||||
}
|
||||
// Zip files, using fflate
|
||||
// TODO somewhat unfortunately fflate only supports unzipping the whole thing at once, not
|
||||
// individual files as needed, but it's also pretty new so maybe later?
|
||||
export class ZipFileSource extends FileSource {
|
||||
constructor(buf) {
|
||||
super();
|
||||
// TODO async? has some setup time but won't freeze browser
|
||||
let files = fflate.unzipSync(new Uint8Array(buf));
|
||||
this.files = {};
|
||||
for (let [path, file] of Object.entries(files)) {
|
||||
this.files['/' + path.toLowerCase()] = file;
|
||||
}
|
||||
}
|
||||
|
||||
async get(path) {
|
||||
let file = this.files[path.toLowerCase()];
|
||||
if (! file)
|
||||
throw new LLError(`No such file in zip: ${path}`);
|
||||
|
||||
return file.buffer;
|
||||
}
|
||||
|
||||
iter_all_files() {
|
||||
return Object.keys(this.files);
|
||||
}
|
||||
}
|
||||
|
||||
3
js/vendor/fflate.js
vendored
Normal file
BIN
levels/CC2LP1.zip
Normal file
BIN
levels/CCLP5.ccl
Normal file
BIN
levels/lexys-lessons.zip
Normal file
BIN
levels/previews/cc2lp1.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
levels/previews/cclp1.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
levels/previews/cclp3.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
levels/previews/cclp4.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
levels/previews/cclp5.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
levels/previews/cclxp2.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
levels/previews/lexys-lessons.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
loading.gif
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
music/asteroid-prairie.ogg
Normal file
BIN
music/canopy.ogg
Normal file
BIN
music/escape-on-star-road.ogg
Normal file
BIN
music/inner-orbit.ogg
Normal file
BIN
music/learning-has-occurred.ogg
Normal file
BIN
og-preview.png
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.4 KiB |
3
package.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||