mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-20 07:24:16 +00:00
Compare commits
1141
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59626d2434 | ||
|
|
46f5472081 | ||
|
|
2cd9167549 | ||
|
|
a648ef6622 | ||
|
|
51c9d01bf7 | ||
|
|
99258bfd98 | ||
|
|
db3d42df26 | ||
|
|
b97bd51f20 | ||
|
|
a02b4386a4 | ||
|
|
5a99091193 | ||
|
|
88c2688662 | ||
|
|
f73c78349b | ||
|
|
0a76208194 | ||
|
|
38b340ac14 | ||
|
|
bfeb017037 | ||
|
|
89b864a142 | ||
|
|
a49a4e0949 | ||
|
|
3c773e3e57 | ||
|
|
84ffce263a | ||
|
|
e4675ecab8 | ||
|
|
b24c38896d | ||
|
|
6270b62552 | ||
|
|
8a1c7ac998 | ||
|
|
8c39f9a01b | ||
|
|
74756dd19e | ||
|
|
5363cbbc02 | ||
|
|
19bbca0e4b | ||
|
|
ec0d2d816a | ||
|
|
340e66105c | ||
|
|
e58d065ca4 | ||
|
|
cebd582018 | ||
|
|
f44ce5b327 | ||
|
|
b1445430bb | ||
|
|
80e7e37791 | ||
|
|
8e71c14f65 | ||
|
|
eb1a244114 | ||
|
|
05eed1f6a5 | ||
|
|
6c3822cdaf | ||
|
|
13312e9182 | ||
|
|
674502eeda | ||
|
|
143ba4771b | ||
|
|
02956fd358 | ||
|
|
76860e94fa | ||
|
|
252facf41e | ||
|
|
ad88b00c2a | ||
|
|
6f930d1370 | ||
|
|
855ab90be4 | ||
|
|
8783fa09a5 | ||
|
|
87dc78934f | ||
|
|
d35fe93f7f | ||
|
|
d6d6fd031c | ||
|
|
7f34bb917f | ||
|
|
18e0834db8 | ||
|
|
6a85cb53a9 | ||
|
|
8a0fd9b9fc | ||
|
|
2fc4c2faf9 | ||
|
|
475f72a300 | ||
|
|
bc7a530486 | ||
|
|
b12b1ee304 | ||
|
|
196de7d858 | ||
|
|
8a18b6ff8b | ||
|
|
e1514503f6 | ||
|
|
f1c1869e43 | ||
|
|
31615dc052 | ||
|
|
4ddfd9549f | ||
|
|
ed67dd6058 | ||
|
|
ee7f54429a | ||
|
|
1e0628f768 | ||
|
|
72faf76269 | ||
|
|
28c4941941 | ||
|
|
d452f10fd9 | ||
|
|
e7d89cadd8 | ||
|
|
cddd6b16d6 | ||
|
|
b2ac4e7f93 | ||
|
|
c2bb32459f | ||
|
|
41888e33f6 | ||
|
|
25fa2553e5 | ||
|
|
86f78baecf | ||
|
|
c31158578f | ||
|
|
4e86d7d634 | ||
|
|
62fe29cf34 | ||
|
|
a3bcea4f3e | ||
|
|
7befce1084 | ||
|
|
351a1aa495 | ||
|
|
d2cb8a9032 | ||
|
|
509715087c | ||
|
|
12527ecdd8 | ||
|
|
01a2999e03 | ||
|
|
dbafe84fa5 | ||
|
|
40b020fc5d | ||
|
|
d15f7d62d1 | ||
|
|
f9c062c794 | ||
|
|
6a89b8bd06 | ||
|
|
194fcb2ea7 | ||
|
|
3b68f855b2 | ||
|
|
cfbf68d37a | ||
|
|
9999f3e429 | ||
|
|
97fa0562a4 | ||
|
|
3abe671851 | ||
|
|
e164a3bb5c | ||
|
|
4dfb4441e4 | ||
|
|
e686f67453 | ||
|
|
e2eed4812a | ||
|
|
1737833bfe | ||
|
|
a4c5f46c37 | ||
|
|
025fa833c4 | ||
|
|
98cc82cc3d | ||
|
|
298365f588 | ||
|
|
90e5720e2e | ||
|
|
7cd43791f4 | ||
|
|
a251d192ad | ||
|
|
abfff28f43 | ||
|
|
fbdae4c928 | ||
|
|
93ca26e368 | ||
|
|
8c1db1c223 | ||
|
|
123708601f | ||
|
|
b5ab4968ba | ||
|
|
93ae6037bd | ||
|
|
dd8e487a3b | ||
|
|
d3c8377a12 | ||
|
|
84f3687c0c | ||
|
|
ed1e1f1181 | ||
|
|
528c97f367 | ||
|
|
f7a459f3bc | ||
|
|
cd179b4321 | ||
|
|
6373dbb1b8 | ||
|
|
0b0797f876 | ||
|
|
446acefc91 | ||
|
|
7a44e555b2 | ||
|
|
57d4c2935c | ||
|
|
234fb1b0c4 | ||
|
|
0882b5acc4 | ||
|
|
18da26e1fa | ||
|
|
65aa95f2a5 | ||
|
|
5330ddc4ec | ||
|
|
a7fdd37569 | ||
|
|
a226fea9e2 | ||
|
|
8cea165a29 | ||
|
|
1d7cba184c | ||
|
|
4ca7ea3911 | ||
|
|
ea5919def2 | ||
|
|
be35e49302 | ||
|
|
aadd03990a | ||
|
|
38a579453d | ||
|
|
0bf5da2ca1 | ||
|
|
317848692e | ||
|
|
22118de9e9 | ||
|
|
d7039d120b | ||
|
|
979ee4e7d8 | ||
|
|
f68d45bd30 | ||
|
|
b86d7f303e | ||
|
|
c573843314 | ||
|
|
51a7bef18e | ||
|
|
0e237aa1ad | ||
|
|
b46c413f6b | ||
|
|
f3ef1ca2ae | ||
|
|
703e61dd54 | ||
|
|
e1ec6b7827 | ||
|
|
f94292fdac | ||
|
|
7d153bf985 | ||
|
|
f85f15ed02 | ||
|
|
867686eced | ||
|
|
526efd3ae1 | ||
|
|
9d796bd2a0 | ||
|
|
1a9a5d80ad | ||
|
|
c5c7f9bdc5 | ||
|
|
01a421b732 | ||
|
|
ae92bcf194 | ||
|
|
7e7933858b | ||
|
|
9c073ecbcb | ||
|
|
f4617c1996 | ||
|
|
21a2e4feef | ||
|
|
258d0d3aaa | ||
|
|
27b2fd0823 | ||
|
|
0524adb0df | ||
|
|
3981b0976d | ||
|
|
b5811cae08 | ||
|
|
c998fd1fd9 | ||
|
|
a7d874d37f | ||
|
|
7c41f86fb3 | ||
|
|
e5e0561d5a | ||
|
|
d50cdfec3e | ||
|
|
00997c1902 | ||
|
|
3095a92522 | ||
|
|
fca309dec7 | ||
|
|
2793ed1035 | ||
|
|
e5908285af | ||
|
|
a8ee321eb8 | ||
|
|
aa14242b54 | ||
|
|
f6968269b4 | ||
|
|
0d0251e2f1 | ||
|
|
81b8fb02ae | ||
|
|
77842489e5 | ||
|
|
eda1f59d3a | ||
|
|
5418868559 | ||
|
|
1d81daafbb | ||
|
|
609a3585c1 | ||
|
|
571dde608c | ||
|
|
3a13f5a7c1 | ||
|
|
e5654b74d4 | ||
|
|
a75738dd7a | ||
|
|
003be1f88b | ||
|
|
1eaafae20d | ||
|
|
79088c0305 | ||
|
|
9e147774bd | ||
|
|
e51b632c8f | ||
|
|
1aa64c9a02 | ||
|
|
1e148d8fcd | ||
|
|
f63cabd8b8 | ||
|
|
176d3a5f9f | ||
|
|
eb184983de | ||
|
|
d1c307865d | ||
|
|
4a26b8b34f | ||
|
|
33fd01c3ac | ||
|
|
34874080e7 | ||
|
|
43131ed8f6 | ||
|
|
9ea425aee4 | ||
|
|
709f05c1a5 | ||
|
|
ec74add5de | ||
|
|
d5b706764a | ||
|
|
ca4c205a4a | ||
|
|
7b9c2ae6ad | ||
|
|
d0e9d44152 | ||
|
|
2cdffaae33 | ||
|
|
d4a8beac95 | ||
|
|
0e0e4075d8 | ||
|
|
9c492a2e66 | ||
|
|
96bf83684c | ||
|
|
25ea9ae62d | ||
|
|
7938c150dd | ||
|
|
a7b4bf3ff2 | ||
|
|
693a05f2cb | ||
|
|
e48cbe1f69 | ||
|
|
558f6fa8dd | ||
|
|
67b29cc363 | ||
|
|
4702534f9c | ||
|
|
29390c5e0a | ||
|
|
e8bfce469d | ||
|
|
770e2f47a2 | ||
|
|
747e7b3330 | ||
|
|
8662f3b47a | ||
|
|
6e738e69e7 | ||
|
|
4bf14c83e1 | ||
|
|
837674c295 | ||
|
|
e5f3bbd69e | ||
|
|
2885a39299 | ||
|
|
bd1b06c761 | ||
|
|
3e1143112a | ||
|
|
834b214fbf | ||
|
|
768e569400 | ||
|
|
a5d04333dd | ||
|
|
ebe8da0fd5 | ||
|
|
5eb25f3977 | ||
|
|
a5bca0a9bb | ||
|
|
a82882c1c1 | ||
|
|
05193bd0d9 | ||
|
|
96efc15c36 | ||
|
|
67f456cf53 | ||
|
|
58b1bd115f | ||
|
|
8ddc8abdb9 | ||
|
|
12c2c71739 | ||
|
|
8705ca6e47 | ||
|
|
a610d0a7a9 | ||
|
|
0648cfd3fa | ||
|
|
fba9c17e3f | ||
|
|
74128d58cf | ||
|
|
70130e976d | ||
|
|
501c426302 | ||
|
|
8471f15bc8 | ||
|
|
c4539441a0 | ||
|
|
472a98da16 | ||
|
|
76a6f1465a | ||
|
|
b0ca0e3617 | ||
|
|
08b9058c8f | ||
|
|
556bef6e43 | ||
|
|
16a4d20c90 | ||
|
|
e5f2fee926 | ||
|
|
3949468a60 | ||
|
|
fc54fcb7eb | ||
|
|
9d6e4e87d8 | ||
|
|
f50e964d4f | ||
|
|
8262e52893 | ||
|
|
1c41dc69ce | ||
|
|
dee46a4284 | ||
|
|
a8892b04ef | ||
|
|
d49f7d7821 | ||
|
|
106a3834ab | ||
|
|
387e8adfe2 | ||
|
|
9ade90d9ca | ||
|
|
3d2392843a | ||
|
|
a9891e819a | ||
|
|
02c13a9391 | ||
|
|
f6590c26e7 | ||
|
|
64bb81626c | ||
|
|
343dc429d5 | ||
|
|
8f5f44bc96 | ||
|
|
6964b99744 | ||
|
|
11cfd85f6a | ||
|
|
c5416f54fa | ||
|
|
94c5782490 | ||
|
|
cc49b0f540 | ||
|
|
1bc6192c8b | ||
|
|
4b0766c097 | ||
|
|
ec5cb2d646 | ||
|
|
0edaeeec99 | ||
|
|
a0543bbbf2 | ||
|
|
0b1af51227 | ||
|
|
03c5998c02 | ||
|
|
6b193f695a | ||
|
|
0a30881f87 | ||
|
|
c01a7def5d | ||
|
|
50db1d11a7 | ||
|
|
2e2ea8f1e4 | ||
|
|
badf108d56 | ||
|
|
f9888d19a5 | ||
|
|
fbc5cee300 | ||
|
|
12777bc3f0 | ||
|
|
9324b1b5c0 | ||
|
|
2ecb555619 | ||
|
|
20c7891c2f | ||
|
|
c35c858eee | ||
|
|
cb93e78242 | ||
|
|
c9ed4519a8 | ||
|
|
86d986dfbc | ||
|
|
54402faea8 | ||
|
|
77b4253624 | ||
|
|
44af23ba2e | ||
|
|
4e8079e431 | ||
|
|
39f65d02bf | ||
|
|
8c025a9ba2 | ||
|
|
7119fa8ef7 | ||
|
|
6f0b2b6bba | ||
|
|
0ae1334a57 | ||
|
|
5d28563b7c | ||
|
|
cf8243a000 | ||
|
|
cca0bed482 | ||
|
|
a802edc375 | ||
|
|
3677931114 | ||
|
|
e38dae0a32 | ||
|
|
d537459c5a | ||
|
|
67398174bb | ||
|
|
0a0f77ff58 | ||
|
|
e5cb4d82a3 | ||
|
|
9d44ac0b47 | ||
|
|
7f6d65a13e | ||
|
|
ad720f4aa2 | ||
|
|
80548f753c | ||
|
|
c9c8a9e412 | ||
|
|
d048c073ac | ||
|
|
2929a1f0a7 | ||
|
|
b6f9f68b9f | ||
|
|
da2dd42e28 | ||
|
|
88360ef772 | ||
|
|
73787e21d2 | ||
|
|
840608a40f | ||
|
|
fcedc35635 | ||
|
|
32d9a5e40a | ||
|
|
8df6253604 | ||
|
|
01b62c13b6 | ||
|
|
f32f73e65a | ||
|
|
7b7118fe0d | ||
|
|
851ac18f0d | ||
|
|
37be7ca847 | ||
|
|
ed67b4d3c2 | ||
|
|
b62ac875d5 | ||
|
|
4c3f95b0e1 | ||
|
|
8712c8874c | ||
|
|
ccc3bab8e0 | ||
|
|
113f25dab8 | ||
|
|
cd80acdc7d | ||
|
|
9316ea9e5b | ||
|
|
e2a3f25dcb | ||
|
|
944cc19ebc | ||
|
|
172e234514 | ||
|
|
f9dac8a3a1 | ||
|
|
eefbbab7ee | ||
|
|
9cf567e1ff | ||
|
|
b8ee7561dd | ||
|
|
c0d17742e8 | ||
|
|
a88d389e0f | ||
|
|
b341089996 | ||
|
|
9cbfd5a10b | ||
|
|
fdaf8ff9d3 | ||
|
|
e63d84c052 | ||
|
|
0d103a934a | ||
|
|
8119207254 | ||
|
|
b66e6d8335 | ||
|
|
ce2b62f5ae | ||
|
|
c767b6c8e8 | ||
|
|
67328d7d10 | ||
|
|
329e136a66 | ||
|
|
52f87befa2 | ||
|
|
2ee22db072 | ||
|
|
cc27e87376 | ||
|
|
abb1d9cd62 | ||
|
|
e0ad029c3d | ||
|
|
5705caa1ba | ||
|
|
a56cd4c268 | ||
|
|
48cc6bb49c | ||
|
|
635ee02459 | ||
|
|
2c7b7c8c91 | ||
|
|
ad0662be54 | ||
|
|
835c8422f1 | ||
|
|
2c60192de3 | ||
|
|
adb7188eb9 | ||
|
|
2436ebb857 | ||
|
|
ad85a0bbd1 | ||
|
|
22ac9ebf47 | ||
|
|
3cb9585dd8 | ||
|
|
f4d1177c51 | ||
|
|
b132862a60 | ||
|
|
2452a2f633 | ||
|
|
f0476fea9b | ||
|
|
90c6457d37 | ||
|
|
61301ffd4d | ||
|
|
e42317139d | ||
|
|
f68600464b | ||
|
|
97ddfd309b | ||
|
|
5d42c5cae0 | ||
|
|
52fecb48d8 | ||
|
|
ce29dc98c3 | ||
|
|
463e3d9658 | ||
|
|
9152b25d51 | ||
|
|
bb9c72e414 | ||
|
|
976648340e | ||
|
|
5958bcb22e | ||
|
|
8eb35aa8be | ||
|
|
49ce84a9e9 | ||
|
|
6724b0f4c5 | ||
|
|
e72abc8c39 | ||
|
|
0a2f35dd8d | ||
|
|
c597c55c2c | ||
|
|
f93995e152 | ||
|
|
7a5a2e5909 | ||
|
|
e15d31a0ed | ||
|
|
09cdd61a68 | ||
|
|
0575cd85c8 | ||
|
|
2008975a8a | ||
|
|
aad1d3815a | ||
|
|
86c64f99e9 | ||
|
|
6577f4a5de | ||
|
|
3415e7c7af | ||
|
|
f1c72974fd | ||
|
|
26e2e20840 | ||
|
|
c00dcf205b | ||
|
|
a10a784c3b | ||
|
|
f99cc61042 | ||
|
|
d2a1e5cc1e | ||
|
|
3e2c0fa025 | ||
|
|
9c151abac7 | ||
|
|
ab4770400e | ||
|
|
1a810d5d62 | ||
|
|
536d20bca7 | ||
|
|
9a691ac5b4 | ||
|
|
71078a20a9 | ||
|
|
566fca2b52 | ||
|
|
74e2576ca2 | ||
|
|
fb0bc112e3 | ||
|
|
407e51378c | ||
|
|
c468eb1ab1 | ||
|
|
bdb7cc61e5 | ||
|
|
e8f9e841c4 | ||
|
|
1b1b9d97b7 | ||
|
|
24355064ff | ||
|
|
06a45056d9 | ||
|
|
dfe4894be7 | ||
|
|
8eb2287ec0 | ||
|
|
c4f0722614 | ||
|
|
7fa1484b21 | ||
|
|
1c5eb8b910 | ||
|
|
94bf530d93 | ||
|
|
686ae57b5b | ||
|
|
a911685aaf | ||
|
|
6899761ca9 | ||
|
|
a58958fd62 | ||
|
|
307f3c9158 | ||
|
|
4f275c2e06 | ||
|
|
7e4e3f3cd8 | ||
|
|
84e5add564 | ||
|
|
4d4adbb76f | ||
|
|
ff2f285784 | ||
|
|
8b1636e78a | ||
|
|
3bdad10562 | ||
|
|
ac03a977aa | ||
|
|
d7270f66e1 | ||
|
|
b220b1bffa | ||
|
|
4796fe98cb | ||
|
|
ece905ec6e | ||
|
|
53c9e7c04c | ||
|
|
29f6f52443 | ||
|
|
a778cc51a6 | ||
|
|
c6963d0cd3 | ||
|
|
489844f61b | ||
|
|
4d1b0246ca | ||
|
|
13f3511fa8 | ||
|
|
f6f2766315 | ||
|
|
56530a1245 | ||
|
|
210a1f29a7 | ||
|
|
847bf972ae | ||
|
|
e5d21fdf7e | ||
|
|
0b2fd3b358 | ||
|
|
10484d1226 | ||
|
|
ce0092c52a | ||
|
|
474e033c2b | ||
|
|
62aad1f497 | ||
|
|
32b5157682 | ||
|
|
70717ea282 | ||
|
|
15975f108c | ||
|
|
66c66e82c1 | ||
|
|
4f6da95d8e | ||
|
|
b37ccbdf01 | ||
|
|
30dbe0881a | ||
|
|
20ad4657a9 | ||
|
|
d4986f42a6 | ||
|
|
8df15c0c2d | ||
|
|
24e1c578c8 | ||
|
|
1c3bff7559 | ||
|
|
a09657b4d0 | ||
|
|
910220d3ca | ||
|
|
fc05c24e3a | ||
|
|
e1a7e772cf | ||
|
|
3c6411c322 | ||
|
|
628761affa | ||
|
|
fa7b0f68ff | ||
|
|
487d0ac237 | ||
|
|
e79e3fbe45 | ||
|
|
23a0ab23aa | ||
|
|
c78ffab948 | ||
|
|
d58f497b71 | ||
|
|
3284ab84c5 | ||
|
|
5cf0ac9315 | ||
|
|
4e4e820826 | ||
|
|
cb6e4570f4 | ||
|
|
fb59c7370d | ||
|
|
c16e65f0a8 | ||
|
|
d9681edf62 | ||
|
|
520c156d5f | ||
|
|
5c26ec2521 | ||
|
|
d192e2b6d1 | ||
|
|
5b60b655ed | ||
|
|
97c7ac0528 | ||
|
|
090ff730a7 | ||
|
|
c1f797e7c9 | ||
|
|
4a1a6b80a6 | ||
|
|
e189b2389c | ||
|
|
3265447536 | ||
|
|
8c7c9f89e1 | ||
|
|
d7a991b9b3 | ||
|
|
abf6c93f2e | ||
|
|
1b76a66760 | ||
|
|
c83ac67b47 | ||
|
|
1dca502a90 | ||
|
|
cacef8c8fc | ||
|
|
623fd45ef4 | ||
|
|
007b4b0b53 | ||
|
|
b6bacee095 | ||
|
|
d5251350a1 | ||
|
|
1f9c72d093 | ||
|
|
a1495a10e5 | ||
|
|
11ae48bea1 | ||
|
|
b0f4b29a2d | ||
|
|
c38bb1cd01 | ||
|
|
7faa727bd2 | ||
|
|
9e40259ca2 | ||
|
|
94a7692735 | ||
|
|
8b803a87f0 | ||
|
|
3ae6e92eaf | ||
|
|
0f3c52a5aa | ||
|
|
747be7aa09 | ||
|
|
d651f204f8 | ||
|
|
2bd3ac7a9b | ||
|
|
5e437b34e3 | ||
|
|
a5ef89a128 | ||
|
|
558b8e4eca | ||
|
|
0a001b8a63 | ||
|
|
6c6c9654c1 | ||
|
|
e21fdf46e0 | ||
|
|
a2ff5f58ed | ||
|
|
b0eedee519 | ||
|
|
e101155a4c | ||
|
|
3c9bcc9bb0 | ||
|
|
6763844030 | ||
|
|
08fa436885 | ||
|
|
df8fa79209 | ||
|
|
8041db4f40 | ||
|
|
18e57e4097 | ||
|
|
cc584ba377 | ||
|
|
0c92c20c65 | ||
|
|
ec8af56f0d | ||
|
|
79341bf562 | ||
|
|
3f24d5c8e1 | ||
|
|
66a1739666 | ||
|
|
29007fdbfa | ||
|
|
be3e8ebd51 | ||
|
|
38ad345f85 | ||
|
|
414f46a08d | ||
|
|
9144606688 | ||
|
|
effb0f5b91 | ||
|
|
bb7bbb6379 | ||
|
|
dafca8e9db | ||
|
|
41b7aec084 | ||
|
|
dfe1cce6de | ||
|
|
fc793592f7 | ||
|
|
c3ac41f894 | ||
|
|
d0688782b1 | ||
|
|
25b41fe62c | ||
|
|
b468478fcb | ||
|
|
b6dc934198 | ||
|
|
f58078e8a0 | ||
|
|
b6457e47e6 | ||
|
|
d275466e1e | ||
|
|
f9cb107728 | ||
|
|
68ad206252 | ||
|
|
288bead792 | ||
|
|
de670ddfd5 | ||
|
|
79d6f34879 | ||
|
|
ceb6c15c97 | ||
|
|
7cf43ad7ab | ||
|
|
ab3397a373 | ||
|
|
8e9f78e982 | ||
|
|
61bbfee640 | ||
|
|
cf4796bd12 | ||
|
|
bd89d8a308 | ||
|
|
df2186d70f | ||
|
|
f9638e830f | ||
|
|
cd55a7fed4 | ||
|
|
fc890a5ba1 | ||
|
|
2ea54b7f2f | ||
|
|
e90e3901f5 | ||
|
|
36d58f29ab | ||
|
|
864c7ae27c | ||
|
|
6d104a2d1c | ||
|
|
89e754245d | ||
|
|
4c68d99c6f | ||
|
|
f8f0a65f0a | ||
|
|
6ff74639b5 | ||
|
|
af80f882da | ||
|
|
84ce7372c3 | ||
|
|
e31d39c5f7 | ||
|
|
2296c1368b | ||
|
|
21d7d96dbc | ||
|
|
956e511fe0 | ||
|
|
e427887f8c | ||
|
|
88ea43fbbe | ||
|
|
29526a80a9 | ||
|
|
48f901fc8c | ||
|
|
6c3f3dc889 | ||
|
|
743410d3ee | ||
|
|
acccbb028f | ||
|
|
8b31fdc780 | ||
|
|
55017b9ac2 | ||
|
|
0288d35c85 | ||
|
|
8b3577dd0b | ||
|
|
ced77ec70d | ||
|
|
e1e90f5c8a | ||
|
|
dc3487583d | ||
|
|
e1d73ebcd5 | ||
|
|
d7cf3b9500 | ||
|
|
8ce4e3c6b2 | ||
|
|
76f7d46b22 | ||
|
|
cfc52ed909 | ||
|
|
cee94d40e1 | ||
|
|
cad7166cbe | ||
|
|
c05fdbd0ec | ||
|
|
c9b26ebaff | ||
|
|
bee1973390 | ||
|
|
57efcab0b5 | ||
|
|
b1ab2b84f8 | ||
|
|
02601e5ee4 | ||
|
|
070479acf3 | ||
|
|
6b2ba74237 | ||
|
|
619de29710 | ||
|
|
12556fa98f | ||
|
|
bef4c0497f | ||
|
|
9193497d36 | ||
|
|
ca6affbde9 | ||
|
|
81c40ce228 | ||
|
|
6ba1d571f2 | ||
|
|
b0663f9b6c | ||
|
|
3ced6f3e9e | ||
|
|
3146d61efe | ||
|
|
a967b6af85 | ||
|
|
db3bc74364 | ||
|
|
eb19d9a846 | ||
|
|
96dd299de7 | ||
|
|
aee0806aa8 | ||
|
|
e39f13bbb4 | ||
|
|
52ec80d384 | ||
|
|
d9f8909bdc | ||
|
|
65aa9dc343 | ||
|
|
9bf7fdf174 | ||
|
|
dafb59e5a8 | ||
|
|
79f86ca87d | ||
|
|
1749fcf6b5 | ||
|
|
e1a0481cdf | ||
|
|
4576b95814 | ||
|
|
e113fa6a61 | ||
|
|
07228afbff | ||
|
|
afdffc2453 | ||
|
|
6348442962 | ||
|
|
fd10c005b0 | ||
|
|
44500d6af9 | ||
|
|
6ddb0b5304 | ||
|
|
8ce0976f88 | ||
|
|
8345a58f2b | ||
|
|
f92234297e | ||
|
|
6a5bcd5990 | ||
|
|
1fe6d9a636 | ||
|
|
97f6ab66c3 | ||
|
|
b78c182d94 | ||
|
|
09a0f83013 | ||
|
|
f68d7420e3 | ||
|
|
55c0fa5f32 | ||
|
|
8b5d1d0298 | ||
|
|
b70e94dc65 | ||
|
|
49a6552168 | ||
|
|
0dd6892b28 | ||
|
|
fec587edad | ||
|
|
712937d661 | ||
|
|
ace434e7b0 | ||
|
|
1e5aa6e8b0 | ||
|
|
441b27f8f4 | ||
|
|
3f3bf70212 | ||
|
|
a12eddbedf | ||
|
|
12abc1709e | ||
|
|
9e6035e7b4 | ||
|
|
20b41d65d3 | ||
|
|
1593bb2088 | ||
|
|
d4f9a20379 | ||
|
|
6e247597a4 | ||
|
|
8a8be4545c | ||
|
|
05f4955989 | ||
|
|
93469fb3b4 | ||
|
|
a70258d69f | ||
|
|
c653d852e4 | ||
|
|
40163d25b8 | ||
|
|
5b4265783e | ||
|
|
720417b6c8 | ||
|
|
bb78f412e0 | ||
|
|
a919b91efb | ||
|
|
a3dcf031c9 | ||
|
|
56eb4b6b84 | ||
|
|
393cb362db | ||
|
|
64e422036c | ||
|
|
468e7ef018 | ||
|
|
e3e4ecbc5c | ||
|
|
545ffc0e9d | ||
|
|
8125c8d5f4 | ||
|
|
304b01d0cf | ||
|
|
a5f687fd76 | ||
|
|
fc1761a55b | ||
|
|
e7c9cad7f6 | ||
|
|
979af82122 | ||
|
|
71a62b9c73 | ||
|
|
bb020cab25 | ||
|
|
1ad35c1310 | ||
|
|
6470b2f4a9 | ||
|
|
c1d5ba55cd | ||
|
|
100e450514 | ||
|
|
0f1d1d9860 | ||
|
|
178a462a4e | ||
|
|
8f4ae613f0 | ||
|
|
9e6963b6ce | ||
|
|
cb9a73c60e | ||
|
|
0e666a4c35 | ||
|
|
c80138a354 | ||
|
|
79a4ebcf65 | ||
|
|
a7118aa785 | ||
|
|
2555cd23a0 | ||
|
|
484fbeca7b | ||
|
|
9a45e0df10 | ||
|
|
9175296fc6 | ||
|
|
5a4a86aeba | ||
|
|
768fa7beb5 | ||
|
|
6645eb9806 | ||
|
|
d7a283c99f | ||
|
|
8f019cd794 | ||
|
|
c1d9cc62cb | ||
|
|
010a1e9e91 | ||
|
|
856c926cde | ||
|
|
72ed312654 | ||
|
|
c7fe4723f7 | ||
|
|
bfbea83a4a | ||
|
|
55c58fe896 | ||
|
|
72949e0950 | ||
|
|
61c0ddb15b | ||
|
|
9ac4024e4e | ||
|
|
7fb2a51a33 | ||
|
|
5b7c0b2bc3 | ||
|
|
fc15175ff9 | ||
|
|
1eec3a09c1 | ||
|
|
60d349dd8b | ||
|
|
0c8d144ffb | ||
|
|
b898f73a05 | ||
|
|
c2b9b0ba0e | ||
|
|
206a4e9057 | ||
|
|
83a0a7dd54 | ||
|
|
63b8a935ae | ||
|
|
16affd11cc | ||
|
|
33b0dec9da | ||
|
|
121a463788 | ||
|
|
3625e70948 | ||
|
|
3d90207172 | ||
|
|
ca7dc19c81 | ||
|
|
d0f16dbbc4 | ||
|
|
fd91c223b7 | ||
|
|
93690b766c | ||
|
|
cc4af9950b | ||
|
|
a6281483dd | ||
|
|
f587816b30 | ||
|
|
eea3c3ab0b | ||
|
|
ba68ab9e06 | ||
|
|
eb94d97ea4 | ||
|
|
04ad02719c | ||
|
|
f68f25b92f | ||
|
|
767cca36c8 | ||
|
|
b30172fe89 | ||
|
|
b2934b0cc2 | ||
|
|
50686795d0 | ||
|
|
5561a4dc2f | ||
|
|
15b8780e17 | ||
|
|
15c4d312cb | ||
|
|
ccbea89253 | ||
|
|
7b1e666b3b | ||
|
|
8af212fca8 | ||
|
|
352ee7a622 | ||
|
|
f26d0fac0b | ||
|
|
9612fef2a4 | ||
|
|
a691d49abc | ||
|
|
289eca35ec | ||
|
|
e9ca30257c | ||
|
|
6b21997baa | ||
|
|
6d3dbf84ef | ||
|
|
eb45339c81 | ||
|
|
65e3fdd26c | ||
|
|
34cad85942 | ||
|
|
e407679d2b | ||
|
|
8a08a3e148 | ||
|
|
a01d27d273 | ||
|
|
43f642ae8b | ||
|
|
93c1265de9 | ||
|
|
d08600fbaa | ||
|
|
fb9f8d28f4 | ||
|
|
cd3e9f772d | ||
|
|
7a95441799 | ||
|
|
9345828c97 | ||
|
|
58d88dd079 | ||
|
|
6cbc2374b3 | ||
|
|
12bd11386d | ||
|
|
0497045388 | ||
|
|
d3f1640855 | ||
|
|
960e2fcc61 | ||
|
|
6a012cf400 | ||
|
|
d658ec2099 | ||
|
|
c9a07bd9d7 | ||
|
|
c0136585e6 | ||
|
|
dc4cfc49ad | ||
|
|
9fc72868d7 | ||
|
|
7d4c9d6c1e | ||
|
|
e68b1a52ee | ||
|
|
955e8622b5 | ||
|
|
82640de06b | ||
|
|
c93cb19563 | ||
|
|
ccb7855d7d | ||
|
|
bad547356c | ||
|
|
1740bb0021 | ||
|
|
20f05bf317 | ||
|
|
669ede5482 | ||
|
|
ab658f58f9 | ||
|
|
1b3916749a | ||
|
|
e29ec657d3 | ||
|
|
04f3a226a1 | ||
|
|
099d1cc362 | ||
|
|
c11e9bf704 | ||
|
|
ffa0b984a0 | ||
|
|
f0f9261ed3 | ||
|
|
ba433bc244 | ||
|
|
27f8ad982a | ||
|
|
9b89ae7846 | ||
|
|
c11b5342e3 | ||
|
|
b9c88f5b12 | ||
|
|
02c7ab71d4 | ||
|
|
8ec1ac9875 | ||
|
|
e03027a386 | ||
|
|
387cf566c9 | ||
|
|
dff7665934 | ||
|
|
9421d788f3 | ||
|
|
d960574b24 | ||
|
|
e8671cbbf0 | ||
|
|
cbf8039eea | ||
|
|
d7415e3711 | ||
|
|
26e71ee6b5 | ||
|
|
f8c42b3e48 | ||
|
|
886bed9634 | ||
|
|
7b7023fc99 | ||
|
|
701bfe4a54 | ||
|
|
290d7a93ab | ||
|
|
729bcc7ba0 | ||
|
|
e46b68e2f3 | ||
|
|
cdf73c4629 | ||
|
|
2f0dd2d172 | ||
|
|
7e5704bf80 | ||
|
|
ef96fb9a0e | ||
|
|
f2370ad814 | ||
|
|
0783620104 | ||
|
|
2193b02c0a | ||
|
|
aef05bad20 | ||
|
|
e002ab6378 | ||
|
|
a2c2b4a2d4 | ||
|
|
3ae896457b | ||
|
|
683a90ed09 | ||
|
|
5685e25234 | ||
|
|
6edf083e7b | ||
|
|
995d1495eb | ||
|
|
d5f74631cd | ||
|
|
dd5b9b2e81 | ||
|
|
ca78200c1c | ||
|
|
d73c2a65ae | ||
|
|
0ee2cb8e3c | ||
|
|
5ba429ec73 | ||
|
|
709825d073 | ||
|
|
3fe73af5e8 | ||
|
|
ccc0b0dd7d | ||
|
|
1114ede267 | ||
|
|
5546f12499 | ||
|
|
4f38acb592 | ||
|
|
39f59e023c | ||
|
|
5ed5322fce | ||
|
|
33b926d547 | ||
|
|
af8778c6cd | ||
|
|
bb4ad08cc5 | ||
|
|
ee4eb7b3f0 | ||
|
|
5bc024a6ee | ||
|
|
0546caeb05 | ||
|
|
a75348d3ee | ||
|
|
3f3f947f43 | ||
|
|
5127b7c959 | ||
|
|
a28df78792 | ||
|
|
4f888f16bc | ||
|
|
55ed9d7132 | ||
|
|
779a56c3a1 | ||
|
|
f6440bde07 | ||
|
|
930dc2fd1c | ||
|
|
1e96fd529b | ||
|
|
167c865bb2 | ||
|
|
4e6b6c11a3 | ||
|
|
bfdb71ca91 | ||
|
|
f590f9824e | ||
|
|
b3a087702d | ||
|
|
1a786fb418 | ||
|
|
650d38e212 | ||
|
|
130cb7ad93 | ||
|
|
85e8ffeaa3 | ||
|
|
db40f608fa | ||
|
|
a7b471682a | ||
|
|
4595fea7d7 | ||
|
|
4580f8d9a4 | ||
|
|
37f76fa133 | ||
|
|
5507c35547 | ||
|
|
350328f99c | ||
|
|
91ec6b773d | ||
|
|
c0fdf23133 | ||
|
|
28523a9593 | ||
|
|
07e0b52ea3 | ||
|
|
008e758da8 | ||
|
|
7ce219a6a8 | ||
|
|
1809d4a53f | ||
|
|
54ae026c2b | ||
|
|
57c647fa0e | ||
|
|
9745f23fb2 | ||
|
|
7d2062f298 | ||
|
|
306a1948cf | ||
|
|
ac4c00d3f2 | ||
|
|
db43bc300e | ||
|
|
6494cded62 | ||
|
|
b5c1e7e2da | ||
|
|
83e311870f | ||
|
|
8fc6e3b335 | ||
|
|
18a69e97b3 | ||
|
|
80d8449f02 | ||
|
|
d830aa1d1a | ||
|
|
d473126d4c | ||
|
|
17f8fe69fb | ||
|
|
7263d102c7 | ||
|
|
815b2235a2 | ||
|
|
f61247f8bb | ||
|
|
205d9d528e | ||
|
|
1044f901bd | ||
|
|
de261a38fd | ||
|
|
81776e60fb | ||
|
|
0c179eae86 | ||
|
|
c30de98449 | ||
|
|
47990cde46 | ||
|
|
b098c210ce | ||
|
|
6edd8bf119 | ||
|
|
de0e124ebb | ||
|
|
5fc012a557 | ||
|
|
32afcc3478 | ||
|
|
a27f37945d | ||
|
|
0adfe6911f | ||
|
|
8bb1964b30 | ||
|
|
cc95e1ae63 | ||
|
|
9843f18c96 | ||
|
|
18ea61fcc0 | ||
|
|
5b3fdb6e62 | ||
|
|
c5220c8d06 | ||
|
|
74e87a7fae | ||
|
|
c809f50c3b | ||
|
|
e7e46f78c7 | ||
|
|
2b449a1f3d | ||
|
|
f257f2c551 | ||
|
|
7f91d73d27 | ||
|
|
15a90edb03 | ||
|
|
b2f1e16d9a | ||
|
|
f544455708 | ||
|
|
5118997188 | ||
|
|
b6ae649cd1 | ||
|
|
a6d0d85b84 | ||
|
|
c71c2494af | ||
|
|
4a3a7bc98d | ||
|
|
4e4f7f693d | ||
|
|
7a729c39a6 | ||
|
|
48b757f693 | ||
|
|
b449938730 | ||
|
|
ba55ff6a4b | ||
|
|
2b4bc621ac | ||
|
|
acac2d0fc5 | ||
|
|
6ff5975918 | ||
|
|
8912cfe37b | ||
|
|
9e02c318a1 | ||
|
|
f6b51c21d5 | ||
|
|
df3e4b055f | ||
|
|
ffcd2b4179 | ||
|
|
7afdbc2a53 | ||
|
|
5004481685 | ||
|
|
1905741a7c | ||
|
|
8bf7451107 | ||
|
|
fdfb708ce0 | ||
|
|
68a4eee8bb | ||
|
|
746ebfffbd | ||
|
|
213f8645bc | ||
|
|
1b2541bd84 | ||
|
|
522de89792 | ||
|
|
5feaa45edf | ||
|
|
9333775e0e | ||
|
|
afd15ad450 | ||
|
|
e7fc9e6fe5 | ||
|
|
021f9b1d0e | ||
|
|
dc21f05482 | ||
|
|
c22ccf71a7 | ||
|
|
d5b9a074e6 | ||
|
|
63e6104736 | ||
|
|
de475c9561 | ||
|
|
3f4fa0c67c | ||
|
|
ca3f228b89 | ||
|
|
2b159041ac | ||
|
|
5fa564969e | ||
|
|
8ba8627281 | ||
|
|
a12cb59a51 | ||
|
|
dd124fab33 | ||
|
|
9f8ef4c1f3 | ||
|
|
563c6d0403 | ||
|
|
1bad0fe048 | ||
|
|
b40dcd3164 | ||
|
|
8a0b443d28 | ||
|
|
5f38503ac5 | ||
|
|
231d1209c9 | ||
|
|
06bc180127 | ||
|
|
fb2221fa8d | ||
|
|
e1c7f38bb8 | ||
|
|
a82fa578eb | ||
|
|
0ecefdcc1c | ||
|
|
8bacc8128b | ||
|
|
3dff96152c | ||
|
|
79e198d441 | ||
|
|
e6cf33e262 | ||
|
|
f839ca674b | ||
|
|
f9427d1258 | ||
|
|
bd2cdc9bd0 | ||
|
|
67352311de | ||
|
|
555a7682e1 | ||
|
|
19d94489d7 | ||
|
|
0175cfa986 | ||
|
|
1b0ff36d51 | ||
|
|
f5c499ab60 | ||
|
|
81e3fd346a | ||
|
|
3bca1f5304 | ||
|
|
593e96a31a | ||
|
|
169b783e4f | ||
|
|
f129742a41 | ||
|
|
01016d1b14 | ||
|
|
a883e43acb | ||
|
|
e0d0657e7b | ||
|
|
e4c27bd49c | ||
|
|
7a31fd31eb | ||
|
|
4f0f2c3213 | ||
|
|
d9889b06ad | ||
|
|
9f5cff4ab8 | ||
|
|
958543a171 | ||
|
|
b8e03f5e13 | ||
|
|
e76218b39f | ||
|
|
7d1a375bda | ||
|
|
dfaa80f1e4 | ||
|
|
8939a903be | ||
|
|
d6f1ea78fa | ||
|
|
f25c4c74be | ||
|
|
1619df1ba3 | ||
|
|
566169cdbf | ||
|
|
ee3e631789 | ||
|
|
8202754988 | ||
|
|
447d7596dc | ||
|
|
7c4568066f | ||
|
|
46ee7a5983 | ||
|
|
5a8ef99afa | ||
|
|
571729cfe4 | ||
|
|
c1de316ed6 | ||
|
|
1e316acd64 | ||
|
|
f89460538d | ||
|
|
0bf0062c2c | ||
|
|
540d1b5801 | ||
|
|
8f0d1b7b7b | ||
|
|
5061791dcf | ||
|
|
ce98264552 | ||
|
|
15e6750e11 | ||
|
|
7a20421580 | ||
|
|
f42f06226c | ||
|
|
cce3d74c52 | ||
|
|
7b9fb880f4 | ||
|
|
2a9b0a54d9 | ||
|
|
373e9e9755 | ||
|
|
4115dd5797 | ||
|
|
57bfd251dc | ||
|
|
d324790f66 | ||
|
|
99d3dba440 | ||
|
|
5a44e79ad1 | ||
|
|
86808017db | ||
|
|
f735db9843 | ||
|
|
5a333c23ac | ||
|
|
4d2d51edfa | ||
|
|
dcfd5a0cd8 | ||
|
|
a821c0da50 |
No files matched your search
+1
-1
@@ -10,4 +10,4 @@ libs/
|
||||
|
||||
collection/
|
||||
|
||||
src/main/java/com/volmit/iris/util/uniques/
|
||||
/core/src/main/java/com/volmit/iris/util/uniques/
|
||||
@@ -15,30 +15,27 @@ Consider supporting our development by buying Iris on spigot! We work hard to ma
|
||||
|
||||
### Command Line Builds
|
||||
|
||||
1. Install [Java JDK 17](https://www.oracle.com/java/technologies/javase/jdk17-archive-downloads.html)
|
||||
1. Install [Java JDK 21](https://www.oracle.com/java/technologies/javase/jdk21-archive-downloads.html)
|
||||
2. Set the JDK installation path to `JAVA_HOME` as an environment variable.
|
||||
* Windows
|
||||
1. Start > Type `env` and press Enter
|
||||
2. Advanced > Environment Variables
|
||||
3. Under System Variables, click `New...`
|
||||
4. Variable Name: `JAVA_HOME`
|
||||
5. Variable Value: `C:\Program Files\Java\jdk-17.0.1` (verify this exists after installing java don't just copy
|
||||
5. Variable Value: `C:\Program Files\Java\jdk-21.0.1` (verify this exists after installing java don't just copy
|
||||
the example text)
|
||||
* MacOS
|
||||
1. Run `/usr/libexec/java_home -V` and look for Java 17
|
||||
1. Run `/usr/libexec/java_home -V` and look for Java 21
|
||||
2. Run `sudo nano ~/.zshenv`
|
||||
3. Add `export JAVA_HOME=$(/usr/libexec/java_home)` as a new line
|
||||
4. Use `CTRL + X`, then Press `Y`, Then `ENTER`
|
||||
5. Quit & Reopen Terminal and verify with `echo $JAVA_HOME`. It should print a directory
|
||||
3. If this is your first time building Iris for MC 1.18+ run `gradlew setup` inside the root Iris project folder.
|
||||
Otherwise, skip this step. Grab a coffee, this may take up to 5 minutes depending on your cpu & internet connection.
|
||||
4. Once the project has setup, run `gradlew iris`
|
||||
5. The Iris jar will be placed in `Iris/build/Iris-XXX-XXX.jar` Enjoy! Consider supporting us by buying it on spigot!
|
||||
3. Once the project has setup, run `gradlew iris`
|
||||
4. The Iris jar will be placed in `Iris/build/Iris-XXX-XXX.jar` Enjoy! Consider supporting us by buying it on spigot!
|
||||
|
||||
### IDE Builds (for development)
|
||||
|
||||
* Run `gradlew setup` any time you get dependency issues with craftbukkit
|
||||
* Configure ITJ Gradle to use JDK 17 (in settings, search for gradle)
|
||||
* Configure ITJ Gradle to use JDK 21 (in settings, search for gradle)
|
||||
* Add a build line in the build.gradle for your own build task to directly compile Iris into your plugins folder if you
|
||||
prefer.
|
||||
* Resync the project & run your newly created task (under the development folder in gradle tasks!)
|
||||
@@ -69,7 +66,6 @@ IrisAccess access=IrisToolbelt.createWorld() // If you like builders...
|
||||
.name("myWorld") // The world name
|
||||
.dimension("terrifyinghands")
|
||||
.seed(69133742) // The world seed
|
||||
.headless(true) // Headless make gen go fast
|
||||
.pregen(PregenTask // Define a pregen job to run
|
||||
.builder()
|
||||
.center(new Position2(0,0)) // REGION coords (1 region = 32x32 chunks)
|
||||
|
||||
-334
@@ -1,334 +0,0 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2021 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
plugins {
|
||||
id 'java'
|
||||
id 'java-library'
|
||||
id "io.freefair.lombok" version "6.3.0"
|
||||
id "com.github.johnrengelman.shadow" version "7.1.2"
|
||||
id "de.undercouch.download" version "5.0.1"
|
||||
}
|
||||
|
||||
version '2.7.4-1.20.1'
|
||||
def nmsVersion = '1.20.1' //[NMS]
|
||||
def apiVersion = '1.20'
|
||||
def specialSourceVersion = '1.11.0' //[NMS]
|
||||
def spigotJarVersion = '1.20.1-R0.1-SNAPSHOT' //[NMS]
|
||||
def name = getRootProject().getName() // Defined in settings.gradle
|
||||
def main = 'com.volmit.iris.Iris'
|
||||
|
||||
// ADD YOURSELF AS A NEW LINE IF YOU WANT YOUR OWN BUILD TASK GENERATED
|
||||
// ======================== WINDOWS =============================
|
||||
registerCustomOutputTask('Cyberpwn', 'C://Users/cyberpwn/Documents/development/server/plugins')
|
||||
registerCustomOutputTask('Psycho', 'C://Dan/MinecraftDevelopment/Server/plugins')
|
||||
registerCustomOutputTask('ArcaneArts', 'C://Users/arcane/Documents/development/server/plugins')
|
||||
registerCustomOutputTask('Coco', 'D://mcsm/plugins')
|
||||
registerCustomOutputTask('Strange', 'D://Servers/1.17 Test Server/plugins')
|
||||
registerCustomOutputTask('Vatuu', 'D://Minecraft/Servers/1.19.4/plugins')
|
||||
// ========================== UNIX ==============================
|
||||
registerCustomOutputTaskUnix('CyberpwnLT', '/Users/danielmills/development/server/plugins')
|
||||
registerCustomOutputTaskUnix('PsychoLT', '/Users/brianfopiano/Desktop/REMOTES/RemoteMinecraft/plugins')
|
||||
// ==============================================================
|
||||
|
||||
/**
|
||||
* Gradle is weird sometimes, we need to delete the plugin yml from the build folder to actually filter properly.
|
||||
*/
|
||||
file(jar.archiveFile.get().getAsFile().getParentFile().getParentFile().getParentFile().getAbsolutePath() + '/build/resources/main/plugin.yml').delete()
|
||||
|
||||
/**
|
||||
* Expand properties into plugin yml
|
||||
*/
|
||||
processResources {
|
||||
filesMatching('**/plugin.yml') {
|
||||
expand(
|
||||
'name': name.toString(),
|
||||
'version': version.toString(),
|
||||
'main': main.toString(),
|
||||
'apiversion': apiVersion.toString()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified repo
|
||||
*/
|
||||
repositories {
|
||||
mavenLocal {
|
||||
content {
|
||||
includeGroup("org.bukkit")
|
||||
includeGroup("org.spigotmc")
|
||||
}
|
||||
}
|
||||
mavenCentral()
|
||||
maven { url "https://arcanearts.jfrog.io/artifactory/archives" }
|
||||
maven { url "https://mvn.lumine.io/repository/maven-public/" }
|
||||
maven { url "https://jitpack.io"}
|
||||
|
||||
maven { url "https://s01.oss.sonatype.org/content/repositories/snapshots" }
|
||||
maven { url "https://mvn.lumine.io/repository/maven/" }
|
||||
maven { url "https://repo.triumphteam.dev/snapshots" }
|
||||
maven { url "https://repo.mineinabyss.com/releases" }
|
||||
maven { url = 'https://hub.jeff-media.com/nexus/repository/jeff-media-public/' }
|
||||
}
|
||||
|
||||
/**
|
||||
* We need parameter meta for the decree command system
|
||||
*/
|
||||
compileJava {
|
||||
options.compilerArgs << '-parameters'
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure Iris for shading
|
||||
*/
|
||||
shadowJar {
|
||||
//minimize()
|
||||
append("plugin.yml")
|
||||
relocate 'com.dfsek.paralithic', 'com.volmit.iris.util.paralithic'
|
||||
relocate 'io.papermc.lib', 'com.volmit.iris.util.paper'
|
||||
relocate 'net.kyori', 'com.volmit.iris.util.kyori'
|
||||
dependencies {
|
||||
include(dependency('io.papermc:paperlib'))
|
||||
include(dependency('com.dfsek:Paralithic'))
|
||||
include(dependency('net.kyori:'))
|
||||
}
|
||||
}
|
||||
|
||||
configurations.all {
|
||||
resolutionStrategy.cacheChangingModulesFor 60, 'minutes'
|
||||
resolutionStrategy.cacheDynamicVersionsFor 60, 'minutes'
|
||||
}
|
||||
|
||||
/**
|
||||
* Dependencies.
|
||||
*
|
||||
* Provided or classpath dependencies are not shaded and are available on the runtime classpath
|
||||
*
|
||||
* Shaded dependencies are not available at runtime, nor are they available on mvn central so they
|
||||
* need to be shaded into the jar (increasing binary size)
|
||||
*
|
||||
* Dynamically loaded dependencies are defined in the plugin.yml (updating these must be updated in the
|
||||
* plugin.yml also, otherwise they wont be available). These do not increase binary size). Only declare
|
||||
* these dependencies if they are available on mvn central.
|
||||
*/
|
||||
dependencies {
|
||||
// Provided or Classpath
|
||||
compileOnly 'org.projectlombok:lombok:1.18.24'
|
||||
annotationProcessor 'org.projectlombok:lombok:1.18.24'
|
||||
implementation 'org.spigotmc:spigot-api:1.20.1-R0.1-SNAPSHOT'
|
||||
implementation 'org.bukkit:craftbukkit:1.20.1-R0.1-SNAPSHOT:remapped-mojang' //[NMS]
|
||||
|
||||
// Third Party Integrations
|
||||
implementation 'com.ticxo.playeranimator:PlayerAnimator:R1.2.7'
|
||||
implementation 'com.github.oraxen:oraxen:1.158.0'
|
||||
implementation 'com.github.LoneDev6:api-itemsadder:3.4.1-r4'
|
||||
implementation 'me.clip:placeholderapi:2.11.3'
|
||||
//implementation files('libs/CustomItems.jar')
|
||||
|
||||
// Shaded
|
||||
implementation 'com.dfsek:Paralithic:0.4.0'
|
||||
implementation 'io.papermc:paperlib:1.0.5'
|
||||
implementation "net.kyori:adventure-text-minimessage:4.13.1"
|
||||
implementation 'net.kyori:adventure-platform-bukkit:4.3.0'
|
||||
implementation 'net.kyori:adventure-api:4.13.1'
|
||||
implementation 'io.lumine:Mythic-Dist:5.2.1'
|
||||
|
||||
// Dynamically Loaded
|
||||
implementation 'io.timeandspace:smoothie-map:2.0.2'
|
||||
implementation 'it.unimi.dsi:fastutil:8.5.8'
|
||||
implementation 'com.googlecode.concurrentlinkedhashmap:concurrentlinkedhashmap-lru:1.4.2'
|
||||
implementation 'org.zeroturnaround:zt-zip:1.14'
|
||||
implementation 'com.google.code.gson:gson:2.9.0'
|
||||
implementation 'org.ow2.asm:asm:9.2'
|
||||
implementation 'com.google.guava:guava:31.1-jre'
|
||||
implementation 'bsf:bsf:2.4.0'
|
||||
implementation 'rhino:js:1.7R2'
|
||||
implementation 'com.github.ben-manes.caffeine:caffeine:3.0.6'
|
||||
implementation 'org.apache.commons:commons-lang3:3.12.0'
|
||||
}
|
||||
|
||||
if (JavaVersion.current().toString() != "17") {
|
||||
System.err.println()
|
||||
System.err.println("=========================================================================================================")
|
||||
System.err.println("You must run gradle on Java 17. You are using " + JavaVersion.current())
|
||||
System.err.println()
|
||||
System.err.println("=== For IDEs ===")
|
||||
System.err.println("1. Configure the project for Java 17")
|
||||
System.err.println("2. Configure the bundled gradle to use Java 17 in settings")
|
||||
System.err.println()
|
||||
System.err.println("=== For Command Line (gradlew) ===")
|
||||
System.err.println("1. Install JDK 17 from https://www.oracle.com/java/technologies/javase/jdk17-archive-downloads.html")
|
||||
System.err.println("2. Set JAVA_HOME environment variable to the new jdk installation folder such as C:\\Program Files\\Java\\jdk-17.0.1")
|
||||
System.err.println("3. Open a new command prompt window to get the new environment variables if need be.")
|
||||
System.err.println("=========================================================================================================")
|
||||
System.err.println()
|
||||
System.exit(69);
|
||||
}
|
||||
|
||||
def buildToolsJar = new File(buildDir, "buildtools/BuildTools.jar");
|
||||
def specialSourceJar = new File(buildDir, "specialsource/SpecialSource.jar");
|
||||
def buildToolsFolder = new File(buildDir, "buildtools");
|
||||
def specialSourceFolder = new File(buildDir, "specialsource");
|
||||
def buildToolsHint = new File(buildDir, "buildtools/craftbukkit-" + nmsVersion + ".jar");
|
||||
def outputShadeJar = new File(buildDir, "libs/Iris-" + version + "-all.jar");
|
||||
def ssiJar = new File(buildDir, "specialsource/Iris-" + version + "-all.jar");
|
||||
def ssobfJar = new File(buildDir, "specialsource/Iris-" + version + "-rmo.jar");
|
||||
def ssJar = new File(buildDir, "specialsource/Iris-" + version + "-rma.jar");
|
||||
def homePath = System.properties['user.home']
|
||||
def m2 = new File(homePath + "/.m2/repository")
|
||||
def m2s = m2.getAbsolutePath();
|
||||
|
||||
// ======================== Building Mapped Jars =============================
|
||||
task downloadBuildtools(type: Download) {
|
||||
group "remapping"
|
||||
src 'https://hub.spigotmc.org/jenkins/job/BuildTools/lastSuccessfulBuild/artifact/target/BuildTools.jar'
|
||||
dest buildToolsJar
|
||||
onlyIf {
|
||||
!buildToolsJar.exists()
|
||||
}
|
||||
}
|
||||
|
||||
task downloadSpecialSource(type: Download) {
|
||||
group "remapping"
|
||||
src 'https://repo.maven.apache.org/maven2/net/md-5/SpecialSource/' + specialSourceVersion + '/SpecialSource-'+specialSourceVersion+'-shaded.jar'
|
||||
dest specialSourceJar
|
||||
onlyIf {
|
||||
!specialSourceJar.exists()
|
||||
}
|
||||
}
|
||||
|
||||
task executeBuildTools(dependsOn: downloadBuildtools, type: JavaExec)
|
||||
{
|
||||
group "remapping"
|
||||
classpath = files(buildToolsJar)
|
||||
workingDir = buildToolsFolder
|
||||
args = [
|
||||
"--rev",
|
||||
nmsVersion,
|
||||
"--compile",
|
||||
"craftbukkit",
|
||||
"--remap"
|
||||
]
|
||||
onlyIf {
|
||||
!buildToolsHint.exists()
|
||||
}
|
||||
}
|
||||
|
||||
task copyBuildToSpecialSource(type: Copy)
|
||||
{
|
||||
group "remapping"
|
||||
from outputShadeJar
|
||||
into specialSourceFolder
|
||||
dependsOn(downloadSpecialSource, shadowJar)
|
||||
}
|
||||
|
||||
task specialSourceRemapObfuscate(type: JavaExec)
|
||||
{
|
||||
group "remapping"
|
||||
dependsOn(copyBuildToSpecialSource, downloadSpecialSource, shadowJar)
|
||||
workingDir = specialSourceFolder
|
||||
classpath = files(specialSourceJar,
|
||||
new File(m2s + "/org/spigotmc/spigot/" + spigotJarVersion + "/spigot-" + spigotJarVersion + "-remapped-mojang.jar"))
|
||||
mainClass = "net.md_5.specialsource.SpecialSource"
|
||||
args = [
|
||||
"--live",
|
||||
"-i",
|
||||
ssiJar.getName(),
|
||||
"-o",
|
||||
ssobfJar.getName(),
|
||||
"-m",
|
||||
m2s + "/org/spigotmc/minecraft-server/" + spigotJarVersion + "/minecraft-server-" + spigotJarVersion + "-maps-mojang.txt",
|
||||
"--reverse",
|
||||
]
|
||||
}
|
||||
|
||||
task specialSourceRemap(type: JavaExec)
|
||||
{
|
||||
group "remapping"
|
||||
dependsOn(specialSourceRemapObfuscate)
|
||||
workingDir = specialSourceFolder
|
||||
classpath = files(specialSourceJar,
|
||||
new File(m2s + "/org/spigotmc/spigot/" + spigotJarVersion + "/spigot-" + spigotJarVersion + "-remapped-obf.jar"))
|
||||
mainClass = "net.md_5.specialsource.SpecialSource"
|
||||
args = [
|
||||
"--live",
|
||||
"-i",
|
||||
ssobfJar.getName(),
|
||||
"-o",
|
||||
ssJar.getName(),
|
||||
"-m",
|
||||
m2s + "/org/spigotmc/minecraft-server/" + spigotJarVersion + "/minecraft-server-" + spigotJarVersion + "-maps-spigot.csrg"
|
||||
]
|
||||
}
|
||||
|
||||
tasks.compileJava.dependsOn(executeBuildTools)
|
||||
|
||||
compileJava {
|
||||
options.encoding = "UTF-8"
|
||||
}
|
||||
|
||||
task setup()
|
||||
{
|
||||
group("iris")
|
||||
dependsOn(clean, executeBuildTools)
|
||||
}
|
||||
|
||||
task iris(type: Copy)
|
||||
{
|
||||
group "iris"
|
||||
from ssJar
|
||||
into buildDir
|
||||
rename { String fileName ->
|
||||
fileName.replace('Iris-' + version + '-rma.jar', "Iris-" + version + ".jar")
|
||||
}
|
||||
dependsOn(specialSourceRemap)
|
||||
}
|
||||
|
||||
def registerCustomOutputTask(name, path) {
|
||||
if (!System.properties['os.name'].toLowerCase().contains('windows')) {
|
||||
return;
|
||||
}
|
||||
|
||||
tasks.register('build' + name, Copy) {
|
||||
group('development')
|
||||
outputs.upToDateWhen { false }
|
||||
dependsOn(iris)
|
||||
from(new File(buildDir, "Iris-" + version + ".jar"))
|
||||
into(file(path))
|
||||
rename { String fileName ->
|
||||
fileName.replace("Iris-" + version + ".jar", "Iris.jar")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def registerCustomOutputTaskUnix(name, path) {
|
||||
if (System.properties['os.name'].toLowerCase().contains('windows')) {
|
||||
return;
|
||||
}
|
||||
|
||||
tasks.register('build' + name, Copy) {
|
||||
group('development')
|
||||
outputs.upToDateWhen { false }
|
||||
dependsOn(iris)
|
||||
from(new File(buildDir, "Iris-" + version + ".jar"))
|
||||
into(file(path))
|
||||
rename { String fileName ->
|
||||
fileName.replace("Iris-" + version + ".jar", "Iris.jar")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import de.undercouch.gradle.tasks.download.Download
|
||||
import org.gradle.jvm.toolchain.JavaLanguageVersion
|
||||
import xyz.jpenilla.runpaper.task.RunServer
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2021 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
buildscript {
|
||||
repositories.maven("https://jitpack.io")
|
||||
dependencies.classpath("com.github.VolmitSoftware:NMSTools:c88961416f")
|
||||
}
|
||||
|
||||
plugins {
|
||||
java
|
||||
`java-library`
|
||||
alias(libs.plugins.download)
|
||||
alias(libs.plugins.runPaper)
|
||||
}
|
||||
|
||||
group = "com.volmit"
|
||||
version = "3.9.2-1.20.1-26.3"
|
||||
|
||||
apply<ApiGenerator>()
|
||||
|
||||
// ADD YOURSELF AS A NEW LINE IF YOU WANT YOUR OWN BUILD TASK GENERATED
|
||||
// ======================== WINDOWS =============================
|
||||
registerCustomOutputTask("Cyberpwn", "C://Users/cyberpwn/Documents/development/server/plugins")
|
||||
registerCustomOutputTask("Psycho", "C://Dan/MinecraftDevelopment/Server/plugins")
|
||||
registerCustomOutputTask("ArcaneArts", "C://Users/arcane/Documents/development/server/plugins")
|
||||
registerCustomOutputTask("Coco", "D://mcsm/plugins")
|
||||
registerCustomOutputTask("Strange", "D://Servers/1.17 Test Server/plugins")
|
||||
registerCustomOutputTask("Vatuu", "D://Minecraft/Servers/1.19.4/plugins")
|
||||
registerCustomOutputTask("CrazyDev22", "C://Users/Julian/Desktop/server/plugins")
|
||||
registerCustomOutputTask("PixelFury", "C://Users/repix/workplace/Iris/1.21.3 - Development-Public-v3/plugins")
|
||||
registerCustomOutputTask("PixelFuryDev", "C://Users/repix/workplace/Iris/1.21 - Development-v3/plugins")
|
||||
// ========================== UNIX ==============================
|
||||
registerCustomOutputTaskUnix("CyberpwnLT", "/Users/danielmills/development/server/plugins")
|
||||
registerCustomOutputTaskUnix("PsychoLT", "/Users/brianfopiano/Developer/RemoteGit/[Minecraft Server]/plugins")
|
||||
registerCustomOutputTaskUnix("PixelMac", "/Users/test/Desktop/mcserver/plugins")
|
||||
registerCustomOutputTaskUnix("CrazyDev22LT", "/home/julian/Desktop/server/plugins")
|
||||
// ==============================================================
|
||||
|
||||
val serverMinHeap = "10G"
|
||||
val serverMaxHeap = "10G"
|
||||
val additionalFlags = "-XX:+AlwaysPreTouch"
|
||||
//Valid values are: none, truecolor, indexed256, indexed16, indexed8
|
||||
val color = "truecolor"
|
||||
val errorReporting = "true" == findProperty("errorReporting")
|
||||
|
||||
val nmsBindings = mapOf(
|
||||
"v26_3_R1" to "26.3.build.176-stable",
|
||||
"v26_1_R1" to "26.1.2.build.74-stable",
|
||||
"v1_21_R7" to "1.21.11-R0.1-SNAPSHOT",
|
||||
"v1_21_R6" to "1.21.10-R0.1-SNAPSHOT",
|
||||
"v1_21_R5" to "1.21.8-R0.1-SNAPSHOT",
|
||||
"v1_21_R4" to "1.21.5-R0.1-SNAPSHOT",
|
||||
"v1_21_R3" to "1.21.4-R0.1-SNAPSHOT",
|
||||
"v1_21_R2" to "1.21.3-R0.1-SNAPSHOT",
|
||||
"v1_21_R1" to "1.21.1-R0.1-SNAPSHOT",
|
||||
"v1_20_R4" to "1.20.6-R0.1-SNAPSHOT",
|
||||
"v1_20_R3" to "1.20.4-R0.1-SNAPSHOT",
|
||||
"v1_20_R2" to "1.20.2-R0.1-SNAPSHOT",
|
||||
"v1_20_R1" to "1.20.1-R0.1-SNAPSHOT",
|
||||
)
|
||||
val jvmVersion = mapOf("v26_3_R1" to 25, "v26_1_R1" to 25)
|
||||
// From 26.1 on the server is not obfuscated, so these bindings compile against Paper's dev bundle as-is.
|
||||
val userDevBindings = setOf("v26_3_R1", "v26_1_R1")
|
||||
nmsBindings.forEach { (key, value) ->
|
||||
project(":nms:$key") {
|
||||
apply<JavaPlugin>()
|
||||
|
||||
nmsBinding {
|
||||
jvm = jvmVersion.getOrDefault(key, 21)
|
||||
version = value
|
||||
type = if (key in userDevBindings) NMSBinding.Type.USER_DEV else NMSBinding.Type.DIRECT
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compileOnly(project(":core"))
|
||||
compileOnly(rootProject.libs.annotations)
|
||||
compileOnly(rootProject.libs.byteBuddy.core)
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register<RunServer>("runServer-$key") {
|
||||
group = "servers"
|
||||
minecraftVersion(value.substringBefore("-").substringBefore(".build"))
|
||||
minHeapSize = serverMinHeap
|
||||
maxHeapSize = serverMaxHeap
|
||||
pluginJars(tasks.jar.flatMap { it.archiveFile })
|
||||
javaLauncher = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(jvmVersion.getOrDefault(key, 21))}
|
||||
runDirectory.convention(layout.buildDirectory.dir("run/$key"))
|
||||
systemProperty("disable.watchdog", "true")
|
||||
systemProperty("net.kyori.ansi.colorLevel", color)
|
||||
systemProperty("com.mojang.eula.agree", true)
|
||||
systemProperty("iris.suppressReporting", !errorReporting)
|
||||
jvmArgs("-javaagent:${project(":core:agent").tasks.jar.flatMap { it.archiveFile }.get().asFile.absolutePath}")
|
||||
jvmArgs(additionalFlags.split(' '))
|
||||
}
|
||||
}
|
||||
|
||||
val included: Configuration by configurations.creating
|
||||
val jarJar: Configuration by configurations.creating
|
||||
dependencies {
|
||||
for (key in nmsBindings.keys) {
|
||||
included(project(":nms:$key", if (key in userDevBindings) "runtimeElements" else "reobf"))
|
||||
}
|
||||
included(project(":core", "shadow"))
|
||||
jarJar(project(":core:agent"))
|
||||
}
|
||||
|
||||
tasks {
|
||||
jar {
|
||||
inputs.files(included)
|
||||
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||
from(jarJar, provider { included.resolve().map(::zipTree) })
|
||||
archiveFileName.set("Iris-${project.version}.jar")
|
||||
}
|
||||
|
||||
register<Copy>("iris") {
|
||||
group = "iris"
|
||||
dependsOn("jar")
|
||||
from(layout.buildDirectory.file("libs/Iris-${project.version}.jar"))
|
||||
into(layout.buildDirectory)
|
||||
}
|
||||
|
||||
register<Copy>("irisDev") {
|
||||
group = "iris"
|
||||
from(project(":core").layout.buildDirectory.files("libs/core-javadoc.jar", "libs/core-sources.jar"))
|
||||
rename { it.replace("core", "Iris-${project.version}") }
|
||||
into(layout.buildDirectory)
|
||||
dependsOn(":core:sourcesJar")
|
||||
dependsOn(":core:javadocJar")
|
||||
}
|
||||
|
||||
val cli = file("sentry-cli.exe")
|
||||
register<Download>("downloadCli") {
|
||||
group = "io.sentry"
|
||||
src("https://release-registry.services.sentry.io/apps/sentry-cli/latest?response=download&arch=x86_64&platform=${System.getProperty("os.name")}&package=sentry-cli")
|
||||
dest(cli)
|
||||
|
||||
doLast {
|
||||
cli.setExecutable(true)
|
||||
}
|
||||
}
|
||||
|
||||
register("release") {
|
||||
group = "io.sentry"
|
||||
dependsOn("downloadCli")
|
||||
doLast {
|
||||
val url = "http://sentry.volmit.com:8080"
|
||||
val authToken = project.findProperty("sentry.auth.token") ?: System.getenv("SENTRY_AUTH_TOKEN")
|
||||
val org = "sentry"
|
||||
val projectName = "iris"
|
||||
exec(cli, "--url", url , "--auth-token", authToken, "releases", "new", "-o", org, "-p", projectName, version)
|
||||
exec(cli, "--url", url , "--auth-token", authToken, "releases", "set-commits", "-o", org, "-p", projectName, version, "--auto", "--ignore-missing")
|
||||
//exec(cli, "--url", url, "--auth-token", authToken, "releases", "finalize", "-o", org, "-p", projectName, version)
|
||||
cli.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun exec(vararg command: Any) {
|
||||
val p = ProcessBuilder(command.map { it.toString() })
|
||||
.start()
|
||||
p.inputStream.reader().useLines { it.forEach(::println) }
|
||||
p.errorStream.reader().useLines { it.forEach(::println) }
|
||||
p.waitFor()
|
||||
}
|
||||
|
||||
configurations.configureEach {
|
||||
resolutionStrategy.cacheChangingModulesFor(60, "minutes")
|
||||
resolutionStrategy.cacheDynamicVersionsFor(60, "minutes")
|
||||
}
|
||||
|
||||
allprojects {
|
||||
apply<JavaPlugin>()
|
||||
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion.set(JavaLanguageVersion.of(jvmVersion.getOrDefault(name, 21)))
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
maven("https://repo.papermc.io/repository/maven-public/")
|
||||
maven("https://repo.codemc.org/repository/maven-public/")
|
||||
|
||||
maven("https://jitpack.io") // EcoItems, score
|
||||
maven("https://repo.nexomc.com/releases/") // nexo
|
||||
maven("https://maven.devs.beer/") // itemsadder
|
||||
maven("https://repo.extendedclip.com/releases/") // placeholderapi
|
||||
maven("https://mvn.lumine.io/repository/maven-public/") // mythic
|
||||
maven("https://nexus.phoenixdevt.fr/repository/maven-public/") //MMOItems
|
||||
maven("https://repo.onarandombox.com/content/groups/public/") //Multiverse Core
|
||||
maven("https://repo.momirealms.net/releases/") //CraftEngine
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Provided or Classpath
|
||||
compileOnly(rootProject.libs.lombok)
|
||||
annotationProcessor(rootProject.libs.lombok)
|
||||
}
|
||||
|
||||
/**
|
||||
* We need parameter meta for the decree command system
|
||||
*/
|
||||
tasks {
|
||||
compileJava {
|
||||
options.compilerArgs.add("-parameters")
|
||||
options.encoding = "UTF-8"
|
||||
}
|
||||
|
||||
javadoc {
|
||||
options.encoding = "UTF-8"
|
||||
options.quiet()
|
||||
//options.addStringOption("Xdoclint:none") // TODO: Re-enable this
|
||||
}
|
||||
|
||||
register<Jar>("sourcesJar") {
|
||||
archiveClassifier.set("sources")
|
||||
from(sourceSets.main.map { it.allSource })
|
||||
}
|
||||
|
||||
register<Jar>("javadocJar") {
|
||||
archiveClassifier.set("javadoc")
|
||||
from(javadoc.map { it.destinationDir!! })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!JavaVersion.current().isCompatibleWith(JavaVersion.VERSION_21)) {
|
||||
System.err.println()
|
||||
System.err.println("=========================================================================================================")
|
||||
System.err.println("You must run gradle on Java 21 or newer. You are using " + JavaVersion.current())
|
||||
System.err.println()
|
||||
System.err.println("=== For IDEs ===")
|
||||
System.err.println("1. Configure the project for Java 21 toolchain")
|
||||
System.err.println("2. Configure the bundled gradle to use Java 21+ in settings")
|
||||
System.err.println()
|
||||
System.err.println("=== For Command Line (gradlew) ===")
|
||||
System.err.println("1. Install JDK 21 from https://www.oracle.com/java/technologies/javase/jdk21-archive-downloads.html")
|
||||
System.err.println("2. Set JAVA_HOME environment variable to the new jdk installation folder such as C:\\Program Files\\Java\\jdk-21.0.4")
|
||||
System.err.println("3. Open a new command prompt window to get the new environment variables if need be.")
|
||||
System.err.println("=========================================================================================================")
|
||||
System.err.println()
|
||||
exitProcess(69)
|
||||
}
|
||||
|
||||
|
||||
fun registerCustomOutputTask(name: String, path: String) {
|
||||
if (!System.getProperty("os.name").lowercase().contains("windows")) {
|
||||
return
|
||||
}
|
||||
|
||||
tasks.register<Copy>("build$name") {
|
||||
group = "development"
|
||||
outputs.upToDateWhen { false }
|
||||
dependsOn("iris")
|
||||
from(layout.buildDirectory.file("Iris-${project.version}.jar"))
|
||||
into(file(path))
|
||||
rename { "Iris.jar" }
|
||||
}
|
||||
}
|
||||
|
||||
fun registerCustomOutputTaskUnix(name: String, path: String) {
|
||||
if (System.getProperty("os.name").lowercase().contains("windows")) {
|
||||
return
|
||||
}
|
||||
|
||||
tasks.register<Copy>("build$name") {
|
||||
group = "development"
|
||||
outputs.upToDateWhen { false }
|
||||
dependsOn("iris")
|
||||
from(layout.buildDirectory.file("Iris-${project.version}.jar"))
|
||||
into(file(path))
|
||||
rename { "Iris.jar" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import org.gradle.jvm.toolchain.JavaLanguageVersion
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
plugins {
|
||||
kotlin("jvm") version embeddedKotlinVersion
|
||||
}
|
||||
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion.set(JavaLanguageVersion.of(21))
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_21)
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
maven("https://jitpack.io")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("org.ow2.asm:asm:9.8")
|
||||
implementation("com.github.VolmitSoftware:NMSTools:c88961416f")
|
||||
implementation("io.papermc.paperweight:paperweight-userdev:2.0.0-beta.21")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import org.gradle.api.DefaultTask
|
||||
import org.gradle.api.Plugin
|
||||
import org.gradle.api.Project
|
||||
import org.gradle.api.publish.PublishingExtension
|
||||
import org.gradle.api.publish.maven.MavenPublication
|
||||
import org.gradle.api.tasks.InputFile
|
||||
import org.gradle.api.tasks.OutputFile
|
||||
import org.gradle.api.tasks.TaskAction
|
||||
import org.gradle.jvm.tasks.Jar
|
||||
import org.objectweb.asm.*
|
||||
import java.io.File
|
||||
import java.util.jar.JarFile
|
||||
import java.util.jar.JarOutputStream
|
||||
|
||||
class ApiGenerator : Plugin<Project> {
|
||||
override fun apply(target: Project): Unit = with(target) {
|
||||
plugins.apply("maven-publish")
|
||||
val task = tasks.register("irisApi", GenerateApiTask::class.java)
|
||||
extensions.findByType(PublishingExtension::class.java)!!.apply {
|
||||
repositories.maven {
|
||||
it.name = "deployDir"
|
||||
it.url = targetDirectory.toURI()
|
||||
}
|
||||
|
||||
publications.create("maven", MavenPublication::class.java) {
|
||||
it.groupId = name
|
||||
it.version = version.toString()
|
||||
it.artifact(task)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
abstract class GenerateApiTask : DefaultTask() {
|
||||
init {
|
||||
group = "iris"
|
||||
dependsOn("jar")
|
||||
finalizedBy("publishMavenPublicationToDeployDirRepository")
|
||||
doLast {
|
||||
logger.lifecycle("The API is located at ${outputFile.absolutePath}")
|
||||
}
|
||||
}
|
||||
|
||||
@InputFile
|
||||
val inputFile: File = project.tasks
|
||||
.named("jar", Jar::class.java)
|
||||
.get()
|
||||
.archiveFile
|
||||
.get()
|
||||
.asFile
|
||||
|
||||
@OutputFile
|
||||
val outputFile: File = project.targetDirectory.resolve(inputFile.name)
|
||||
|
||||
@TaskAction
|
||||
fun generate() {
|
||||
JarFile(inputFile).use { jar ->
|
||||
JarOutputStream(outputFile.apply { parentFile?.mkdirs() }.outputStream()).use { out ->
|
||||
jar.stream()
|
||||
.parallel()
|
||||
.filter { !it.isDirectory }
|
||||
.filter { it.name.endsWith(".class") }
|
||||
.forEach {
|
||||
val bytes = jar.getInputStream(it).use { input ->
|
||||
val writer = ClassWriter(ClassWriter.COMPUTE_MAXS)
|
||||
val visitor = MethodClearingVisitor(writer)
|
||||
ClassReader(input).accept(visitor, 0)
|
||||
writer.toByteArray()
|
||||
}
|
||||
|
||||
synchronized(out) {
|
||||
out.putNextEntry(it)
|
||||
out.write(bytes)
|
||||
out.closeEntry()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val Project.targetDirectory: File get() {
|
||||
val dir = System.getenv("DEPLOY_DIR") ?: return project.layout.buildDirectory.dir("api").get().asFile
|
||||
return File(dir)
|
||||
}
|
||||
|
||||
private class MethodClearingVisitor(
|
||||
cv: ClassVisitor
|
||||
) : ClassVisitor(Opcodes.ASM9, cv) {
|
||||
|
||||
override fun visitMethod(
|
||||
access: Int,
|
||||
name: String?,
|
||||
descriptor: String?,
|
||||
signature: String?,
|
||||
exceptions: Array<out String>?
|
||||
) = ExceptionThrowingMethodVisitor(super.visitMethod(access, name, descriptor, signature, exceptions))
|
||||
}
|
||||
|
||||
private class ExceptionThrowingMethodVisitor(
|
||||
mv: MethodVisitor
|
||||
) : MethodVisitor(Opcodes.ASM9, mv) {
|
||||
|
||||
override fun visitCode() {
|
||||
if (mv == null) return
|
||||
mv.visitCode()
|
||||
|
||||
mv.visitTypeInsn(Opcodes.NEW, "java/lang/IllegalStateException")
|
||||
mv.visitInsn(Opcodes.DUP)
|
||||
mv.visitLdcInsn("Only API")
|
||||
mv.visitMethodInsn(
|
||||
Opcodes.INVOKESPECIAL,
|
||||
"java/lang/IllegalStateException",
|
||||
"<init>", "(Ljava/lang/String;)V", false
|
||||
)
|
||||
mv.visitInsn(Opcodes.ATHROW)
|
||||
|
||||
mv.visitMaxs(0, 0)
|
||||
mv.visitEnd()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import NMSBinding.Type
|
||||
import com.volmit.nmstools.NMSToolsExtension
|
||||
import com.volmit.nmstools.NMSToolsPlugin
|
||||
import io.papermc.paperweight.userdev.PaperweightUser
|
||||
import io.papermc.paperweight.userdev.PaperweightUserDependenciesExtension
|
||||
import io.papermc.paperweight.userdev.PaperweightUserExtension
|
||||
import io.papermc.paperweight.userdev.attribute.Obfuscation
|
||||
import io.papermc.paperweight.util.constants.REOBF_CONFIG
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.gradle.api.*
|
||||
import org.gradle.api.attributes.Bundling
|
||||
import org.gradle.api.attributes.Category
|
||||
import org.gradle.api.attributes.LibraryElements
|
||||
import org.gradle.api.attributes.Usage
|
||||
import org.gradle.api.model.ObjectFactory
|
||||
import org.gradle.api.plugins.JavaPluginExtension
|
||||
import org.gradle.api.tasks.TaskAction
|
||||
import org.gradle.internal.extensions.core.extra
|
||||
import org.gradle.jvm.toolchain.JavaLanguageVersion
|
||||
import org.gradle.jvm.toolchain.JavaToolchainService
|
||||
import org.gradle.work.DisableCachingByDefault
|
||||
import java.io.RandomAccessFile
|
||||
import javax.inject.Inject
|
||||
|
||||
class NMSBinding : Plugin<Project> {
|
||||
override fun apply(target: Project): Unit = with(target) {
|
||||
val config = extra["nms"] as? Config ?: throw GradleException("No NMS binding configuration found")
|
||||
val jvm = config.jvm
|
||||
val type = config.type
|
||||
|
||||
if (type == Type.USER_DEV) {
|
||||
plugins.apply(PaperweightUser::class.java)
|
||||
dependencies.extensions.findByType(PaperweightUserDependenciesExtension::class.java)
|
||||
?.paperDevBundle(config.version)
|
||||
|
||||
val java = extensions.findByType(JavaPluginExtension::class.java) ?: throw GradleException("Java plugin not found")
|
||||
java.toolchain.languageVersion.set(JavaLanguageVersion.of(jvm))
|
||||
|
||||
val javaToolchains = project.extensions.getByType(JavaToolchainService::class.java) ?: throw GradleException("Java toolchain service not found")
|
||||
extensions.configure(PaperweightUserExtension::class.java) {
|
||||
it.javaLauncher.set(javaToolchains.launcherFor(java.toolchain))
|
||||
}
|
||||
} else {
|
||||
extra["nmsTools.useBuildTools"] = type == Type.BUILD_TOOLS
|
||||
plugins.apply(NMSToolsPlugin::class.java)
|
||||
extensions.configure(NMSToolsExtension::class.java) {
|
||||
it.jvm.set(jvm)
|
||||
it.version.set(config.version)
|
||||
}
|
||||
|
||||
registerReobfConfiguration("remap")
|
||||
}
|
||||
|
||||
val (major, minor) = config.version.parseVersion()
|
||||
if (major <= 20 && minor <= 4) return@with
|
||||
tasks.register("convert", ConversionTask::class.java, type)
|
||||
tasks.named("compileJava") { it.dependsOn("convert") }
|
||||
rootProject.tasks.named("prepareKotlinBuildScriptModel") { it.dependsOn("$path:convert") }
|
||||
}
|
||||
|
||||
private fun Project.registerReobfConfiguration(artifactTask: String) {
|
||||
val conf = configurations.create(REOBF_CONFIG)
|
||||
conf.isCanBeConsumed = true
|
||||
conf.isCanBeResolved = false
|
||||
conf.attributes {
|
||||
it.attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage.JAVA_RUNTIME))
|
||||
it.attribute(Category.CATEGORY_ATTRIBUTE, objects.named(Category.LIBRARY))
|
||||
it.attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, objects.named(LibraryElements.JAR))
|
||||
it.attribute(Bundling.BUNDLING_ATTRIBUTE, objects.named(Bundling.EXTERNAL))
|
||||
it.attribute(Obfuscation.OBFUSCATION_ATTRIBUTE, objects.named(Obfuscation.OBFUSCATED))
|
||||
}
|
||||
conf.outgoing.artifact(tasks.named(artifactTask))
|
||||
}
|
||||
|
||||
@DisableCachingByDefault
|
||||
abstract class ConversionTask @Inject constructor(type: Type) : DefaultTask() {
|
||||
private val pattern: Regex
|
||||
private val replacement: String
|
||||
|
||||
init {
|
||||
group = "nms"
|
||||
inputs.property("type", type)
|
||||
val java = project.extensions.findByType(JavaPluginExtension::class.java) ?: throw GradleException("Java plugin not found")
|
||||
val source = java.sourceSets.named("main").map { it.allJava }
|
||||
inputs.files(source)
|
||||
outputs.files(source)
|
||||
|
||||
if (type == Type.USER_DEV) {
|
||||
pattern = "org\\.bukkit\\.craftbukkit\\.${project.name}".toRegex()
|
||||
replacement = "org.bukkit.craftbukkit"
|
||||
} else {
|
||||
pattern = "org\\.bukkit\\.craftbukkit\\.(?!${project.name})".toRegex()
|
||||
replacement = "org.bukkit.craftbukkit.${project.name}."
|
||||
}
|
||||
}
|
||||
|
||||
@TaskAction
|
||||
fun process() {
|
||||
val dispatcher = Dispatchers.IO.limitedParallelism(16)
|
||||
runBlocking {
|
||||
for (file in inputs.files) {
|
||||
if (file.extension !in listOf("java"))
|
||||
continue
|
||||
|
||||
launch(dispatcher) {
|
||||
val output = ArrayList<String>()
|
||||
var changed = false
|
||||
|
||||
file.bufferedReader().use {
|
||||
for (line in it.lines()) {
|
||||
if (line.startsWith("package") || line.isBlank()) {
|
||||
output += line
|
||||
continue
|
||||
}
|
||||
|
||||
if (!line.startsWith("import")) {
|
||||
if (!changed) return@launch
|
||||
else {
|
||||
output += line
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (!line.contains(pattern)) {
|
||||
output += line
|
||||
continue
|
||||
}
|
||||
|
||||
output += line.replace(pattern, replacement)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
RandomAccessFile(file, "r").use { raf ->
|
||||
val bytes = ByteArray(NEW_LINE_BYTES.size)
|
||||
raf.seek(raf.length() - bytes.size)
|
||||
raf.readFully(bytes)
|
||||
if (bytes.contentEquals(NEW_LINE_BYTES))
|
||||
output += ""
|
||||
}
|
||||
|
||||
file.writer().use {
|
||||
val iterator = output.iterator()
|
||||
while (iterator.hasNext()) {
|
||||
it.append(iterator.next())
|
||||
if (iterator.hasNext())
|
||||
it.append(NEW_LINE)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class Type {
|
||||
USER_DEV,
|
||||
BUILD_TOOLS,
|
||||
DIRECT,
|
||||
}
|
||||
}
|
||||
|
||||
private val NEW_LINE = System.lineSeparator()
|
||||
private val NEW_LINE_BYTES = NEW_LINE.encodeToByteArray()
|
||||
// "1.21.11-R0.1-SNAPSHOT" -> (21, 11), "26.1.2.build.74-stable" -> (26, 1)
|
||||
private fun String.parseVersion() = substringBefore('-').split(".").let {
|
||||
if (it[0] == "1") it[1].toInt() to it[2].toInt()
|
||||
else it[0].toInt() to it[1].toInt()
|
||||
}
|
||||
|
||||
class Config(
|
||||
var jvm: Int = 21,
|
||||
var type: Type = Type.DIRECT
|
||||
) {
|
||||
lateinit var version: String
|
||||
}
|
||||
|
||||
fun Project.nmsBinding(action: Config.() -> Unit) {
|
||||
extra["nms"] = Config().apply(action)
|
||||
plugins.apply(NMSBinding::class.java)
|
||||
}
|
||||
|
||||
private inline fun <reified T : Named> ObjectFactory.named(name: String): T = named(T::class.java, name)
|
||||
@@ -0,0 +1,12 @@
|
||||
plugins {
|
||||
java
|
||||
}
|
||||
|
||||
tasks.jar {
|
||||
manifest.attributes(
|
||||
"Agent-Class" to "com.volmit.iris.util.agent.Installer",
|
||||
"Premain-Class" to "com.volmit.iris.util.agent.Installer",
|
||||
"Can-Redefine-Classes" to true,
|
||||
"Can-Retransform-Classes" to true
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.volmit.iris.util.agent;
|
||||
|
||||
import java.lang.instrument.Instrumentation;
|
||||
|
||||
public class Installer {
|
||||
private static volatile Instrumentation instrumentation;
|
||||
|
||||
public static Instrumentation getInstrumentation() {
|
||||
Instrumentation instrumentation = Installer.instrumentation;
|
||||
if (instrumentation == null) {
|
||||
throw new IllegalStateException("The agent is not loaded or this method is not called via the system class loader");
|
||||
}
|
||||
return instrumentation;
|
||||
}
|
||||
|
||||
public static void premain(String arguments, Instrumentation instrumentation) {
|
||||
doMain(instrumentation);
|
||||
}
|
||||
|
||||
public static void agentmain(String arguments, Instrumentation instrumentation) {
|
||||
doMain(instrumentation);
|
||||
}
|
||||
|
||||
private static synchronized void doMain(Instrumentation instrumentation) {
|
||||
if (Installer.instrumentation != null)
|
||||
return;
|
||||
Installer.instrumentation = instrumentation;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import io.github.slimjar.func.slimjarHelper
|
||||
import io.github.slimjar.resolver.data.Mirror
|
||||
import org.ajoberstar.grgit.Grgit
|
||||
import org.gradle.jvm.toolchain.JavaLanguageVersion
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
import java.net.URI
|
||||
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2021 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
plugins {
|
||||
java
|
||||
`java-library`
|
||||
alias(libs.plugins.shadow)
|
||||
alias(libs.plugins.sentry)
|
||||
alias(libs.plugins.slimjar)
|
||||
alias(libs.plugins.grgit)
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
alias(libs.plugins.kotlin.lombok)
|
||||
}
|
||||
|
||||
val apiVersion = "1.19"
|
||||
val main = "com.volmit.iris.Iris"
|
||||
val lib = "com.volmit.iris.util"
|
||||
|
||||
/**
|
||||
* Dependencies.
|
||||
*
|
||||
* Provided or classpath dependencies are not shaded and are available on the runtime classpath
|
||||
*
|
||||
* Shaded dependencies are not available at runtime, nor are they available on mvn central so they
|
||||
* need to be shaded into the jar (increasing binary size)
|
||||
*
|
||||
* Dynamically loaded dependencies are defined in the plugin.yml (updating these must be updated in the
|
||||
* plugin.yml also, otherwise they wont be available). These do not increase binary size). Only declare
|
||||
* these dependencies if they are available on mvn central.
|
||||
*/
|
||||
dependencies {
|
||||
// Provided or Classpath
|
||||
compileOnly(libs.paper)
|
||||
compileOnly(platform(libs.adventure.bom))
|
||||
compileOnly(libs.log4j.api)
|
||||
compileOnly(libs.log4j.core)
|
||||
|
||||
// Third Party Integrations
|
||||
compileOnly(libs.nexo)
|
||||
compileOnly(libs.itemsadder)
|
||||
compileOnly(libs.placeholderApi)
|
||||
compileOnly(libs.score)
|
||||
compileOnly(libs.mmoitems)
|
||||
compileOnly(libs.ecoitems)
|
||||
compileOnly(libs.mythic)
|
||||
compileOnly(libs.mythicChrucible)
|
||||
compileOnly(libs.kgenerators) {
|
||||
isTransitive = false
|
||||
}
|
||||
compileOnly(libs.multiverseCore)
|
||||
compileOnly(libs.craftengine.core)
|
||||
compileOnly(libs.craftengine.bukkit)
|
||||
//compileOnly(libs.sparrowNbt)
|
||||
|
||||
// Shaded
|
||||
implementation(slimjarHelper("spigot"))
|
||||
|
||||
// Dynamically Loaded
|
||||
slim(libs.paralithic)
|
||||
slim(libs.bstats)
|
||||
slim(libs.sentry)
|
||||
|
||||
slim(libs.commons.io)
|
||||
slim(libs.commons.lang)
|
||||
slim(libs.commons.lang3)
|
||||
slim(libs.commons.math3)
|
||||
slim(libs.oshi)
|
||||
slim(libs.lz4)
|
||||
slim(libs.fastutil)
|
||||
slim(libs.lru)
|
||||
slim(libs.zip)
|
||||
slim(libs.gson)
|
||||
slim(libs.asm)
|
||||
slim(libs.caffeine)
|
||||
slim(libs.byteBuddy.core)
|
||||
slim(libs.byteBuddy.agent)
|
||||
slim(libs.dom4j)
|
||||
slim(libs.jaxen)
|
||||
slim(libs.flatlaf)
|
||||
slim(libs.flatlaf.extras)
|
||||
|
||||
// Script Engine
|
||||
slim(libs.kotlin.stdlib)
|
||||
slim(libs.kotlin.coroutines)
|
||||
slim(libs.kotlin.scripting.common)
|
||||
slim(libs.kotlin.scripting.jvm)
|
||||
slim(libs.kotlin.scripting.jvm.host)
|
||||
slim(libs.kotlin.scripting.dependencies.maven) {
|
||||
constraints {
|
||||
slim(libs.mavenCore)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
java {
|
||||
disableAutoTargetJvm()
|
||||
toolchain {
|
||||
languageVersion.set(JavaLanguageVersion.of(21))
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_21)
|
||||
}
|
||||
}
|
||||
|
||||
sentry {
|
||||
url = "http://sentry.volmit.com:8080"
|
||||
autoInstallation.enabled = false
|
||||
includeSourceContext = true
|
||||
|
||||
org = "sentry"
|
||||
projectName = "iris"
|
||||
authToken = findProperty("sentry.auth.token") as String? ?: System.getenv("SENTRY_AUTH_TOKEN")
|
||||
}
|
||||
|
||||
slimJar {
|
||||
mirrors = listOf(Mirror(
|
||||
URI.create("https://maven-central.storage-download.googleapis.com/maven2").toURL(),
|
||||
URI.create("https://repo.maven.apache.org/maven2/").toURL()
|
||||
))
|
||||
|
||||
relocate("com.dfsek.paralithic", "$lib.paralithic")
|
||||
relocate("org.bstats", "$lib.metrics")
|
||||
relocate("io.sentry", "$lib.sentry")
|
||||
relocate("org.apache.maven", "$lib.maven")
|
||||
relocate("org.codehaus.plexus", "$lib.plexus")
|
||||
relocate("org.eclipse.sisu", "$lib.sisu")
|
||||
relocate("org.eclipse.aether", "$lib.aether")
|
||||
relocate("com.google.inject", "$lib.guice")
|
||||
relocate("org.dom4j", "$lib.dom4j")
|
||||
relocate("org.jaxen", "$lib.jaxen")
|
||||
relocate("com.github.benmanes.caffeine", "$lib.caffeine")
|
||||
}
|
||||
|
||||
tasks {
|
||||
/**
|
||||
* We need parameter meta for the decree command system
|
||||
*/
|
||||
compileJava {
|
||||
options.compilerArgs.add("-parameters")
|
||||
options.encoding = "UTF-8"
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand properties into plugin yml
|
||||
*/
|
||||
processResources {
|
||||
inputs.properties(
|
||||
"name" to rootProject.name,
|
||||
"version" to rootProject.version,
|
||||
"apiVersion" to apiVersion,
|
||||
"main" to main,
|
||||
)
|
||||
filesMatching("**/plugin.yml") {
|
||||
expand(inputs.properties)
|
||||
}
|
||||
}
|
||||
|
||||
shadowJar {
|
||||
mergeServiceFiles()
|
||||
//minimize()
|
||||
relocate("io.github.slimjar", "$lib.slimjar")
|
||||
exclude("modules/loader-agent.isolated-jar")
|
||||
}
|
||||
|
||||
sentryCollectSourcesJava {
|
||||
dependsOn(generateTemplates)
|
||||
}
|
||||
}
|
||||
|
||||
val templateSource = file("src/main/templates")
|
||||
val templateDest = layout.buildDirectory.dir("generated/sources/templates")!!
|
||||
val generateTemplates = tasks.register<Copy>("generateTemplates") {
|
||||
inputs.properties(
|
||||
"environment" to when {
|
||||
project.hasProperty("release") -> "production"
|
||||
project.hasProperty("argghh") -> "Argghh!"
|
||||
else -> "development"
|
||||
},
|
||||
"commit" to provider {
|
||||
val res = runCatching { project.extensions.getByType<Grgit>().head().id }
|
||||
res.getOrDefault("")
|
||||
.takeIf { it.length == 40 } ?: run {
|
||||
this.logger.error("Git commit hash not found", res.exceptionOrNull())
|
||||
"unknown"
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
from(templateSource)
|
||||
into(templateDest)
|
||||
rename { "com/volmit/iris/$it" }
|
||||
expand(inputs.properties)
|
||||
}
|
||||
|
||||
tasks.generateSentryBundleIdJava {
|
||||
dependsOn(generateTemplates)
|
||||
}
|
||||
|
||||
rootProject.tasks.named("prepareKotlinBuildScriptModel") {
|
||||
dependsOn(generateTemplates)
|
||||
}
|
||||
|
||||
sourceSets.main {
|
||||
java.srcDir(generateTemplates.map { it.outputs })
|
||||
}
|
||||
+776
-771
File diff suppressed because it is too large.
Load diff
+118
-5
@@ -23,8 +23,11 @@ import com.volmit.iris.Iris;
|
||||
import com.volmit.iris.util.io.IO;
|
||||
import com.volmit.iris.util.json.JSONException;
|
||||
import com.volmit.iris.util.json.JSONObject;
|
||||
import com.volmit.iris.util.misc.getHardware;
|
||||
import com.volmit.iris.util.plugin.VolmitSender;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
@@ -41,13 +44,15 @@ public class IrisSettings {
|
||||
private IrisSettingsConcurrency concurrency = new IrisSettingsConcurrency();
|
||||
private IrisSettingsStudio studio = new IrisSettingsStudio();
|
||||
private IrisSettingsPerformance performance = new IrisSettingsPerformance();
|
||||
private IrisSettingsUpdater updater = new IrisSettingsUpdater();
|
||||
private IrisSettingsPregen pregen = new IrisSettingsPregen();
|
||||
private IrisSettingsSentry sentry = new IrisSettingsSentry();
|
||||
|
||||
public static int getThreadCount(int c) {
|
||||
return switch (c) {
|
||||
return Math.max(switch (c) {
|
||||
case -1, -2, -4 -> Runtime.getRuntime().availableProcessors() / -c;
|
||||
case 0, 1, 2 -> 1;
|
||||
default -> Math.max(c, 2);
|
||||
};
|
||||
}, 1);
|
||||
}
|
||||
|
||||
public static IrisSettings get() {
|
||||
@@ -126,32 +131,116 @@ public class IrisSettings {
|
||||
public boolean markerEntitySpawningSystem = true;
|
||||
public boolean effectSystem = true;
|
||||
public boolean worldEditWandCUI = true;
|
||||
public boolean globalPregenCache = false;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class IrisSettingsConcurrency {
|
||||
public int parallelism = -1;
|
||||
public int ioParallelism = -2;
|
||||
public int worldGenParallelism = -1;
|
||||
|
||||
public int getWorldGenThreads() {
|
||||
return getThreadCount(worldGenParallelism);
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class IrisSettingsPregen {
|
||||
private static final int MIN_RESIDENT_TECTONIC_PLATES = 16;
|
||||
private static final double MANTLE_HEAP_FRACTION = 0.6D;
|
||||
private static final int MEASURED_PLATE_MEGABYTES = 38;
|
||||
private static final int MEASURED_WORLD_HEIGHT = 1088;
|
||||
|
||||
public boolean useCacheByDefault = true;
|
||||
public boolean useHighPriority = false;
|
||||
public boolean useVirtualThreads = false;
|
||||
public boolean useTicketQueue = true;
|
||||
public int maxConcurrency = 256;
|
||||
public int maxResidentTectonicPlates = 96;
|
||||
public int mantleBackpressureWaitMs = 25;
|
||||
public int mantleBackpressureTimeoutMs = 60_000;
|
||||
|
||||
public int getEffectiveResidentTectonicPlates(int worldHeight) {
|
||||
// Mantle chunks only hold the sections something was written to, so shorter worlds keep the measured size.
|
||||
double heightScale = Math.max(1D, (double) worldHeight / MEASURED_WORLD_HEIGHT);
|
||||
double plateMegabytes = MEASURED_PLATE_MEGABYTES * heightScale;
|
||||
int heapBudget = (int) Math.floor(MANTLE_HEAP_FRACTION * getHardware.getProcessMemory() / plateMegabytes);
|
||||
return Math.max(MIN_RESIDENT_TECTONIC_PLATES, Math.min(maxResidentTectonicPlates, heapBudget));
|
||||
}
|
||||
|
||||
public int getMantleBackpressureWaitMs() {
|
||||
return Math.max(5, Math.min(mantleBackpressureWaitMs, 1_000));
|
||||
}
|
||||
|
||||
public int getMantleBackpressureTimeoutMs() {
|
||||
return Math.max(5_000, Math.min(mantleBackpressureTimeoutMs, 600_000));
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class IrisSettingsPerformance {
|
||||
public boolean trimMantleInStudio = false;
|
||||
private IrisSettingsEngineSVC engineSVC = new IrisSettingsEngineSVC();
|
||||
public boolean trimMantleInStudio = false;
|
||||
public int mantleKeepAlive = 30;
|
||||
public int cacheSize = 4_096;
|
||||
public int noiseCacheSize = 1_024;
|
||||
public int resourceLoaderCacheSize = 1_024;
|
||||
public int objectLoaderCacheSize = 4_096;
|
||||
public int scriptLoaderCacheSize = 512;
|
||||
public int tectonicPlateSize = -1;
|
||||
public int mantleCleanupDelay = 200;
|
||||
|
||||
public int getTectonicPlateSize() {
|
||||
if (tectonicPlateSize > 0)
|
||||
return tectonicPlateSize;
|
||||
|
||||
return (int) (getHardware.getProcessMemory() / 512L);
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class IrisSettingsUpdater {
|
||||
public int maxConcurrency = 256;
|
||||
public boolean nativeThreads = false;
|
||||
public double threadMultiplier = 2;
|
||||
|
||||
public double chunkLoadSensitivity = 0.7;
|
||||
public MsRange emptyMsRange = new MsRange(80, 100);
|
||||
public MsRange defaultMsRange = new MsRange(20, 40);
|
||||
|
||||
public int getMaxConcurrency() {
|
||||
return Math.max(Math.abs(maxConcurrency), 1);
|
||||
}
|
||||
|
||||
public double getThreadMultiplier() {
|
||||
return Math.min(Math.abs(threadMultiplier), 0.1);
|
||||
}
|
||||
|
||||
public double getChunkLoadSensitivity() {
|
||||
return Math.min(chunkLoadSensitivity, 0.9);
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class MsRange {
|
||||
public int min = 20;
|
||||
public int max = 40;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class IrisSettingsGeneral {
|
||||
public boolean DoomsdayAnnihilationSelfDestructMode = false;
|
||||
public boolean commandSounds = true;
|
||||
public boolean debug = false;
|
||||
public boolean dumpMantleOnError = false;
|
||||
public boolean disableNMS = false;
|
||||
public boolean pluginMetrics = true;
|
||||
public boolean splashLogoStartup = true;
|
||||
public boolean useConsoleCustomColors = true;
|
||||
public boolean useCustomColorsIngame = true;
|
||||
public boolean adjustVanillaHeight = false;
|
||||
public String forceMainWorld = "";
|
||||
public int spinh = -20;
|
||||
public int spins = 7;
|
||||
@@ -165,10 +254,19 @@ public class IrisSettings {
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class IrisSettingsSentry {
|
||||
public boolean includeServerId = true;
|
||||
public boolean disableAutoReporting = false;
|
||||
public boolean debug = false;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class IrisSettingsGUI {
|
||||
public boolean useServerLaunchedGuis = true;
|
||||
public boolean maximumPregenGuiFPS = false;
|
||||
public boolean colorMode = true;
|
||||
public String theme = "auto";
|
||||
}
|
||||
|
||||
@Data
|
||||
@@ -176,6 +274,10 @@ public class IrisSettings {
|
||||
public String defaultWorldType = "overworld";
|
||||
public int maxBiomeChildDepth = 4;
|
||||
public boolean preventLeafDecay = true;
|
||||
public boolean useMulticore = false;
|
||||
public boolean useMulticoreMantle = false;
|
||||
public boolean offsetNoiseTypes = false;
|
||||
public boolean earlyCustomBlocks = false;
|
||||
}
|
||||
|
||||
@Data
|
||||
@@ -185,4 +287,15 @@ public class IrisSettings {
|
||||
public boolean disableTimeAndWeather = true;
|
||||
public boolean autoStartDefaultStudio = false;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class IrisSettingsEngineSVC {
|
||||
public boolean useVirtualThreads = true;
|
||||
public boolean forceMulticoreWrite = false;
|
||||
public int priority = Thread.NORM_PRIORITY;
|
||||
|
||||
public int getPriority() {
|
||||
return Math.max(Math.min(priority, Thread.MAX_PRIORITY), Thread.MIN_PRIORITY);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package com.volmit.iris.core;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import com.volmit.iris.Iris;
|
||||
import com.volmit.iris.core.loader.IrisData;
|
||||
import com.volmit.iris.core.world.WorldStorage;
|
||||
import com.volmit.iris.engine.data.cache.AtomicCache;
|
||||
import com.volmit.iris.engine.object.IrisDimension;
|
||||
import com.volmit.iris.util.collection.KMap;
|
||||
import com.volmit.iris.util.io.IO;
|
||||
import com.volmit.iris.util.misc.ServerProperties;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStreamWriter;
|
||||
import java.lang.reflect.Type;
|
||||
import java.util.HashSet;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public class IrisWorlds {
|
||||
private static final AtomicCache<IrisWorlds> cache = new AtomicCache<>();
|
||||
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
|
||||
private static final Type TYPE = TypeToken.getParameterized(KMap.class, String.class, String.class).getType();
|
||||
private final KMap<String, String> worlds;
|
||||
private volatile boolean dirty = false;
|
||||
|
||||
private IrisWorlds(KMap<String, String> worlds) {
|
||||
this.worlds = worlds;
|
||||
readBukkitWorlds().forEach(this::put0);
|
||||
save();
|
||||
}
|
||||
|
||||
public static IrisWorlds get() {
|
||||
return cache.aquire(() -> new IrisWorlds(readWorldsFile()));
|
||||
}
|
||||
|
||||
public static Set<String> readKnownWorldNames() {
|
||||
Set<String> names = new HashSet<>(readWorldsFile().keySet());
|
||||
names.addAll(readBukkitWorlds().keySet());
|
||||
return names;
|
||||
}
|
||||
|
||||
private static KMap<String, String> readWorldsFile() {
|
||||
File file = Iris.instance.getDataFile("worlds.json");
|
||||
if (!file.exists()) {
|
||||
return new KMap<>();
|
||||
}
|
||||
|
||||
try {
|
||||
String json = IO.readAll(file);
|
||||
KMap<String, String> worlds = GSON.fromJson(json, TYPE);
|
||||
return Objects.requireNonNullElseGet(worlds, KMap::new);
|
||||
} catch (Throwable e) {
|
||||
Iris.error("Failed to load worlds.json!");
|
||||
e.printStackTrace();
|
||||
Iris.reportError(e);
|
||||
}
|
||||
|
||||
return new KMap<>();
|
||||
}
|
||||
|
||||
public void put(String name, String type) {
|
||||
put0(name, type);
|
||||
save();
|
||||
}
|
||||
|
||||
private void put0(String name, String type) {
|
||||
String old = worlds.put(name, type);
|
||||
if (!type.equals(old))
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
public KMap<String, String> getWorlds() {
|
||||
clean();
|
||||
return readBukkitWorlds().put(worlds);
|
||||
}
|
||||
|
||||
public Stream<IrisData> getPacks() {
|
||||
return getDimensions()
|
||||
.map(IrisDimension::getLoader)
|
||||
.filter(Objects::nonNull);
|
||||
}
|
||||
|
||||
public Stream<IrisDimension> getDimensions() {
|
||||
return getWorlds()
|
||||
.entrySet()
|
||||
.stream()
|
||||
.map(entry -> Iris.loadDimension(entry.getKey(), entry.getValue()))
|
||||
.filter(Objects::nonNull);
|
||||
}
|
||||
|
||||
public void clean() {
|
||||
dirty = worlds.entrySet().removeIf(entry -> !new File(WorldStorage.get().worldFolder(entry.getKey()), "iris/pack/dimensions/" + entry.getValue() + ".json").exists());
|
||||
}
|
||||
|
||||
public synchronized void save() {
|
||||
clean();
|
||||
if (!dirty) return;
|
||||
try {
|
||||
IO.write(Iris.instance.getDataFile("worlds.json"), OutputStreamWriter::new, writer -> GSON.toJson(worlds, TYPE, writer));
|
||||
dirty = false;
|
||||
} catch (IOException e) {
|
||||
Iris.error("Failed to save worlds.json!");
|
||||
e.printStackTrace();
|
||||
Iris.reportError(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static KMap<String, String> readBukkitWorlds() {
|
||||
var bukkit = YamlConfiguration.loadConfiguration(ServerProperties.BUKKIT_YML);
|
||||
var worlds = bukkit.getConfigurationSection("worlds");
|
||||
if (worlds == null) return new KMap<>();
|
||||
|
||||
var result = new KMap<String, String>();
|
||||
for (String world : worlds.getKeys(false)) {
|
||||
var gen = worlds.getString(world + ".generator");
|
||||
if (gen == null) continue;
|
||||
|
||||
String loadKey;
|
||||
if (gen.equalsIgnoreCase("iris")) {
|
||||
loadKey = IrisSettings.get().getGenerator().getDefaultWorldType();
|
||||
} else if (gen.startsWith("Iris:")) {
|
||||
loadKey = gen.substring(5);
|
||||
} else continue;
|
||||
|
||||
result.put(world, loadKey);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.volmit.iris.core;
|
||||
|
||||
import com.volmit.iris.Iris;
|
||||
import com.volmit.iris.core.loader.IrisData;
|
||||
import com.volmit.iris.core.nms.INMS;
|
||||
import com.volmit.iris.core.nms.datapack.DataVersion;
|
||||
import com.volmit.iris.core.nms.datapack.IDataFixer;
|
||||
import com.volmit.iris.core.world.WorldStorage;
|
||||
import com.volmit.iris.engine.object.*;
|
||||
import com.volmit.iris.util.collection.KList;
|
||||
import com.volmit.iris.util.collection.KMap;
|
||||
import com.volmit.iris.util.collection.KSet;
|
||||
import com.volmit.iris.util.format.C;
|
||||
import com.volmit.iris.util.misc.ServerProperties;
|
||||
import com.volmit.iris.util.plugin.VolmitSender;
|
||||
import com.volmit.iris.util.scheduling.J;
|
||||
import lombok.NonNull;
|
||||
import lombok.SneakyThrows;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.configuration.InvalidConfigurationException;
|
||||
import org.bukkit.configuration.file.FileConfiguration;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicIntegerArray;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public class ServerConfigurator {
|
||||
public static void configure() {
|
||||
IrisSettings.IrisSettingsAutoconfiguration s = IrisSettings.get().getAutoConfiguration();
|
||||
if (s.isConfigureSpigotTimeoutTime()) {
|
||||
J.attempt(ServerConfigurator::increaseKeepAliveSpigot);
|
||||
}
|
||||
|
||||
if (s.isConfigurePaperWatchdogDelay()) {
|
||||
J.attempt(ServerConfigurator::increasePaperWatchdog);
|
||||
}
|
||||
|
||||
installDataPacks(true);
|
||||
}
|
||||
|
||||
private static void increaseKeepAliveSpigot() throws IOException, InvalidConfigurationException {
|
||||
File spigotConfig = new File("spigot.yml");
|
||||
FileConfiguration f = new YamlConfiguration();
|
||||
f.load(spigotConfig);
|
||||
long tt = f.getLong("settings.timeout-time");
|
||||
|
||||
long spigotTimeout = TimeUnit.MINUTES.toSeconds(5);
|
||||
|
||||
if (tt < spigotTimeout) {
|
||||
Iris.warn("Updating spigot.yml timeout-time: " + tt + " -> " + spigotTimeout + " (5 minutes)");
|
||||
Iris.warn("You can disable this change (autoconfigureServer) in Iris settings, then change back the value.");
|
||||
f.set("settings.timeout-time", spigotTimeout);
|
||||
f.save(spigotConfig);
|
||||
}
|
||||
}
|
||||
private static void increasePaperWatchdog() throws IOException, InvalidConfigurationException {
|
||||
File spigotConfig = new File("config/paper-global.yml");
|
||||
FileConfiguration f = new YamlConfiguration();
|
||||
f.load(spigotConfig);
|
||||
long tt = f.getLong("watchdog.early-warning-delay");
|
||||
|
||||
long watchdog = TimeUnit.MINUTES.toMillis(3);
|
||||
if (tt < watchdog) {
|
||||
Iris.warn("Updating paper.yml watchdog early-warning-delay: " + tt + " -> " + watchdog + " (3 minutes)");
|
||||
Iris.warn("You can disable this change (autoconfigureServer) in Iris settings, then change back the value.");
|
||||
f.set("watchdog.early-warning-delay", watchdog);
|
||||
f.save(spigotConfig);
|
||||
}
|
||||
}
|
||||
|
||||
private static KList<File> getDatapacksFolder() {
|
||||
if (!IrisSettings.get().getGeneral().forceMainWorld.isEmpty()) {
|
||||
return new KList<File>().qadd(new File(Bukkit.getWorldContainer(), IrisSettings.get().getGeneral().forceMainWorld + "/datapacks"));
|
||||
}
|
||||
return WorldStorage.get().datapackFolders();
|
||||
}
|
||||
|
||||
public static boolean installDataPacks(boolean fullInstall) {
|
||||
return installDataPacks(DataVersion.getDefault(), fullInstall);
|
||||
}
|
||||
|
||||
public static boolean installDataPacks(IDataFixer fixer, boolean fullInstall) {
|
||||
if (fixer == null) {
|
||||
Iris.error("Unable to install datapacks, fixer is null!");
|
||||
return false;
|
||||
}
|
||||
Iris.info("Checking Data Packs...");
|
||||
DimensionHeight height = new DimensionHeight(fixer);
|
||||
KList<File> folders = getDatapacksFolder();
|
||||
KMap<String, KSet<String>> biomes = new KMap<>();
|
||||
|
||||
try (Stream<IrisData> stream = allPacks()) {
|
||||
stream.flatMap(height::merge)
|
||||
.parallel()
|
||||
.forEach(dim -> {
|
||||
Iris.verbose(" Checking Dimension " + dim.getLoadFile().getPath());
|
||||
dim.installBiomes(fixer, dim::getLoader, folders, biomes.computeIfAbsent(dim.getLoadKey(), k -> new KSet<>()));
|
||||
dim.installDimensionType(fixer, folders);
|
||||
});
|
||||
}
|
||||
IrisDimension.writeShared(folders, height);
|
||||
Iris.info("Data Packs Setup!");
|
||||
|
||||
return fullInstall && verifyDataPacksPost(IrisSettings.get().getAutoConfiguration().isAutoRestartOnCustomBiomeInstall());
|
||||
}
|
||||
|
||||
private static boolean verifyDataPacksPost(boolean allowRestarting) {
|
||||
try (Stream<IrisData> stream = allPacks()) {
|
||||
boolean bad = stream
|
||||
.map(data -> {
|
||||
Iris.verbose("Checking Pack: " + data.getDataFolder().getPath());
|
||||
var loader = data.getDimensionLoader();
|
||||
return loader.loadAll(loader.getPossibleKeys())
|
||||
.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map(ServerConfigurator::verifyDataPackInstalled)
|
||||
.toList()
|
||||
.contains(false);
|
||||
})
|
||||
.toList()
|
||||
.contains(true);
|
||||
if (!bad) return false;
|
||||
}
|
||||
|
||||
|
||||
if (allowRestarting) {
|
||||
restart();
|
||||
} else if (INMS.get().supportsDataPacks()) {
|
||||
Iris.error("============================================================================");
|
||||
Iris.error(C.ITALIC + "You need to restart your server to properly generate custom biomes.");
|
||||
Iris.error(C.ITALIC + "By continuing, Iris will use backup biomes in place of the custom biomes.");
|
||||
Iris.error("----------------------------------------------------------------------------");
|
||||
Iris.error(C.UNDERLINE + "IT IS HIGHLY RECOMMENDED YOU RESTART THE SERVER BEFORE GENERATING!");
|
||||
Iris.error("============================================================================");
|
||||
|
||||
for (Player i : Bukkit.getOnlinePlayers()) {
|
||||
if (i.isOp() || i.hasPermission("iris.all")) {
|
||||
VolmitSender sender = new VolmitSender(i, Iris.instance.getTag("WARNING"));
|
||||
sender.sendMessage("There are some Iris Packs that have custom biomes in them");
|
||||
sender.sendMessage("You need to restart your server to use these packs.");
|
||||
}
|
||||
}
|
||||
|
||||
J.sleep(3000);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void restart() {
|
||||
J.s(() -> {
|
||||
Iris.warn("New data pack entries have been installed in Iris! Restarting server!");
|
||||
Iris.warn("This will only happen when your pack changes (updates/first time setup)");
|
||||
Iris.warn("(You can disable this auto restart in iris settings)");
|
||||
J.s(() -> {
|
||||
Iris.warn("Looks like the restart command didn't work. Stopping the server instead!");
|
||||
Bukkit.shutdown();
|
||||
}, 100);
|
||||
Bukkit.dispatchCommand(Bukkit.getConsoleSender(), "restart");
|
||||
});
|
||||
}
|
||||
|
||||
public static boolean verifyDataPackInstalled(IrisDimension dimension) {
|
||||
KSet<String> keys = new KSet<>();
|
||||
boolean warn = false;
|
||||
|
||||
for (IrisBiome i : dimension.getAllBiomes(dimension::getLoader)) {
|
||||
if (i.isCustom()) {
|
||||
for (IrisBiomeCustom j : i.getCustomDerivitives()) {
|
||||
keys.add(dimension.getLoadKey() + ":" + j.getId());
|
||||
}
|
||||
}
|
||||
}
|
||||
String key = worldFolderNameOf(dimension.getLoader());
|
||||
if (key == null) key = dimension.getLoadKey();
|
||||
else key += "/" + dimension.getLoadKey();
|
||||
|
||||
if (!INMS.get().supportsDataPacks()) {
|
||||
if (!keys.isEmpty()) {
|
||||
Iris.warn("===================================================================================");
|
||||
Iris.warn("Pack " + key + " has " + keys.size() + " custom biome(s). ");
|
||||
Iris.warn("Your server version does not yet support datapacks for iris.");
|
||||
Iris.warn("The world will generate these biomes as backup biomes.");
|
||||
Iris.warn("====================================================================================");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
for (String i : keys) {
|
||||
Object o = INMS.get().getCustomBiomeBaseFor(i);
|
||||
|
||||
if (o == null) {
|
||||
Iris.warn("The Biome " + i + " is not registered on the server.");
|
||||
warn = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (INMS.get().missingDimensionTypes(dimension.getDimensionTypeKey())) {
|
||||
Iris.warn("The Dimension Type for " + dimension.getLoadFile() + " is not registered on the server.");
|
||||
warn = true;
|
||||
}
|
||||
|
||||
if (warn) {
|
||||
Iris.error("The Pack " + key + " is INCAPABLE of generating custom biomes");
|
||||
Iris.error("If not done automatically, restart your server before generating with this pack!");
|
||||
}
|
||||
|
||||
return !warn;
|
||||
}
|
||||
|
||||
public static Stream<IrisData> allPacks() {
|
||||
return Stream.concat(listFiles(Iris.instance.getDataFolder("packs"))
|
||||
.filter(File::isDirectory)
|
||||
.filter( base -> {
|
||||
var content = new File(base, "dimensions").listFiles();
|
||||
return content != null && content.length > 0;
|
||||
})
|
||||
.map(IrisData::get), IrisWorlds.get().getPacks());
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static String worldFolderNameOf(IrisData data) {
|
||||
File pack = data.getDataFolder();
|
||||
File iris = pack.getParentFile();
|
||||
if (iris == null || iris.getParentFile() == null || !pack.getName().equals("pack") || !iris.getName().equals("iris")) {
|
||||
return null;
|
||||
}
|
||||
return iris.getParentFile().getName();
|
||||
}
|
||||
|
||||
@SneakyThrows
|
||||
private static Stream<File> listFiles(File parent) {
|
||||
if (!parent.isDirectory()) return Stream.empty();
|
||||
return Files.walk(parent.toPath()).map(Path::toFile);
|
||||
}
|
||||
|
||||
public static class DimensionHeight {
|
||||
private final IDataFixer fixer;
|
||||
private final AtomicIntegerArray[] dimensions = new AtomicIntegerArray[3];
|
||||
|
||||
public DimensionHeight(IDataFixer fixer) {
|
||||
this.fixer = fixer;
|
||||
for (int i = 0; i < 3; i++) {
|
||||
dimensions[i] = new AtomicIntegerArray(new int[]{
|
||||
Integer.MAX_VALUE, Integer.MIN_VALUE, Integer.MIN_VALUE
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public Stream<IrisDimension> merge(IrisData data) {
|
||||
Iris.verbose("Checking Pack: " + data.getDataFolder().getPath());
|
||||
var loader = data.getDimensionLoader();
|
||||
return loader.loadAll(loader.getPossibleKeys())
|
||||
.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.peek(this::merge);
|
||||
}
|
||||
|
||||
public void merge(IrisDimension dimension) {
|
||||
AtomicIntegerArray array = dimensions[dimension.getBaseDimension().ordinal()];
|
||||
array.updateAndGet(0, min -> Math.min(min, dimension.getMinHeight()));
|
||||
array.updateAndGet(1, max -> Math.max(max, dimension.getMaxHeight()));
|
||||
array.updateAndGet(2, logical -> Math.max(logical, dimension.getLogicalHeight()));
|
||||
}
|
||||
|
||||
public String[] jsonStrings() {
|
||||
var dims = IDataFixer.Dimension.values();
|
||||
var arr = new String[3];
|
||||
for (int i = 0; i < 3; i++) {
|
||||
arr[i] = jsonString(dims[i]);
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
public String jsonString(IDataFixer.Dimension dimension) {
|
||||
var data = dimensions[dimension.ordinal()];
|
||||
int minY = data.get(0);
|
||||
int maxY = data.get(1);
|
||||
int logicalHeight = data.get(2);
|
||||
if (minY == Integer.MAX_VALUE || maxY == Integer.MIN_VALUE || Integer.MIN_VALUE == logicalHeight)
|
||||
return null;
|
||||
return fixer.createDimension(dimension, minY, maxY - minY, logicalHeight, null).toString(4);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,580 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.volmit.iris.core.commands;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.volmit.iris.Iris;
|
||||
import com.volmit.iris.core.ServerConfigurator;
|
||||
import com.volmit.iris.core.loader.IrisData;
|
||||
import com.volmit.iris.core.nms.INMS;
|
||||
import com.volmit.iris.core.nms.datapack.DataVersion;
|
||||
import com.volmit.iris.core.service.IrisEngineSVC;
|
||||
import com.volmit.iris.core.tools.IrisPackBenchmarking;
|
||||
import com.volmit.iris.core.tools.IrisToolbelt;
|
||||
import com.volmit.iris.engine.framework.Engine;
|
||||
import com.volmit.iris.engine.object.IrisDimension;
|
||||
import com.volmit.iris.engine.object.IrisPosition;
|
||||
import com.volmit.iris.engine.object.annotations.Snippet;
|
||||
import com.volmit.iris.util.collection.KMap;
|
||||
import com.volmit.iris.util.collection.KSet;
|
||||
import com.volmit.iris.util.context.IrisContext;
|
||||
import com.volmit.iris.engine.object.IrisJigsawStructurePlacement;
|
||||
import com.volmit.iris.util.collection.KList;
|
||||
import com.volmit.iris.util.decree.DecreeExecutor;
|
||||
import com.volmit.iris.util.decree.DecreeOrigin;
|
||||
import com.volmit.iris.util.decree.annotations.Decree;
|
||||
import com.volmit.iris.util.decree.annotations.Param;
|
||||
import com.volmit.iris.util.decree.specialhandlers.NullableDimensionHandler;
|
||||
import com.volmit.iris.util.format.C;
|
||||
import com.volmit.iris.util.format.Form;
|
||||
import com.volmit.iris.util.io.CountingDataInputStream;
|
||||
import com.volmit.iris.util.io.IO;
|
||||
import com.volmit.iris.util.mantle.TectonicPlate;
|
||||
import com.volmit.iris.util.math.M;
|
||||
import com.volmit.iris.util.matter.Matter;
|
||||
import com.volmit.iris.util.nbt.mca.MCAFile;
|
||||
import com.volmit.iris.util.nbt.mca.MCAUtil;
|
||||
import com.volmit.iris.util.parallel.MultiBurst;
|
||||
import com.volmit.iris.util.plugin.VolmitSender;
|
||||
import com.volmit.iris.util.scheduling.jobs.Job;
|
||||
import lombok.SneakyThrows;
|
||||
import net.jpountz.lz4.LZ4BlockInputStream;
|
||||
import net.jpountz.lz4.LZ4BlockOutputStream;
|
||||
import net.jpountz.lz4.LZ4FrameInputStream;
|
||||
import net.jpountz.lz4.LZ4FrameOutputStream;
|
||||
import org.apache.commons.lang.RandomStringUtils;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Chunk;
|
||||
import org.bukkit.World;
|
||||
|
||||
import java.io.*;
|
||||
import java.net.InetAddress;
|
||||
import java.net.NetworkInterface;
|
||||
import java.nio.file.Files;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.zip.GZIPInputStream;
|
||||
import java.util.zip.GZIPOutputStream;
|
||||
|
||||
@Decree(name = "Developer", origin = DecreeOrigin.BOTH, description = "Iris World Manager", aliases = {"dev"})
|
||||
public class CommandDeveloper implements DecreeExecutor {
|
||||
private CommandTurboPregen turboPregen;
|
||||
private CommandLazyPregen lazyPregen;
|
||||
|
||||
@Decree(description = "Get Loaded TectonicPlates Count", origin = DecreeOrigin.BOTH, sync = true)
|
||||
public void EngineStatus() {
|
||||
Iris.service(IrisEngineSVC.class)
|
||||
.engineStatus(sender());
|
||||
}
|
||||
|
||||
@Decree(description = "Send a test exception to sentry")
|
||||
public void Sentry() {
|
||||
Engine engine = engine();
|
||||
if (engine != null) IrisContext.getOr(engine);
|
||||
Iris.reportError(new Exception("This is a test"));
|
||||
}
|
||||
|
||||
@Decree(description = "Dev cmd to fix all the broken objects caused by faulty shrinkwarp")
|
||||
public void fixObjects(
|
||||
@Param(aliases = "dimension", description = "The dimension type to create the world with")
|
||||
IrisDimension type
|
||||
) {
|
||||
if (type == null) {
|
||||
sender().sendMessage("Type cant be null?");
|
||||
return;
|
||||
}
|
||||
|
||||
IrisData dm = IrisData.get(Iris.instance.getDataFolder("packs", type.getLoadKey()));
|
||||
var loader = dm.getObjectLoader();
|
||||
var processed = new KMap<String, IrisPosition>();
|
||||
|
||||
var objects = loader.getPossibleKeys();
|
||||
var pieces = dm.getJigsawPieceLoader().getPossibleKeys();
|
||||
var sender = sender();
|
||||
|
||||
sender.sendMessage(C.IRIS + "Found " + objects.length + " objects in " + type.getLoadKey());
|
||||
sender.sendMessage(C.IRIS + "Found " + pieces.length + " jigsaw pieces in " + type.getLoadKey());
|
||||
|
||||
final int total = objects.length;
|
||||
final AtomicInteger completed = new AtomicInteger();
|
||||
final AtomicInteger changed = new AtomicInteger();
|
||||
|
||||
new Job() {
|
||||
@Override
|
||||
public String getName() {
|
||||
return "Fixing Objects";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute() {
|
||||
Arrays.stream(pieces).parallel()
|
||||
.map(dm.getJigsawPieceLoader()::load)
|
||||
.filter(Objects::nonNull)
|
||||
.forEach(piece -> {
|
||||
var offset = processed.compute(piece.getObject(), (key, o) -> {
|
||||
if (o != null) return o;
|
||||
var obj = loader.load(key);
|
||||
if (obj == null) return new IrisPosition();
|
||||
|
||||
obj.shrinkwrap();
|
||||
try {
|
||||
if (!obj.getShrinkOffset().isZero()) {
|
||||
changed.incrementAndGet();
|
||||
obj.write(obj.getLoadFile());
|
||||
}
|
||||
completeWork();
|
||||
} catch (IOException e) {
|
||||
Iris.error("Failed to write object " + obj.getLoadKey());
|
||||
e.printStackTrace();
|
||||
return new IrisPosition();
|
||||
}
|
||||
|
||||
return new IrisPosition(obj.getShrinkOffset());
|
||||
});
|
||||
if (offset.getX() == 0 && offset.getY() == 0 && offset.getZ() == 0)
|
||||
return;
|
||||
|
||||
piece.getConnectors().forEach(connector -> connector.setPosition(connector.getPosition().add(offset)));
|
||||
|
||||
try {
|
||||
IO.writeAll(piece.getLoadFile(), dm.getGson().toJson(piece));
|
||||
} catch (IOException e) {
|
||||
Iris.error("Failed to write jigsaw piece " + piece.getLoadKey());
|
||||
e.printStackTrace();
|
||||
}
|
||||
});
|
||||
|
||||
Arrays.stream(loader.getPossibleKeys()).parallel()
|
||||
.filter(key -> !processed.containsKey(key))
|
||||
.map(loader::load)
|
||||
.forEach(obj -> {
|
||||
if (obj == null) {
|
||||
completeWork();
|
||||
return;
|
||||
}
|
||||
|
||||
obj.shrinkwrap();
|
||||
if (obj.getShrinkOffset().isZero()) {
|
||||
completeWork();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
obj.write(obj.getLoadFile());
|
||||
completeWork();
|
||||
changed.incrementAndGet();
|
||||
} catch (IOException e) {
|
||||
Iris.error("Failed to write object " + obj.getLoadKey());
|
||||
e.printStackTrace();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void completeWork() {
|
||||
completed.incrementAndGet();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getTotalWork() {
|
||||
return total;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getWorkCompleted() {
|
||||
return completed.get();
|
||||
}
|
||||
}.execute(sender, () -> {
|
||||
var failed = total - completed.get();
|
||||
if (failed != 0) sender.sendMessage(C.IRIS + "" + failed + " objects failed!");
|
||||
if (changed.get() != 0) sender.sendMessage(C.IRIS + "" + changed.get() + " objects had their offsets changed!");
|
||||
else sender.sendMessage(C.IRIS + "No objects had their offsets changed!");
|
||||
});
|
||||
}
|
||||
|
||||
@Decree(description = "Test")
|
||||
public void mantle(@Param(defaultValue = "false") boolean plate, @Param(defaultValue = "21474836474") String name) throws Throwable {
|
||||
var base = Iris.instance.getDataFile("dump", "pv." + name + ".ttp.lz4b.bin");
|
||||
var section = Iris.instance.getDataFile("dump", "pv." + name + ".section.bin");
|
||||
|
||||
//extractSection(base, section, 5604930, 4397);
|
||||
|
||||
if (plate) {
|
||||
try (var in = CountingDataInputStream.wrap(new BufferedInputStream(new FileInputStream(base)))) {
|
||||
new TectonicPlate(1088, in, true);
|
||||
} catch (Throwable e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
} else Matter.read(section);
|
||||
if (!TectonicPlate.hasError())
|
||||
Iris.info("Read " + (plate ? base : section).length() + " bytes from " + (plate ? base : section).getAbsolutePath());
|
||||
}
|
||||
|
||||
private void extractSection(File source, File target, long offset, int length) throws IOException {
|
||||
var raf = new RandomAccessFile(source, "r");
|
||||
var bytes = new byte[length];
|
||||
raf.seek(offset);
|
||||
raf.readFully(bytes);
|
||||
raf.close();
|
||||
Files.write(target.toPath(), bytes);
|
||||
}
|
||||
|
||||
@Decree(description = "Test")
|
||||
public void dumpThreads() {
|
||||
try {
|
||||
File fi = Iris.instance.getDataFile("dump", "td-" + new java.sql.Date(M.ms()) + ".txt");
|
||||
FileOutputStream fos = new FileOutputStream(fi);
|
||||
Map<Thread, StackTraceElement[]> f = Thread.getAllStackTraces();
|
||||
PrintWriter pw = new PrintWriter(fos);
|
||||
|
||||
pw.println(Thread.activeCount() + "/" + f.size());
|
||||
var run = Runtime.getRuntime();
|
||||
pw.println("Memory:");
|
||||
pw.println("\tMax: " + run.maxMemory());
|
||||
pw.println("\tTotal: " + run.totalMemory());
|
||||
pw.println("\tFree: " + run.freeMemory());
|
||||
pw.println("\tUsed: " + (run.totalMemory() - run.freeMemory()));
|
||||
|
||||
for (Thread i : f.keySet()) {
|
||||
pw.println("========================================");
|
||||
pw.println("Thread: '" + i.getName() + "' ID: " + i.getId() + " STATUS: " + i.getState().name());
|
||||
|
||||
for (StackTraceElement j : f.get(i)) {
|
||||
pw.println(" @ " + j.toString());
|
||||
}
|
||||
|
||||
pw.println("========================================");
|
||||
pw.println();
|
||||
pw.println();
|
||||
}
|
||||
|
||||
pw.close();
|
||||
Iris.info("DUMPED! See " + fi.getAbsolutePath());
|
||||
} catch (Throwable e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
@SneakyThrows
|
||||
@Decree(description = "Generate Iris structures for all loaded datapack structures")
|
||||
public void generateStructures(
|
||||
@Param(description = "The pack to add the generated structures to", aliases = "pack", defaultValue = "null", customHandler = NullableDimensionHandler.class)
|
||||
IrisDimension dimension,
|
||||
@Param(description = "Ignore existing structures", defaultValue = "false")
|
||||
boolean force
|
||||
) {
|
||||
var map = INMS.get().collectStructures();
|
||||
if (map.isEmpty()) {
|
||||
sender().sendMessage(C.IRIS + "No structures found");
|
||||
return;
|
||||
}
|
||||
|
||||
sender().sendMessage(C.IRIS + "Found " + map.size() + " structures");
|
||||
|
||||
final File dataDir;
|
||||
final IrisData data;
|
||||
final Set<String> existingStructures;
|
||||
final Map<String, Set<String>> snippets;
|
||||
final File dimensionFile;
|
||||
final File structuresFolder;
|
||||
final File snippetsFolder;
|
||||
|
||||
var dimensionObj = new JsonObject();
|
||||
|
||||
if (dimension == null) {
|
||||
dataDir = Iris.instance.getDataFolder("structures");
|
||||
IO.delete(dataDir);
|
||||
data = IrisData.get(dataDir);
|
||||
existingStructures = Set.of();
|
||||
snippets = Map.of();
|
||||
dimensionFile = new File(dataDir, "structures.json");
|
||||
} else {
|
||||
data = dimension.getLoader();
|
||||
dataDir = data.getDataFolder();
|
||||
existingStructures = new KSet<>(data.getJigsawStructureLoader().getPossibleKeys());
|
||||
|
||||
dimensionObj = data.getGson().fromJson(IO.readAll(dimension.getLoadFile()), JsonObject.class);
|
||||
snippets = Optional.ofNullable(dimensionObj.getAsJsonArray("jigsawStructures"))
|
||||
.map(array -> array.asList()
|
||||
.stream()
|
||||
.filter(JsonElement::isJsonPrimitive)
|
||||
.collect(Collectors.toMap(element -> data.getGson()
|
||||
.fromJson(element, IrisJigsawStructurePlacement.class)
|
||||
.getStructure(),
|
||||
element -> Set.of(element.getAsString()),
|
||||
KSet::merge)))
|
||||
.orElse(Map.of());
|
||||
|
||||
dimensionFile = dimension.getLoadFile();
|
||||
}
|
||||
structuresFolder = new File(dataDir, "jigsaw-structures");
|
||||
snippetsFolder = new File(dataDir, "snippet" + "/" + IrisJigsawStructurePlacement.class.getAnnotation(Snippet.class).value());
|
||||
|
||||
var gson = data.getGson();
|
||||
var jigsawStructures = Optional.ofNullable(dimensionObj.getAsJsonArray("jigsawStructures"))
|
||||
.orElse(new JsonArray(map.size()));
|
||||
|
||||
map.forEach((key, placement) -> {
|
||||
String loadKey = "datapack/" + key.namespace() + "/" + key.key();
|
||||
if (existingStructures.contains(loadKey) && !force)
|
||||
return;
|
||||
|
||||
var structures = placement.structures();
|
||||
var obj = placement.toJson(loadKey);
|
||||
if (obj == null || structures.isEmpty()) {
|
||||
sender().sendMessage(C.RED + "Failed to generate hook for " + key);
|
||||
return;
|
||||
}
|
||||
File snippetFile = new File(snippetsFolder, loadKey + ".json");
|
||||
try {
|
||||
IO.writeAll(snippetFile, gson.toJson(obj));
|
||||
} catch (IOException e) {
|
||||
sender().sendMessage(C.RED + "Failed to generate snippet for " + key);
|
||||
e.printStackTrace();
|
||||
return;
|
||||
}
|
||||
|
||||
Set<String> loadKeys = snippets.getOrDefault(loadKey, Set.of(loadKey));
|
||||
jigsawStructures.asList().removeIf(e -> loadKeys.contains((e.isJsonObject() ? e.getAsJsonObject().get("structure") : e).getAsString()));
|
||||
jigsawStructures.add("snippet/" + loadKey);
|
||||
|
||||
String structureKey;
|
||||
if (structures.size() > 1) {
|
||||
KList<String> common = new KList<>();
|
||||
for (int i = 0; i < structures.size(); i++) {
|
||||
var tags = structures.get(i).tags();
|
||||
if (i == 0) common.addAll(tags);
|
||||
else common.removeIf(tag -> !tags.contains(tag));
|
||||
}
|
||||
structureKey = common.isNotEmpty() ? "#" + common.getFirst() : structures.getFirst().key();
|
||||
} else structureKey = structures.getFirst().key();
|
||||
|
||||
JsonArray array = new JsonArray();
|
||||
if (structures.size() > 1) {
|
||||
structures.stream()
|
||||
.flatMap(structure -> {
|
||||
String[] arr = new String[structure.weight()];
|
||||
Arrays.fill(arr, structure.key());
|
||||
return Arrays.stream(arr);
|
||||
})
|
||||
.forEach(array::add);
|
||||
} else array.add(structureKey);
|
||||
|
||||
obj = new JsonObject();
|
||||
obj.addProperty("structureKey", structureKey);
|
||||
obj.add("datapackStructures", array);
|
||||
|
||||
File out = new File(structuresFolder, loadKey + ".json");
|
||||
out.getParentFile().mkdirs();
|
||||
try {
|
||||
IO.writeAll(out, gson.toJson(obj));
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
});
|
||||
|
||||
dimensionObj.add("jigsawStructures", jigsawStructures);
|
||||
IO.writeAll(dimensionFile, gson.toJson(dimensionObj));
|
||||
|
||||
data.hotloaded();
|
||||
}
|
||||
|
||||
@Decree(description = "Test")
|
||||
public void packBenchmark(
|
||||
@Param(description = "The pack to bench", aliases = {"pack"}, defaultValue = "overworld")
|
||||
IrisDimension dimension,
|
||||
@Param(description = "Radius in regions", defaultValue = "2048")
|
||||
int radius,
|
||||
@Param(description = "Open GUI while benchmarking", defaultValue = "false")
|
||||
boolean gui
|
||||
) {
|
||||
new IrisPackBenchmarking(dimension, radius, gui);
|
||||
}
|
||||
|
||||
@Decree(description = "Upgrade to another Minecraft version")
|
||||
public void upgrade(
|
||||
@Param(description = "The version to upgrade to", defaultValue = "latest") DataVersion version) {
|
||||
sender().sendMessage(C.GREEN + "Upgrading to " + version.getVersion() + "...");
|
||||
ServerConfigurator.installDataPacks(version.get(), false);
|
||||
sender().sendMessage(C.GREEN + "Done upgrading! You can now update your server version to " + version.getVersion());
|
||||
}
|
||||
|
||||
@Decree(description = "test")
|
||||
public void mca (
|
||||
@Param(description = "String") String world) {
|
||||
try {
|
||||
File[] McaFiles = new File(world, "region").listFiles((dir, name) -> name.endsWith(".mca"));
|
||||
for (File mca : McaFiles) {
|
||||
MCAFile MCARegion = MCAUtil.read(mca);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Decree(description = "UnloadChunks for good reasons.")
|
||||
public void unloadchunks() {
|
||||
List<World> IrisWorlds = new ArrayList<>();
|
||||
int chunksUnloaded = 0;
|
||||
for (World world : Bukkit.getWorlds()) {
|
||||
try {
|
||||
if (IrisToolbelt.access(world).getEngine() != null) {
|
||||
IrisWorlds.add(world);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// no
|
||||
}
|
||||
}
|
||||
|
||||
for (World world : IrisWorlds) {
|
||||
for (Chunk chunk : world.getLoadedChunks()) {
|
||||
if (chunk.isLoaded()) {
|
||||
chunk.unload();
|
||||
chunksUnloaded++;
|
||||
}
|
||||
}
|
||||
}
|
||||
Iris.info(C.IRIS + "Chunks Unloaded: " + chunksUnloaded);
|
||||
|
||||
}
|
||||
|
||||
@Decree
|
||||
public void objects(@Param(defaultValue = "overworld") IrisDimension dimension) {
|
||||
var loader = dimension.getLoader().getObjectLoader();
|
||||
var sender = sender();
|
||||
var keys = loader.getPossibleKeys();
|
||||
var burst = MultiBurst.burst.burst(keys.length);
|
||||
AtomicInteger failed = new AtomicInteger();
|
||||
for (String key : keys) {
|
||||
burst.queue(() -> {
|
||||
if (loader.load(key) == null)
|
||||
failed.incrementAndGet();
|
||||
});
|
||||
}
|
||||
burst.complete();
|
||||
sender.sendMessage(C.RED + "Failed to load " + failed.get() + " of " + keys.length + " objects");
|
||||
}
|
||||
|
||||
@Decree(description = "Test", aliases = {"ip"})
|
||||
public void network() {
|
||||
try {
|
||||
Enumeration<NetworkInterface> networkInterfaces = NetworkInterface.getNetworkInterfaces();
|
||||
for (NetworkInterface ni : Collections.list(networkInterfaces)) {
|
||||
Iris.info("Display Name: %s", ni.getDisplayName());
|
||||
Enumeration<InetAddress> inetAddresses = ni.getInetAddresses();
|
||||
for (InetAddress ia : Collections.list(inetAddresses)) {
|
||||
Iris.info("IP: %s", ia.getHostAddress());
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
@Decree(description = "Test the compression algorithms")
|
||||
public void compression(
|
||||
@Param(description = "base IrisWorld") World world,
|
||||
@Param(description = "raw TectonicPlate File") String path,
|
||||
@Param(description = "Algorithm to Test") String algorithm,
|
||||
@Param(description = "Amount of Tests") int amount,
|
||||
@Param(description = "Is versioned", defaultValue = "false") boolean versioned) {
|
||||
if (!IrisToolbelt.isIrisWorld(world)) {
|
||||
sender().sendMessage(C.RED + "This is not an Iris world. Iris worlds: " + String.join(", ", Bukkit.getServer().getWorlds().stream().filter(IrisToolbelt::isIrisWorld).map(World::getName).toList()));
|
||||
return;
|
||||
}
|
||||
|
||||
File file = new File(path);
|
||||
if (!file.exists()) return;
|
||||
|
||||
Engine engine = IrisToolbelt.access(world).getEngine();
|
||||
if(engine != null) {
|
||||
int height = engine.getTarget().getHeight();
|
||||
ExecutorService service = Executors.newFixedThreadPool(1);
|
||||
VolmitSender sender = sender();
|
||||
service.submit(() -> {
|
||||
try {
|
||||
CountingDataInputStream raw = CountingDataInputStream.wrap(new FileInputStream(file));
|
||||
TectonicPlate plate = new TectonicPlate(height, raw, versioned);
|
||||
raw.close();
|
||||
|
||||
double d1 = 0;
|
||||
double d2 = 0;
|
||||
long size = 0;
|
||||
File folder = new File("tmp");
|
||||
folder.mkdirs();
|
||||
for (int i = 0; i < amount; i++) {
|
||||
File tmp = new File(folder, RandomStringUtils.randomAlphanumeric(10) + "." + algorithm + ".bin");
|
||||
DataOutputStream dos = createOutput(tmp, algorithm);
|
||||
long start = System.currentTimeMillis();
|
||||
plate.write(dos);
|
||||
dos.close();
|
||||
d1 += System.currentTimeMillis() - start;
|
||||
if (size == 0)
|
||||
size = tmp.length();
|
||||
start = System.currentTimeMillis();
|
||||
CountingDataInputStream din = createInput(tmp, algorithm);
|
||||
new TectonicPlate(height, din, true);
|
||||
din.close();
|
||||
d2 += System.currentTimeMillis() - start;
|
||||
tmp.delete();
|
||||
}
|
||||
IO.delete(folder);
|
||||
sender.sendMessage(algorithm + " is " + Form.fileSize(size) + " big after compression");
|
||||
sender.sendMessage(algorithm + " Took " + d2/amount + "ms to read");
|
||||
sender.sendMessage(algorithm + " Took " + d1/amount + "ms to write");
|
||||
} catch (Throwable e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
});
|
||||
service.shutdown();
|
||||
} else {
|
||||
Iris.info(C.RED + "Engine is null!");
|
||||
}
|
||||
}
|
||||
|
||||
private CountingDataInputStream createInput(File file, String algorithm) throws Throwable {
|
||||
FileInputStream in = new FileInputStream(file);
|
||||
|
||||
return CountingDataInputStream.wrap(switch (algorithm) {
|
||||
case "gzip" -> new GZIPInputStream(in);
|
||||
case "lz4f" -> new LZ4FrameInputStream(in);
|
||||
case "lz4b" -> new LZ4BlockInputStream(in);
|
||||
default -> throw new IllegalStateException("Unexpected value: " + algorithm);
|
||||
});
|
||||
}
|
||||
|
||||
private DataOutputStream createOutput(File file, String algorithm) throws Throwable {
|
||||
FileOutputStream out = new FileOutputStream(file);
|
||||
|
||||
return new DataOutputStream(switch (algorithm) {
|
||||
case "gzip" -> new GZIPOutputStream(out);
|
||||
case "lz4f" -> new LZ4FrameOutputStream(out);
|
||||
case "lz4b" -> new LZ4BlockOutputStream(out);
|
||||
default -> throw new IllegalStateException("Unexpected value: " + algorithm);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+10
@@ -46,6 +46,16 @@ public class CommandEdit implements DecreeExecutor {
|
||||
sender().sendMessage(C.RED + "You must be in a studio world!");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (GraphicsEnvironment.isHeadless()) {
|
||||
sender().sendMessage(C.RED + "Cannot open files in headless environments!");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!Desktop.isDesktopSupported()) {
|
||||
sender().sendMessage(C.RED + "Desktop is not supported by this environment!");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
+20
-10
@@ -34,7 +34,9 @@ public class CommandFind implements DecreeExecutor {
|
||||
@Decree(description = "Find a biome")
|
||||
public void biome(
|
||||
@Param(description = "The biome to look for")
|
||||
IrisBiome biome
|
||||
IrisBiome biome,
|
||||
@Param(description = "Should you be teleported", defaultValue = "true")
|
||||
boolean teleport
|
||||
) {
|
||||
Engine e = engine();
|
||||
|
||||
@@ -43,13 +45,15 @@ public class CommandFind implements DecreeExecutor {
|
||||
return;
|
||||
}
|
||||
|
||||
e.gotoBiome(biome, player());
|
||||
e.gotoBiome(biome, player(), teleport);
|
||||
}
|
||||
|
||||
@Decree(description = "Find a region")
|
||||
public void region(
|
||||
@Param(description = "The region to look for")
|
||||
IrisRegion region
|
||||
IrisRegion region,
|
||||
@Param(description = "Should you be teleported", defaultValue = "true")
|
||||
boolean teleport
|
||||
) {
|
||||
Engine e = engine();
|
||||
|
||||
@@ -58,13 +62,15 @@ public class CommandFind implements DecreeExecutor {
|
||||
return;
|
||||
}
|
||||
|
||||
e.gotoRegion(region, player());
|
||||
e.gotoRegion(region, player(), teleport);
|
||||
}
|
||||
|
||||
@Decree(description = "Find a structure")
|
||||
public void structure(
|
||||
@Param(description = "The structure to look for")
|
||||
IrisJigsawStructure structure
|
||||
IrisJigsawStructure structure,
|
||||
@Param(description = "Should you be teleported", defaultValue = "true")
|
||||
boolean teleport
|
||||
) {
|
||||
Engine e = engine();
|
||||
|
||||
@@ -73,13 +79,15 @@ public class CommandFind implements DecreeExecutor {
|
||||
return;
|
||||
}
|
||||
|
||||
e.gotoJigsaw(structure, player());
|
||||
e.gotoJigsaw(structure, player(), teleport);
|
||||
}
|
||||
|
||||
@Decree(description = "Find a point of interest.")
|
||||
public void poi(
|
||||
@Param(description = "The type of PoI to look for.")
|
||||
String type
|
||||
String type,
|
||||
@Param(description = "Should you be teleported", defaultValue = "true")
|
||||
boolean teleport
|
||||
) {
|
||||
Engine e = engine();
|
||||
if (e == null) {
|
||||
@@ -87,13 +95,15 @@ public class CommandFind implements DecreeExecutor {
|
||||
return;
|
||||
}
|
||||
|
||||
e.gotoPOI(type, player());
|
||||
e.gotoPOI(type, player(), teleport);
|
||||
}
|
||||
|
||||
@Decree(description = "Find an object")
|
||||
public void object(
|
||||
@Param(description = "The object to look for", customHandler = ObjectHandler.class)
|
||||
String object
|
||||
String object,
|
||||
@Param(description = "Should you be teleported", defaultValue = "true")
|
||||
boolean teleport
|
||||
) {
|
||||
Engine e = engine();
|
||||
|
||||
@@ -102,6 +112,6 @@ public class CommandFind implements DecreeExecutor {
|
||||
return;
|
||||
}
|
||||
|
||||
e.gotoObject(object, player());
|
||||
e.gotoObject(object, player(), teleport);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.volmit.iris.core.commands;
|
||||
|
||||
import com.volmit.iris.Iris;
|
||||
import com.volmit.iris.core.IrisSettings;
|
||||
import com.volmit.iris.core.nms.INMS;
|
||||
import com.volmit.iris.core.service.StudioSVC;
|
||||
import com.volmit.iris.core.tools.IrisToolbelt;
|
||||
import com.volmit.iris.core.world.WorldStorage;
|
||||
import com.volmit.iris.engine.framework.Engine;
|
||||
import com.volmit.iris.engine.object.IrisDimension;
|
||||
import com.volmit.iris.util.collection.KList;
|
||||
import com.volmit.iris.util.decree.DecreeExecutor;
|
||||
import com.volmit.iris.util.decree.DecreeOrigin;
|
||||
import com.volmit.iris.util.decree.annotations.Decree;
|
||||
import com.volmit.iris.util.decree.annotations.Param;
|
||||
import com.volmit.iris.util.decree.specialhandlers.NullablePlayerHandler;
|
||||
import com.volmit.iris.util.format.C;
|
||||
import com.volmit.iris.util.plugin.VolmitSender;
|
||||
import com.volmit.iris.util.scheduling.J;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.configuration.ConfigurationSection;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.scheduler.BukkitRunnable;
|
||||
|
||||
import java.io.*;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static com.volmit.iris.core.service.EditSVC.deletingWorld;
|
||||
import static com.volmit.iris.util.misc.ServerProperties.BUKKIT_YML;
|
||||
import static org.bukkit.Bukkit.getServer;
|
||||
|
||||
@Decree(name = "iris", aliases = {"ir", "irs"}, description = "Basic Command")
|
||||
public class CommandIris implements DecreeExecutor {
|
||||
private CommandUpdater updater;
|
||||
private CommandStudio studio;
|
||||
private CommandPregen pregen;
|
||||
private CommandSettings settings;
|
||||
private CommandObject object;
|
||||
private CommandJigsaw jigsaw;
|
||||
private CommandWhat what;
|
||||
private CommandEdit edit;
|
||||
private CommandFind find;
|
||||
private CommandDeveloper developer;
|
||||
public static boolean worldCreation = false;
|
||||
private static final AtomicReference<Thread> mainWorld = new AtomicReference<>();
|
||||
String WorldEngine;
|
||||
String worldNameToCheck = "YourWorldName";
|
||||
VolmitSender sender = Iris.getSender();
|
||||
|
||||
@Decree(description = "Create a new world", aliases = {"+", "c"})
|
||||
public void create(
|
||||
@Param(aliases = "world-name", description = "The name of the world to create")
|
||||
String name,
|
||||
@Param(aliases = "dimension", description = "The dimension type to create the world with", defaultValue = "default")
|
||||
IrisDimension type,
|
||||
@Param(description = "The seed to generate the world with", defaultValue = "1337")
|
||||
long seed,
|
||||
@Param(aliases = "main-world", description = "Whether or not to automatically use this world as the main world", defaultValue = "false")
|
||||
boolean main
|
||||
) {
|
||||
if (name.equalsIgnoreCase("iris")) {
|
||||
sender().sendMessage(C.RED + "You cannot use the world name \"iris\" for creating worlds as Iris uses this directory for studio worlds.");
|
||||
sender().sendMessage(C.RED + "May we suggest the name \"IrisWorld\" instead?");
|
||||
return;
|
||||
}
|
||||
|
||||
if (name.equalsIgnoreCase("benchmark")) {
|
||||
sender().sendMessage(C.RED + "You cannot use the world name \"benchmark\" for creating worlds as Iris uses this directory for Benchmarking Packs.");
|
||||
sender().sendMessage(C.RED + "May we suggest the name \"IrisWorld\" instead?");
|
||||
return;
|
||||
}
|
||||
|
||||
if (WorldStorage.get().worldFolder(name).exists()) {
|
||||
sender().sendMessage(C.RED + "That folder already exists!");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
worldCreation = true;
|
||||
IrisToolbelt.createWorld()
|
||||
.dimension(type.getLoadKey())
|
||||
.name(name)
|
||||
.seed(seed)
|
||||
.sender(sender())
|
||||
.studio(false)
|
||||
.create();
|
||||
if (main) {
|
||||
WorldStorage storage = WorldStorage.get();
|
||||
Runtime.getRuntime().addShutdownHook(mainWorld.updateAndGet(old -> {
|
||||
if (old != null) Runtime.getRuntime().removeShutdownHook(old);
|
||||
return new Thread(() -> makeMainWorld(storage, name));
|
||||
}));
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
sender().sendMessage(C.RED + "Exception raised during creation. See the console for more details.");
|
||||
Iris.error("Exception raised during world creation: " + e.getMessage());
|
||||
Iris.reportError(e);
|
||||
worldCreation = false;
|
||||
return;
|
||||
}
|
||||
worldCreation = false;
|
||||
sender().sendMessage(C.GREEN + "Successfully created your world!");
|
||||
if (main) sender().sendMessage(C.GREEN + "Your world will automatically be set as the main world when the server restarts.");
|
||||
}
|
||||
|
||||
private static void makeMainWorld(WorldStorage storage, String worldName) {
|
||||
try {
|
||||
storage.makeMainWorld(worldName);
|
||||
} catch (Throwable e) {
|
||||
Iris.error("Failed to make " + worldName + " the main world!");
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
@Decree(description = "Teleport to another world", aliases = {"tp"}, sync = true)
|
||||
public void teleport(
|
||||
@Param(description = "World to teleport to")
|
||||
World world,
|
||||
@Param(description = "Player to teleport", defaultValue = "---", customHandler = NullablePlayerHandler.class)
|
||||
Player player
|
||||
) {
|
||||
if (player == null && sender().isPlayer())
|
||||
player = sender().player();
|
||||
|
||||
final Player target = player;
|
||||
if (target == null) {
|
||||
sender().sendMessage(C.RED + "The specified player does not exist.");
|
||||
return;
|
||||
}
|
||||
|
||||
new BukkitRunnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
target.teleport(world.getSpawnLocation());
|
||||
new VolmitSender(target).sendMessage(C.GREEN + "You have been teleported to " + world.getName() + ".");
|
||||
}
|
||||
}.runTask(Iris.instance);
|
||||
}
|
||||
|
||||
@Decree(description = "Print version information")
|
||||
public void version() {
|
||||
sender().sendMessage(C.GREEN + "Iris v" + Iris.instance.getDescription().getVersion() + " by Volmit Software");
|
||||
}
|
||||
|
||||
/*
|
||||
/todo
|
||||
@Decree(description = "Benchmark a pack", origin = DecreeOrigin.CONSOLE)
|
||||
public void packbenchmark(
|
||||
@Param(description = "Dimension to benchmark")
|
||||
IrisDimension type
|
||||
) throws InterruptedException {
|
||||
|
||||
BenchDimension = type.getLoadKey();
|
||||
|
||||
IrisPackBenchmarking.runBenchmark();
|
||||
} */
|
||||
|
||||
@Decree(description = "Print world height information", origin = DecreeOrigin.PLAYER)
|
||||
public void height() {
|
||||
if (sender().isPlayer()) {
|
||||
sender().sendMessage(C.GREEN + "" + sender().player().getWorld().getMinHeight() + " to " + sender().player().getWorld().getMaxHeight());
|
||||
sender().sendMessage(C.GREEN + "Total Height: " + (sender().player().getWorld().getMaxHeight() - sender().player().getWorld().getMinHeight()));
|
||||
} else {
|
||||
World mainWorld = getServer().getWorlds().get(0);
|
||||
Iris.info(C.GREEN + "" + mainWorld.getMinHeight() + " to " + mainWorld.getMaxHeight());
|
||||
Iris.info(C.GREEN + "Total Height: " + (mainWorld.getMaxHeight() - mainWorld.getMinHeight()));
|
||||
}
|
||||
}
|
||||
|
||||
@Decree(description = "QOL command to open a overworld studio world.", sync = true)
|
||||
public void so() {
|
||||
sender().sendMessage(C.GREEN + "Opening studio for the \"Overworld\" pack (seed: 1337)");
|
||||
Iris.service(StudioSVC.class).open(sender(), 1337, "overworld");
|
||||
}
|
||||
|
||||
@Decree(description = "Check access of all worlds.", aliases = {"accesslist"})
|
||||
public void worlds() {
|
||||
KList<World> IrisWorlds = new KList<>();
|
||||
KList<World> BukkitWorlds = new KList<>();
|
||||
|
||||
for (World w : Bukkit.getServer().getWorlds()) {
|
||||
try {
|
||||
Engine engine = IrisToolbelt.access(w).getEngine();
|
||||
if (engine != null) {
|
||||
IrisWorlds.add(w);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
BukkitWorlds.add(w);
|
||||
}
|
||||
}
|
||||
|
||||
if (sender().isPlayer()) {
|
||||
sender().sendMessage(C.BLUE + "Iris Worlds: ");
|
||||
for (World IrisWorld : IrisWorlds.copy()) {
|
||||
sender().sendMessage(C.IRIS + "- " +IrisWorld.getName());
|
||||
}
|
||||
sender().sendMessage(C.GOLD + "Bukkit Worlds: ");
|
||||
for (World BukkitWorld : BukkitWorlds.copy()) {
|
||||
sender().sendMessage(C.GRAY + "- " +BukkitWorld.getName());
|
||||
}
|
||||
} else {
|
||||
Iris.info(C.BLUE + "Iris Worlds: ");
|
||||
for (World IrisWorld : IrisWorlds.copy()) {
|
||||
Iris.info(C.IRIS + "- " +IrisWorld.getName());
|
||||
}
|
||||
Iris.info(C.GOLD + "Bukkit Worlds: ");
|
||||
for (World BukkitWorld : BukkitWorlds.copy()) {
|
||||
Iris.info(C.GRAY + "- " +BukkitWorld.getName());
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@Decree(description = "Remove an Iris world", aliases = {"del", "rm", "delete"}, sync = true)
|
||||
public void remove(
|
||||
@Param(description = "The world to remove")
|
||||
World world,
|
||||
@Param(description = "Whether to also remove the folder (if set to false, just does not load the world)", defaultValue = "true")
|
||||
boolean delete
|
||||
) {
|
||||
if (!IrisToolbelt.isIrisWorld(world)) {
|
||||
sender().sendMessage(C.RED + "This is not an Iris world. Iris worlds: " + String.join(", ", getServer().getWorlds().stream().filter(IrisToolbelt::isIrisWorld).map(World::getName).toList()));
|
||||
return;
|
||||
}
|
||||
sender().sendMessage(C.GREEN + "Removing world: " + world.getName());
|
||||
|
||||
if (!IrisToolbelt.evacuate(world)) {
|
||||
sender().sendMessage(C.RED + "Failed to evacuate world: " + world.getName());
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Bukkit.unloadWorld(world, false)) {
|
||||
sender().sendMessage(C.RED + "Failed to unload world: " + world.getName());
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (IrisToolbelt.removeWorld(world)) {
|
||||
sender().sendMessage(C.GREEN + "Successfully removed " + world.getName() + " from bukkit.yml");
|
||||
} else {
|
||||
sender().sendMessage(C.YELLOW + "Looks like the world was already removed from bukkit.yml");
|
||||
}
|
||||
} catch (IOException e) {
|
||||
sender().sendMessage(C.RED + "Failed to save bukkit.yml because of " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
}
|
||||
IrisToolbelt.evacuate(world, "Deleting world");
|
||||
deletingWorld = true;
|
||||
if (!delete) {
|
||||
deletingWorld = false;
|
||||
return;
|
||||
}
|
||||
VolmitSender sender = sender();
|
||||
J.a(() -> {
|
||||
int retries = 12;
|
||||
|
||||
if (deleteDirectory(world.getWorldFolder())) {
|
||||
sender.sendMessage(C.GREEN + "Successfully removed world folder");
|
||||
} else {
|
||||
while(true){
|
||||
if (deleteDirectory(world.getWorldFolder())){
|
||||
sender.sendMessage(C.GREEN + "Successfully removed world folder");
|
||||
break;
|
||||
}
|
||||
retries--;
|
||||
if (retries == 0){
|
||||
sender.sendMessage(C.RED + "Failed to remove world folder");
|
||||
break;
|
||||
}
|
||||
J.sleep(3000);
|
||||
}
|
||||
}
|
||||
deletingWorld = false;
|
||||
});
|
||||
}
|
||||
|
||||
public static boolean deleteDirectory(File dir) {
|
||||
if (dir.isDirectory()) {
|
||||
File[] children = dir.listFiles();
|
||||
for (int i = 0; i < children.length; i++) {
|
||||
boolean success = deleteDirectory(children[i]);
|
||||
if (!success) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return dir.delete();
|
||||
}
|
||||
|
||||
@Decree(description = "Set aura spins")
|
||||
public void aura(
|
||||
@Param(description = "The h color value", defaultValue = "-20")
|
||||
int h,
|
||||
@Param(description = "The s color value", defaultValue = "7")
|
||||
int s,
|
||||
@Param(description = "The b color value", defaultValue = "8")
|
||||
int b
|
||||
) {
|
||||
IrisSettings.get().getGeneral().setSpinh(h);
|
||||
IrisSettings.get().getGeneral().setSpins(s);
|
||||
IrisSettings.get().getGeneral().setSpinb(b);
|
||||
IrisSettings.get().forceSave();
|
||||
sender().sendMessage("<rainbow>Aura Spins updated to " + h + " " + s + " " + b);
|
||||
}
|
||||
|
||||
@Decree(description = "Bitwise calculations")
|
||||
public void bitwise(
|
||||
@Param(description = "The first value to run calculations on")
|
||||
int value1,
|
||||
@Param(description = "The operator: | & ^ ≺≺ ≻≻ %")
|
||||
String operator,
|
||||
@Param(description = "The second value to run calculations on")
|
||||
int value2
|
||||
) {
|
||||
Integer v = null;
|
||||
switch (operator) {
|
||||
case "|" -> v = value1 | value2;
|
||||
case "&" -> v = value1 & value2;
|
||||
case "^" -> v = value1 ^ value2;
|
||||
case "%" -> v = value1 % value2;
|
||||
case ">>" -> v = value1 >> value2;
|
||||
case "<<" -> v = value1 << value2;
|
||||
}
|
||||
if (v == null) {
|
||||
sender().sendMessage(C.RED + "The operator you entered: (" + operator + ") is invalid!");
|
||||
return;
|
||||
}
|
||||
sender().sendMessage(C.GREEN + "" + value1 + " " + C.GREEN + operator.replaceAll("<", "≺").replaceAll(">", "≻").replaceAll("%", "%") + " " + C.GREEN + value2 + C.GREEN + " returns " + C.GREEN + v);
|
||||
}
|
||||
|
||||
@Decree(description = "Toggle debug")
|
||||
public void debug(
|
||||
@Param(name = "on", description = "Whether or not debug should be on", defaultValue = "other")
|
||||
Boolean on
|
||||
) {
|
||||
boolean to = on == null ? !IrisSettings.get().getGeneral().isDebug() : on;
|
||||
IrisSettings.get().getGeneral().setDebug(to);
|
||||
IrisSettings.get().forceSave();
|
||||
sender().sendMessage(C.GREEN + "Set debug to: " + to);
|
||||
}
|
||||
|
||||
//TODO fix pack trimming
|
||||
@Decree(description = "Download a project.", aliases = "dl")
|
||||
public void download(
|
||||
@Param(name = "pack", description = "The pack to download", defaultValue = "overworld", aliases = "project")
|
||||
String pack,
|
||||
@Param(name = "branch", description = "The branch to download from", defaultValue = "main")
|
||||
String branch,
|
||||
//@Param(name = "trim", description = "Whether or not to download a trimmed version (do not enable when editing)", defaultValue = "false")
|
||||
//boolean trim,
|
||||
@Param(name = "overwrite", description = "Whether or not to overwrite the pack with the downloaded one", aliases = "force", defaultValue = "false")
|
||||
boolean overwrite
|
||||
) {
|
||||
boolean trim = false;
|
||||
sender().sendMessage(C.GREEN + "Downloading pack: " + pack + "/" + branch + (trim ? " trimmed" : "") + (overwrite ? " overwriting" : ""));
|
||||
if (pack.equals("overworld")) {
|
||||
String url = "https://github.com/IrisDimensions/overworld/releases/download/" + INMS.OVERWORLD_TAG + "/overworld.zip";
|
||||
Iris.service(StudioSVC.class).downloadRelease(sender(), url, trim, overwrite);
|
||||
} else {
|
||||
Iris.service(StudioSVC.class).downloadSearch(sender(), "IrisDimensions/" + pack + "/" + branch, trim, overwrite);
|
||||
}
|
||||
}
|
||||
|
||||
@Decree(description = "Get metrics for your world", aliases = "measure", origin = DecreeOrigin.PLAYER)
|
||||
public void metrics() {
|
||||
if (!IrisToolbelt.isIrisWorld(world())) {
|
||||
sender().sendMessage(C.RED + "You must be in an Iris world");
|
||||
return;
|
||||
}
|
||||
sender().sendMessage(C.GREEN + "Sending metrics...");
|
||||
engine().printMetrics(sender());
|
||||
}
|
||||
|
||||
@Decree(description = "Reload configuration file (this is also done automatically)")
|
||||
public void reload() {
|
||||
IrisSettings.invalidate();
|
||||
IrisSettings.get();
|
||||
sender().sendMessage(C.GREEN + "Hotloaded settings");
|
||||
}
|
||||
|
||||
@Decree(description = "Update the pack of a world (UNSAFE!)", name = "^world", aliases = "update-world")
|
||||
public void updateWorld(
|
||||
@Param(description = "The world to update", contextual = true)
|
||||
World world,
|
||||
@Param(description = "The pack to install into the world", contextual = true, aliases = "dimension")
|
||||
IrisDimension pack,
|
||||
@Param(description = "Make sure to make a backup & read the warnings first!", defaultValue = "false", aliases = "c")
|
||||
boolean confirm,
|
||||
@Param(description = "Should Iris download the pack again for you", defaultValue = "false", name = "fresh-download", aliases = {"fresh", "new"})
|
||||
boolean freshDownload
|
||||
) {
|
||||
if (!confirm) {
|
||||
sender().sendMessage(new String[]{
|
||||
C.RED + "You should always make a backup before using this",
|
||||
C.YELLOW + "Issues caused by this can be, but are not limited to:",
|
||||
C.YELLOW + " - Broken chunks (cut-offs) between old and new chunks (before & after the update)",
|
||||
C.YELLOW + " - Regenerated chunks that do not fit in with the old chunks",
|
||||
C.YELLOW + " - Structures not spawning again when regenerating",
|
||||
C.YELLOW + " - Caves not lining up",
|
||||
C.YELLOW + " - Terrain layers not lining up",
|
||||
C.RED + "Now that you are aware of the risks, and have made a back-up:",
|
||||
C.RED + "/iris ^world " + world.getName() + " " + pack.getLoadKey() + " confirm=true"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
File folder = world.getWorldFolder();
|
||||
folder.mkdirs();
|
||||
|
||||
if (freshDownload) {
|
||||
Iris.service(StudioSVC.class).downloadSearch(sender(), pack.getLoadKey(), false, true);
|
||||
}
|
||||
|
||||
Iris.service(StudioSVC.class).installIntoWorld(sender(), pack.getLoadKey(), folder);
|
||||
}
|
||||
|
||||
@Decree(description = "Unload an Iris World", origin = DecreeOrigin.PLAYER, sync = true)
|
||||
public void unloadWorld(
|
||||
@Param(description = "The world to unload")
|
||||
World world
|
||||
) {
|
||||
if (!IrisToolbelt.isIrisWorld(world)) {
|
||||
sender().sendMessage(C.RED + "This is not an Iris world. Iris worlds: " + String.join(", ", getServer().getWorlds().stream().filter(IrisToolbelt::isIrisWorld).map(World::getName).toList()));
|
||||
return;
|
||||
}
|
||||
sender().sendMessage(C.GREEN + "Unloading world: " + world.getName());
|
||||
try {
|
||||
IrisToolbelt.evacuate(world);
|
||||
Bukkit.unloadWorld(world, false);
|
||||
sender().sendMessage(C.GREEN + "World unloaded successfully.");
|
||||
} catch (Exception e) {
|
||||
sender().sendMessage(C.RED + "Failed to unload the world: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
@Decree(description = "Load an Iris World", origin = DecreeOrigin.PLAYER, sync = true, aliases = {"import"})
|
||||
public void loadWorld(
|
||||
@Param(description = "The name of the world to load")
|
||||
String world
|
||||
) {
|
||||
World worldloaded = Bukkit.getWorld(world);
|
||||
WorldStorage.get().adoptLegacyData(world);
|
||||
worldNameToCheck = world;
|
||||
boolean worldExists = doesWorldExist(worldNameToCheck);
|
||||
WorldEngine = world;
|
||||
|
||||
if (!worldExists) {
|
||||
sender().sendMessage(C.YELLOW + world + " Doesnt exist on the server.");
|
||||
return;
|
||||
}
|
||||
|
||||
File directory = new File(WorldStorage.get().worldFolder(world), "iris/pack/dimensions");
|
||||
|
||||
String dimension = null;
|
||||
if (directory.exists() && directory.isDirectory()) {
|
||||
File[] files = directory.listFiles();
|
||||
if (files != null) {
|
||||
for (File file : files) {
|
||||
if (file.isFile()) {
|
||||
String fileName = file.getName();
|
||||
if (fileName.endsWith(".json")) {
|
||||
dimension = fileName.substring(0, fileName.length() - 5);
|
||||
sender().sendMessage(C.BLUE + "Generator: " + dimension);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
sender().sendMessage(C.GOLD + world + " is not an iris world.");
|
||||
return;
|
||||
}
|
||||
sender().sendMessage(C.GREEN + "Loading world: " + world);
|
||||
|
||||
YamlConfiguration yml = YamlConfiguration.loadConfiguration(BUKKIT_YML);
|
||||
String gen = "Iris:" + dimension;
|
||||
ConfigurationSection section = yml.contains("worlds") ? yml.getConfigurationSection("worlds") : yml.createSection("worlds");
|
||||
if (!section.contains(world)) {
|
||||
section.createSection(world).set("generator", gen);
|
||||
try {
|
||||
yml.save(BUKKIT_YML);
|
||||
Iris.info("Registered \"" + world + "\" in bukkit.yml");
|
||||
} catch (IOException e) {
|
||||
Iris.error("Failed to update bukkit.yml!");
|
||||
e.printStackTrace();
|
||||
return;
|
||||
}
|
||||
}
|
||||
Iris.instance.checkForBukkitWorlds(world::equals);
|
||||
sender().sendMessage(C.GREEN + world + " loaded successfully.");
|
||||
}
|
||||
@Decree(description = "Evacuate an iris world", origin = DecreeOrigin.PLAYER, sync = true)
|
||||
public void evacuate(
|
||||
@Param(description = "Evacuate the world")
|
||||
World world
|
||||
) {
|
||||
if (!IrisToolbelt.isIrisWorld(world)) {
|
||||
sender().sendMessage(C.RED + "This is not an Iris world. Iris worlds: " + String.join(", ", getServer().getWorlds().stream().filter(IrisToolbelt::isIrisWorld).map(World::getName).toList()));
|
||||
return;
|
||||
}
|
||||
sender().sendMessage(C.GREEN + "Evacuating world" + world.getName());
|
||||
IrisToolbelt.evacuate(world);
|
||||
}
|
||||
|
||||
boolean doesWorldExist(String worldName) {
|
||||
File worldDirectory = WorldStorage.get().worldFolder(worldName);
|
||||
return worldDirectory.exists() && worldDirectory.isDirectory();
|
||||
}
|
||||
}
|
||||
+14
-5
@@ -21,6 +21,7 @@ package com.volmit.iris.core.commands;
|
||||
import com.volmit.iris.Iris;
|
||||
import com.volmit.iris.core.edit.JigsawEditor;
|
||||
import com.volmit.iris.core.loader.IrisData;
|
||||
import com.volmit.iris.engine.framework.placer.WorldObjectPlacer;
|
||||
import com.volmit.iris.engine.jigsaw.PlannedStructure;
|
||||
import com.volmit.iris.engine.object.IrisJigsawPiece;
|
||||
import com.volmit.iris.engine.object.IrisJigsawStructure;
|
||||
@@ -34,6 +35,7 @@ import com.volmit.iris.util.decree.specialhandlers.ObjectHandler;
|
||||
import com.volmit.iris.util.format.C;
|
||||
import com.volmit.iris.util.format.Form;
|
||||
import com.volmit.iris.util.math.RNG;
|
||||
import com.volmit.iris.util.plugin.VolmitSender;
|
||||
import com.volmit.iris.util.scheduling.PrecisionStopwatch;
|
||||
|
||||
import java.io.File;
|
||||
@@ -46,7 +48,7 @@ public class CommandJigsaw implements DecreeExecutor {
|
||||
IrisJigsawPiece piece
|
||||
) {
|
||||
File dest = piece.getLoadFile();
|
||||
new JigsawEditor(player(), piece, IrisData.loadAnyObject(piece.getObject()), dest);
|
||||
new JigsawEditor(player(), piece, IrisData.loadAnyObject(piece.getObject(), data()), dest);
|
||||
}
|
||||
|
||||
@Decree(description = "Place a jigsaw structure")
|
||||
@@ -55,9 +57,16 @@ public class CommandJigsaw implements DecreeExecutor {
|
||||
IrisJigsawStructure structure
|
||||
) {
|
||||
PrecisionStopwatch p = PrecisionStopwatch.start();
|
||||
PlannedStructure ps = new PlannedStructure(structure, new IrisPosition(player().getLocation()), new RNG());
|
||||
sender().sendMessage(C.GREEN + "Generated " + ps.getPieces().size() + " pieces in " + Form.duration(p.getMilliseconds(), 2));
|
||||
ps.place(world());
|
||||
try {
|
||||
var world = world();
|
||||
WorldObjectPlacer placer = new WorldObjectPlacer(world);
|
||||
PlannedStructure ps = new PlannedStructure(structure, new IrisPosition(player().getLocation().add(0, world.getMinHeight(), 0)), new RNG(), true);
|
||||
VolmitSender sender = sender();
|
||||
sender.sendMessage(C.GREEN + "Generated " + ps.getPieces().size() + " pieces in " + Form.duration(p.getMilliseconds(), 2));
|
||||
ps.place(placer, failed -> sender.sendMessage(failed ? C.GREEN + "Placed the structure!" : C.RED + "Failed to place the structure!"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
sender().sendMessage(C.RED + "Failed to place the structure: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Decree(description = "Create a jigsaw piece")
|
||||
@@ -69,7 +78,7 @@ public class CommandJigsaw implements DecreeExecutor {
|
||||
@Param(description = "The object to use for this piece", customHandler = ObjectHandler.class)
|
||||
String object
|
||||
) {
|
||||
IrisObject o = IrisData.loadAnyObject(object);
|
||||
IrisObject o = IrisData.loadAnyObject(object, data());
|
||||
|
||||
if (object == null) {
|
||||
sender().sendMessage(C.RED + "Failed to find existing object");
|
||||
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.volmit.iris.core.commands;
|
||||
|
||||
import com.volmit.iris.Iris;
|
||||
import com.volmit.iris.core.IrisSettings;
|
||||
import com.volmit.iris.core.gui.PregeneratorJob;
|
||||
import com.volmit.iris.core.pregenerator.LazyPregenerator;
|
||||
import com.volmit.iris.core.pregenerator.PregenTask;
|
||||
import com.volmit.iris.core.tools.IrisToolbelt;
|
||||
import com.volmit.iris.util.decree.DecreeExecutor;
|
||||
import com.volmit.iris.util.decree.annotations.Decree;
|
||||
import com.volmit.iris.util.decree.annotations.Param;
|
||||
import com.volmit.iris.util.format.C;
|
||||
import com.volmit.iris.util.math.Position2;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.util.Vector;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
|
||||
@Decree(name = "lazypregen", aliases = "lazy", description = "Pregenerate your Iris worlds!")
|
||||
public class CommandLazyPregen implements DecreeExecutor {
|
||||
public String worldName;
|
||||
@Decree(description = "Pregenerate a world")
|
||||
public void start(
|
||||
@Param(description = "The radius of the pregen in blocks", aliases = "size")
|
||||
int radius,
|
||||
@Param(description = "The world to pregen", contextual = true)
|
||||
World world,
|
||||
@Param(aliases = "middle", description = "The center location of the pregen. Use \"me\" for your current location", defaultValue = "0,0")
|
||||
Vector center,
|
||||
@Param(aliases = "maxcpm", description = "Limit the chunks per minute the pregen will generate", defaultValue = "999999999")
|
||||
int cpm,
|
||||
@Param(aliases = "silent", description = "Silent generation", defaultValue = "false")
|
||||
boolean silent
|
||||
) {
|
||||
|
||||
worldName = world.getName();
|
||||
File worldDirectory = world.getWorldFolder();
|
||||
File lazyFile = new File(worldDirectory, "lazygen.json");
|
||||
if (lazyFile.exists()) {
|
||||
sender().sendMessage(C.BLUE + "Lazy pregen is already in progress");
|
||||
Iris.info(C.YELLOW + "Lazy pregen is already in progress");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (sender().isPlayer() && access() == null) {
|
||||
sender().sendMessage(C.RED + "The engine access for this world is null!");
|
||||
sender().sendMessage(C.RED + "Please make sure the world is loaded & the engine is initialized. Generate a new chunk, for example.");
|
||||
}
|
||||
|
||||
LazyPregenerator.LazyPregenJob pregenJob = LazyPregenerator.LazyPregenJob.builder()
|
||||
.world(worldName)
|
||||
.healingPosition(0)
|
||||
.healing(false)
|
||||
.chunksPerMinute(cpm)
|
||||
.radiusBlocks(radius)
|
||||
.position(0)
|
||||
.silent(silent)
|
||||
.build();
|
||||
|
||||
File lazyGenFile = new File(worldDirectory, "lazygen.json");
|
||||
LazyPregenerator pregenerator = new LazyPregenerator(pregenJob, lazyGenFile);
|
||||
pregenerator.start();
|
||||
|
||||
String msg = C.GREEN + "LazyPregen started in " + C.GOLD + worldName + C.GREEN + " of " + C.GOLD + (radius * 2) + C.GREEN + " by " + C.GOLD + (radius * 2) + C.GREEN + " blocks from " + C.GOLD + center.getX() + "," + center.getZ();
|
||||
sender().sendMessage(msg);
|
||||
Iris.info(msg);
|
||||
} catch (Throwable e) {
|
||||
sender().sendMessage(C.RED + "Epic fail. See console.");
|
||||
Iris.reportError(e);
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
@Decree(description = "Stop the active pregeneration task", aliases = "x")
|
||||
public void stop(
|
||||
@Param(aliases = "world", description = "The world to pause")
|
||||
World world
|
||||
) throws IOException {
|
||||
if (LazyPregenerator.getInstance() != null) {
|
||||
LazyPregenerator.getInstance().shutdownInstance(world);
|
||||
sender().sendMessage(C.LIGHT_PURPLE + "Closed lazygen instance for " + world.getName());
|
||||
} else {
|
||||
sender().sendMessage(C.YELLOW + "No active pregeneration tasks to stop");
|
||||
}
|
||||
}
|
||||
|
||||
@Decree(description = "Pause / continue the active pregeneration task", aliases = {"t", "resume", "unpause"})
|
||||
public void pause(
|
||||
@Param(aliases = "world", description = "The world to pause")
|
||||
World world
|
||||
) {
|
||||
if (LazyPregenerator.getInstance() != null) {
|
||||
LazyPregenerator.getInstance().setPausedLazy(world);
|
||||
sender().sendMessage(C.GREEN + "Paused/unpaused Lazy Pregen, now: " + (LazyPregenerator.getInstance().isPausedLazy(world) ? "Paused" : "Running") + ".");
|
||||
} else {
|
||||
sender().sendMessage(C.YELLOW + "No active Lazy Pregen tasks to pause/unpause.");
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
+68
-18
@@ -24,9 +24,12 @@ import com.volmit.iris.core.loader.IrisData;
|
||||
import com.volmit.iris.core.service.ObjectSVC;
|
||||
import com.volmit.iris.core.service.StudioSVC;
|
||||
import com.volmit.iris.core.service.WandSVC;
|
||||
import com.volmit.iris.core.tools.IrisConverter;
|
||||
import com.volmit.iris.engine.framework.Engine;
|
||||
import com.volmit.iris.engine.object.*;
|
||||
import com.volmit.iris.util.data.Cuboid;
|
||||
import com.volmit.iris.util.data.IrisCustomData;
|
||||
import com.volmit.iris.util.data.registry.Materials;
|
||||
import com.volmit.iris.util.decree.DecreeExecutor;
|
||||
import com.volmit.iris.util.decree.DecreeOrigin;
|
||||
import com.volmit.iris.util.decree.annotations.Decree;
|
||||
@@ -35,11 +38,8 @@ import com.volmit.iris.util.decree.specialhandlers.ObjectHandler;
|
||||
import com.volmit.iris.util.format.C;
|
||||
import com.volmit.iris.util.math.Direction;
|
||||
import com.volmit.iris.util.math.RNG;
|
||||
import com.volmit.iris.util.scheduling.Queue;
|
||||
import org.bukkit.*;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.block.BlockState;
|
||||
import org.bukkit.block.TileState;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.util.Vector;
|
||||
@@ -52,7 +52,7 @@ import java.util.*;
|
||||
@Decree(name = "object", aliases = "o", origin = DecreeOrigin.PLAYER, studio = true, description = "Iris object manipulation")
|
||||
public class CommandObject implements DecreeExecutor {
|
||||
|
||||
private static final Set<Material> skipBlocks = Set.of(Material.GRASS, Material.SNOW, Material.VINE, Material.TORCH, Material.DEAD_BUSH,
|
||||
private static final Set<Material> skipBlocks = Set.of(Materials.GRASS, Material.SNOW, Material.VINE, Material.TORCH, Material.DEAD_BUSH,
|
||||
Material.POPPY, Material.DANDELION);
|
||||
|
||||
public static IObjectPlacer createPlacer(World world, Map<Block, BlockData> futureBlockChanges) {
|
||||
@@ -77,7 +77,10 @@ public class CommandObject implements DecreeExecutor {
|
||||
|
||||
futureBlockChanges.put(block, block.getBlockData());
|
||||
|
||||
block.setBlockData(d);
|
||||
if (d instanceof IrisCustomData data) {
|
||||
block.setBlockData(data.getBase(), false);
|
||||
Iris.warn("Tried to place custom block at " + x + ", " + y + ", " + z + " which is not supported!");
|
||||
} else block.setBlockData(d, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -116,10 +119,18 @@ public class CommandObject implements DecreeExecutor {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setTile(int xx, int yy, int zz, TileData<? extends TileState> tile) {
|
||||
BlockState state = world.getBlockAt(xx, yy, zz).getState();
|
||||
tile.toBukkitTry(state);
|
||||
state.update();
|
||||
public void setTile(int xx, int yy, int zz, TileData tile) {
|
||||
tile.toBukkitTry(world.getBlockAt(xx, yy, zz));
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> void setData(int xx, int yy, int zz, T data) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> T getData(int xx, int yy, int zz, Class<T> t) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -134,11 +145,11 @@ public class CommandObject implements DecreeExecutor {
|
||||
@Param(description = "The object to analyze", customHandler = ObjectHandler.class)
|
||||
String object
|
||||
) {
|
||||
IrisObject o = IrisData.loadAnyObject(object);
|
||||
IrisObject o = IrisData.loadAnyObject(object, data());
|
||||
sender().sendMessage("Object Size: " + o.getW() + " * " + o.getH() + " * " + o.getD() + "");
|
||||
sender().sendMessage("Blocks Used: " + NumberFormat.getIntegerInstance().format(o.getBlocks().size()));
|
||||
|
||||
Queue<BlockData> queue = o.getBlocks().enqueueValues();
|
||||
var queue = o.getBlocks().values();
|
||||
Map<Material, Set<BlockData>> unsorted = new HashMap<>();
|
||||
Map<BlockData, Integer> amounts = new HashMap<>();
|
||||
Map<Material, Integer> materials = new HashMap<>();
|
||||
@@ -197,6 +208,30 @@ public class CommandObject implements DecreeExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
@Decree(description = "Shrink an object to its minimum size")
|
||||
public void shrink(@Param(description = "The object to shrink", customHandler = ObjectHandler.class) String object) {
|
||||
IrisObject o = IrisData.loadAnyObject(object, data());
|
||||
sender().sendMessage("Current Object Size: " + o.getW() + " * " + o.getH() + " * " + o.getD());
|
||||
o.shrinkwrap();
|
||||
sender().sendMessage("New Object Size: " + o.getW() + " * " + o.getH() + " * " + o.getD());
|
||||
try {
|
||||
o.write(o.getLoadFile());
|
||||
} catch (IOException e) {
|
||||
sender().sendMessage("Failed to save object " + o.getLoadFile() + ": " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
@Decree(description = "Convert .schem files in the 'convert' folder to .iob files.")
|
||||
public void convert () {
|
||||
try {
|
||||
IrisConverter.convertSchematics(sender());
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Decree(description = "Get a powder that reveals objects", studio = true, aliases = "d")
|
||||
public void dust() {
|
||||
player().getInventory().addItem(WandSVC.createDust());
|
||||
@@ -215,7 +250,8 @@ public class CommandObject implements DecreeExecutor {
|
||||
|
||||
|
||||
Location[] b = WandSVC.getCuboid(player());
|
||||
if (b == null) {
|
||||
if (b == null || b[0] == null || b[1] == null) {
|
||||
sender().sendMessage("No area selected.");
|
||||
return;
|
||||
}
|
||||
Location a1 = b[0].clone();
|
||||
@@ -298,7 +334,7 @@ public class CommandObject implements DecreeExecutor {
|
||||
// @Param(description = "The scale interpolator to use", defaultValue = "none")
|
||||
// IrisObjectPlacementScaleInterpolator interpolator
|
||||
) {
|
||||
IrisObject o = IrisData.loadAnyObject(object);
|
||||
IrisObject o = IrisData.loadAnyObject(object, data());
|
||||
double maxScale = Double.max(10 - o.getBlocks().size() / 10000d, 1);
|
||||
if (scale > maxScale) {
|
||||
sender().sendMessage(C.YELLOW + "Indicated scale exceeds maximum. Downscaled to maximum: " + maxScale);
|
||||
@@ -341,7 +377,7 @@ public class CommandObject implements DecreeExecutor {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
sender().sendMessage("Placed " + object);
|
||||
sender().sendMessage(C.IRIS + "Placed " + object);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -352,9 +388,11 @@ public class CommandObject implements DecreeExecutor {
|
||||
@Param(description = "The file to store it in, can use / for subfolders")
|
||||
String name,
|
||||
@Param(description = "Overwrite existing object files", defaultValue = "false", aliases = "force")
|
||||
boolean overwrite
|
||||
boolean overwrite,
|
||||
@Param(description = "Use legacy TileState serialization if possible", defaultValue = "true")
|
||||
boolean legacy
|
||||
) {
|
||||
IrisObject o = WandSVC.createSchematic(player());
|
||||
IrisObject o = WandSVC.createSchematic(player(), legacy);
|
||||
|
||||
if (o == null) {
|
||||
sender().sendMessage(C.YELLOW + "You need to hold your wand!");
|
||||
@@ -368,7 +406,7 @@ public class CommandObject implements DecreeExecutor {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
o.write(file);
|
||||
o.write(file, sender());
|
||||
} catch (IOException e) {
|
||||
sender().sendMessage(C.RED + "Failed to save object because of an IOException: " + e.getMessage());
|
||||
Iris.reportError(e);
|
||||
@@ -389,6 +427,10 @@ public class CommandObject implements DecreeExecutor {
|
||||
}
|
||||
|
||||
Location[] b = WandSVC.getCuboid(player());
|
||||
if (b == null || b[0] == null || b[1] == null) {
|
||||
sender().sendMessage("No area selected.");
|
||||
return;
|
||||
}
|
||||
Location a1 = b[0].clone();
|
||||
Location a2 = b[1].clone();
|
||||
Direction d = Direction.closest(player().getLocation().getDirection()).reverse();
|
||||
@@ -413,7 +455,7 @@ public class CommandObject implements DecreeExecutor {
|
||||
ObjectSVC service = Iris.service(ObjectSVC.class);
|
||||
int actualReverts = Math.min(service.getUndos().size(), amount);
|
||||
service.revertChanges(actualReverts);
|
||||
sender().sendMessage("Reverted " + actualReverts + " pastes!");
|
||||
sender().sendMessage(C.BLUE + "Reverted " + actualReverts + C.BLUE +" pastes!");
|
||||
}
|
||||
|
||||
@Decree(description = "Gets an object wand and grabs the current WorldEdit selection.", aliases = "we", origin = DecreeOrigin.PLAYER, studio = true)
|
||||
@@ -449,6 +491,10 @@ public class CommandObject implements DecreeExecutor {
|
||||
}
|
||||
|
||||
Location[] b = WandSVC.getCuboid(player());
|
||||
if (b == null || b[0] == null || b[1] == null) {
|
||||
sender().sendMessage("No area selected.");
|
||||
return;
|
||||
}
|
||||
Location a1 = b[0].clone();
|
||||
Location a2 = b[1].clone();
|
||||
Location a1x = b[0].clone();
|
||||
@@ -496,6 +542,10 @@ public class CommandObject implements DecreeExecutor {
|
||||
}
|
||||
|
||||
Location[] b = WandSVC.getCuboid(player());
|
||||
if (b == null || b[0] == null || b[1] == null) {
|
||||
sender().sendMessage("No area selected.");
|
||||
return;
|
||||
}
|
||||
b[0].add(new Vector(0, 1, 0));
|
||||
b[1].add(new Vector(0, 1, 0));
|
||||
Location a1 = b[0].clone();
|
||||
+9
-7
@@ -39,20 +39,22 @@ public class CommandPregen implements DecreeExecutor {
|
||||
@Param(description = "The world to pregen", contextual = true)
|
||||
World world,
|
||||
@Param(aliases = "middle", description = "The center location of the pregen. Use \"me\" for your current location", defaultValue = "0,0")
|
||||
Vector center
|
||||
) {
|
||||
Vector center,
|
||||
@Param(description = "Open the Iris pregen gui", defaultValue = "true")
|
||||
boolean gui
|
||||
) {
|
||||
try {
|
||||
if (sender().isPlayer() && access() == null) {
|
||||
sender().sendMessage(C.RED + "The engine access for this world is null!");
|
||||
sender().sendMessage(C.RED + "Please make sure the world is loaded & the engine is initialized. Generate a new chunk, for example.");
|
||||
}
|
||||
radius = Math.max(radius, 1024);
|
||||
int w = (radius >> 9 + 1) * 2;
|
||||
IrisToolbelt.pregenerate(PregenTask
|
||||
.builder()
|
||||
.center(new Position2(center.getBlockX() >> 9, center.getBlockZ() >> 9))
|
||||
.width(w)
|
||||
.height(w)
|
||||
.center(new Position2(center.getBlockX(), center.getBlockZ()))
|
||||
.gui(gui)
|
||||
.radiusX(radius)
|
||||
.radiusZ(radius)
|
||||
.build(), world);
|
||||
String msg = C.GREEN + "Pregen started in " + C.GOLD + world.getName() + C.GREEN + " of " + C.GOLD + (radius * 2) + C.GREEN + " by " + C.GOLD + (radius * 2) + C.GREEN + " blocks from " + C.GOLD + center.getX() + "," + center.getZ();
|
||||
sender().sendMessage(msg);
|
||||
@@ -67,7 +69,7 @@ public class CommandPregen implements DecreeExecutor {
|
||||
@Decree(description = "Stop the active pregeneration task", aliases = "x")
|
||||
public void stop() {
|
||||
if (PregeneratorJob.shutdownInstance()) {
|
||||
sender().sendMessage(C.GREEN + "Stopped pregeneration task");
|
||||
Iris.info( C.BLUE + "Finishing up mca region...");
|
||||
} else {
|
||||
sender().sendMessage(C.YELLOW + "No active pregeneration tasks to stop");
|
||||
}
|
||||
File renamed without changes.
+270
-29
@@ -20,8 +20,10 @@ package com.volmit.iris.core.commands;
|
||||
|
||||
import com.volmit.iris.Iris;
|
||||
import com.volmit.iris.core.IrisSettings;
|
||||
import com.volmit.iris.core.gui.GuiHost;
|
||||
import com.volmit.iris.core.gui.MapWindow;
|
||||
import com.volmit.iris.core.gui.NoiseExplorerGUI;
|
||||
import com.volmit.iris.core.gui.VisionGUI;
|
||||
import com.volmit.iris.core.gui.PaletteExplorerStart;
|
||||
import com.volmit.iris.core.loader.IrisData;
|
||||
import com.volmit.iris.core.project.IrisProject;
|
||||
import com.volmit.iris.core.service.ConversionSVC;
|
||||
@@ -29,28 +31,35 @@ import com.volmit.iris.core.service.StudioSVC;
|
||||
import com.volmit.iris.core.tools.IrisToolbelt;
|
||||
import com.volmit.iris.engine.framework.Engine;
|
||||
import com.volmit.iris.engine.object.*;
|
||||
import com.volmit.iris.engine.platform.PlatformChunkGenerator;
|
||||
import com.volmit.iris.util.collection.KList;
|
||||
import com.volmit.iris.util.collection.KMap;
|
||||
import com.volmit.iris.util.collection.KSet;
|
||||
import com.volmit.iris.util.decree.DecreeContext;
|
||||
import com.volmit.iris.util.decree.DecreeExecutor;
|
||||
import com.volmit.iris.util.decree.DecreeOrigin;
|
||||
import com.volmit.iris.util.decree.annotations.Decree;
|
||||
import com.volmit.iris.util.decree.annotations.Param;
|
||||
import com.volmit.iris.util.format.C;
|
||||
import com.volmit.iris.util.format.Form;
|
||||
import com.volmit.iris.util.function.Function2;
|
||||
import com.volmit.iris.util.function.NoiseProvider;
|
||||
import com.volmit.iris.util.interpolation.InterpolationMethod;
|
||||
import com.volmit.iris.util.io.IO;
|
||||
import com.volmit.iris.util.json.JSONArray;
|
||||
import com.volmit.iris.util.json.JSONObject;
|
||||
import com.volmit.iris.util.mantle.MantleChunk;
|
||||
import com.volmit.iris.util.math.M;
|
||||
import com.volmit.iris.util.math.Position2;
|
||||
import com.volmit.iris.util.math.RNG;
|
||||
import com.volmit.iris.util.math.Spiraler;
|
||||
import com.volmit.iris.util.noise.CNG;
|
||||
import com.volmit.iris.util.parallel.MultiBurst;
|
||||
import com.volmit.iris.util.parallel.SyncExecutor;
|
||||
import com.volmit.iris.util.plugin.VolmitSender;
|
||||
import com.volmit.iris.util.scheduling.J;
|
||||
import com.volmit.iris.util.scheduling.O;
|
||||
import com.volmit.iris.util.scheduling.PrecisionStopwatch;
|
||||
import io.papermc.lib.PaperLib;
|
||||
import com.volmit.iris.util.scheduling.jobs.ParallelRadiusJob;
|
||||
import org.bukkit.*;
|
||||
import org.bukkit.event.inventory.InventoryType;
|
||||
import org.bukkit.inventory.Inventory;
|
||||
@@ -65,31 +74,36 @@ import java.nio.file.Files;
|
||||
import java.nio.file.attribute.FileTime;
|
||||
import java.time.Duration;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Arrays;
|
||||
import java.util.Date;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
@Decree(name = "studio", aliases = {"std", "s"}, description = "Studio Commands", studio = true)
|
||||
public class CommandStudio implements DecreeExecutor {
|
||||
private CommandFind find;
|
||||
private CommandEdit edit;
|
||||
//private CommandDeepSearch deepSearch;
|
||||
|
||||
public static String hrf(Duration duration) {
|
||||
return duration.toString().substring(2).replaceAll("(\\d[HMS])(?!$)", "$1 ").toLowerCase();
|
||||
}
|
||||
|
||||
//TODO fix pack trimming
|
||||
@Decree(description = "Download a project.", aliases = "dl")
|
||||
public void download(
|
||||
@Param(name = "pack", description = "The pack to download", defaultValue = "overworld", aliases = "project")
|
||||
String pack,
|
||||
@Param(name = "branch", description = "The branch to download from", defaultValue = "master")
|
||||
String branch,
|
||||
@Param(name = "trim", description = "Whether or not to download a trimmed version (do not enable when editing)", defaultValue = "false")
|
||||
boolean trim,
|
||||
//@Param(name = "trim", description = "Whether or not to download a trimmed version (do not enable when editing)", defaultValue = "false")
|
||||
//boolean trim,
|
||||
@Param(name = "overwrite", description = "Whether or not to overwrite the pack with the downloaded one", aliases = "force", defaultValue = "false")
|
||||
boolean overwrite
|
||||
) {
|
||||
new CommandIris().download(pack, branch, trim, overwrite);
|
||||
new CommandIris().download(pack, branch, overwrite);
|
||||
}
|
||||
|
||||
@Decree(description = "Open a new studio world", aliases = "o", sync = true)
|
||||
@@ -143,12 +157,87 @@ public class CommandStudio implements DecreeExecutor {
|
||||
sender().sendMessage(C.GREEN + "The \"" + dimension.getName() + "\" pack has version: " + dimension.getVersion());
|
||||
}
|
||||
|
||||
@Decree(name = "regen", description = "Regenerate nearby chunks.", aliases = "rg", sync = true, origin = DecreeOrigin.PLAYER)
|
||||
public void regen(
|
||||
@Param(name = "radius", description = "The radius of nearby cunks", defaultValue = "5")
|
||||
int radius
|
||||
) {
|
||||
World world = player().getWorld();
|
||||
if (!IrisToolbelt.isIrisWorld(world)) {
|
||||
sender().sendMessage(C.RED + "You must be in an Iris World to use regen!");
|
||||
}
|
||||
|
||||
VolmitSender sender = sender();
|
||||
var loc = player().getLocation().clone();
|
||||
|
||||
J.a(() -> {
|
||||
PlatformChunkGenerator plat = IrisToolbelt.access(world);
|
||||
Engine engine = plat.getEngine();
|
||||
DecreeContext.touch(sender);
|
||||
try (SyncExecutor executor = new SyncExecutor(20);
|
||||
var service = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors())
|
||||
) {
|
||||
int x = loc.getBlockX() >> 4;
|
||||
int z = loc.getBlockZ() >> 4;
|
||||
|
||||
int rad = engine.getMantle().getRadius();
|
||||
var mantle = engine.getMantle().getMantle();
|
||||
var chunkMap = new KMap<Position2, MantleChunk>();
|
||||
ParallelRadiusJob prep = new ParallelRadiusJob(Integer.MAX_VALUE, service) {
|
||||
@Override
|
||||
protected void execute(int rX, int rZ) {
|
||||
if (Math.abs(rX) <= radius && Math.abs(rZ) <= radius) {
|
||||
mantle.deleteChunk(rX + x, rZ + z);
|
||||
return;
|
||||
}
|
||||
rX += x;
|
||||
rZ += z;
|
||||
chunkMap.put(new Position2(rX, rZ), mantle.getChunk(rX, rZ));
|
||||
mantle.deleteChunk(rX, rZ);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "Preparing Mantle";
|
||||
}
|
||||
}.retarget(radius + rad, 0, 0);
|
||||
CountDownLatch pLatch = new CountDownLatch(1);
|
||||
prep.execute(sender(), pLatch::countDown);
|
||||
pLatch.await();
|
||||
|
||||
|
||||
ParallelRadiusJob job = new ParallelRadiusJob(Integer.MAX_VALUE, service) {
|
||||
@Override
|
||||
protected void execute(int x, int z) {
|
||||
plat.injectChunkReplacement(world, x, z, executor);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "Regenerating";
|
||||
}
|
||||
}.retarget(radius, x, z);
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
job.execute(sender(), latch::countDown);
|
||||
latch.await();
|
||||
|
||||
chunkMap.forEach((pos, chunk) ->
|
||||
mantle.getChunk(pos.getX(), pos.getZ()).copyFrom(chunk));
|
||||
} catch (Throwable e) {
|
||||
sender().sendMessage("Error while regenerating chunks");
|
||||
e.printStackTrace();
|
||||
} finally {
|
||||
DecreeContext.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Decree(description = "Convert objects in the \"convert\" folder")
|
||||
public void convert() {
|
||||
Iris.service(ConversionSVC.class).check(sender());
|
||||
//IrisConverter.convertSchematics(sender());
|
||||
}
|
||||
|
||||
|
||||
@Decree(description = "Execute a script", aliases = "run", origin = DecreeOrigin.PLAYER)
|
||||
public void execute(
|
||||
@Param(description = "The script to run")
|
||||
@@ -157,11 +246,47 @@ public class CommandStudio implements DecreeExecutor {
|
||||
engine().getExecution().execute(script.getLoadKey());
|
||||
}
|
||||
|
||||
@Decree(description = "Open the noise explorer (External GUI)", aliases = {"nmap", "n"})
|
||||
@Decree(description = "Open the map on its noise explorer (External GUI)", aliases = {"nmap", "n"})
|
||||
public void noise() {
|
||||
if (noGUI()) return;
|
||||
sender().sendMessage(C.GREEN + "Opening Noise Explorer!");
|
||||
NoiseExplorerGUI.launch();
|
||||
Engine playerEngine = engine();
|
||||
Engine engine = playerEngine != null ? playerEngine : GuiHost.findActiveEngine();
|
||||
World world = engine == null ? null : engine.getWorld().realWorld();
|
||||
if (world == null) {
|
||||
sender().sendMessage(C.RED + "Open a studio or stand in an Iris world to explore its noise!");
|
||||
return;
|
||||
}
|
||||
Location center = playerEngine != null ? player().getLocation() : null;
|
||||
sender().sendMessage(C.GREEN + "Opening the map on its noise!");
|
||||
MapWindow.openNoise(new PaletteExplorerStart(
|
||||
engine,
|
||||
world.getUID(),
|
||||
sender().isPlayer() ? player().getUniqueId() : null,
|
||||
null,
|
||||
center == null ? 0D : center.getX(),
|
||||
center == null ? 0D : center.getZ()
|
||||
));
|
||||
}
|
||||
|
||||
@Decree(description = "Preview the palettes around you (External GUI)", aliases = "pal", origin = DecreeOrigin.PLAYER)
|
||||
public void palette() {
|
||||
if (noGUI()) return;
|
||||
Engine engine = engine();
|
||||
if (engine == null) {
|
||||
sender().sendMessage(C.RED + "Stand in an Iris world to preview its palettes!");
|
||||
return;
|
||||
}
|
||||
Location location = player().getLocation();
|
||||
IrisBiome surfaceBiome = engine.getSurfaceBiome(location.getBlockX(), location.getBlockZ());
|
||||
sender().sendMessage(C.GREEN + "Opening the map on its palettes!");
|
||||
MapWindow.openPalettes(new PaletteExplorerStart(
|
||||
engine,
|
||||
location.getWorld().getUID(),
|
||||
player().getUniqueId(),
|
||||
surfaceBiome == null ? null : surfaceBiome.getLoadKey(),
|
||||
location.getX(),
|
||||
location.getZ()
|
||||
));
|
||||
}
|
||||
|
||||
@Decree(description = "Charges all spawners in the area", aliases = "zzt", origin = DecreeOrigin.PLAYER)
|
||||
@@ -183,16 +308,7 @@ public class CommandStudio implements DecreeExecutor {
|
||||
) {
|
||||
if (noGUI()) return;
|
||||
sender().sendMessage(C.GREEN + "Opening Noise Explorer!");
|
||||
|
||||
Supplier<Function2<Double, Double, Double>> l = () -> {
|
||||
|
||||
if (generator == null) {
|
||||
return (x, z) -> 0D;
|
||||
}
|
||||
|
||||
return (x, z) -> generator.getHeight(x, z, new RNG(seed).nextParallelRNG(3245).lmax());
|
||||
};
|
||||
NoiseExplorerGUI.launch(l, "Custom Generator");
|
||||
NoiseExplorerGUI.launchGeneratorKey(engine(), generator == null ? null : generator.getLoadKey(), generator, seed);
|
||||
}
|
||||
|
||||
@Decree(description = "Hotload a studio", aliases = {"reload", "h"})
|
||||
@@ -218,7 +334,7 @@ public class CommandStudio implements DecreeExecutor {
|
||||
Inventory inv = Bukkit.createInventory(null, 27 * 2);
|
||||
|
||||
try {
|
||||
engine().addItems(true, inv, RNG.r, tables, InventorySlotType.STORAGE, player().getLocation().getBlockX(), player().getLocation().getBlockY(), player().getLocation().getBlockZ(), 1);
|
||||
engine().addItems(true, inv, RNG.r, tables, InventorySlotType.STORAGE, player().getWorld(), player().getLocation().getBlockX(), player().getLocation().getBlockY(), player().getLocation().getBlockZ(), 1);
|
||||
} catch (Throwable e) {
|
||||
Iris.reportError(e);
|
||||
sender().sendMessage(C.RED + "Cannot add items to virtual inventory because of: " + e.getMessage());
|
||||
@@ -229,11 +345,15 @@ public class CommandStudio implements DecreeExecutor {
|
||||
O<Integer> ta = new O<>();
|
||||
ta.set(-1);
|
||||
|
||||
var sender = sender();
|
||||
var player = player();
|
||||
var engine = engine();
|
||||
|
||||
ta.set(Bukkit.getScheduler().scheduleSyncRepeatingTask(Iris.instance, () ->
|
||||
{
|
||||
if (!player().getOpenInventory().getType().equals(InventoryType.CHEST)) {
|
||||
if (!player.getOpenInventory().getType().equals(InventoryType.CHEST)) {
|
||||
Bukkit.getScheduler().cancelTask(ta.get());
|
||||
sender().sendMessage(C.GREEN + "Opened inventory!");
|
||||
sender.sendMessage(C.GREEN + "Opened inventory!");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -241,13 +361,116 @@ public class CommandStudio implements DecreeExecutor {
|
||||
inv.clear();
|
||||
}
|
||||
|
||||
engine().addItems(true, inv, new RNG(RNG.r.imax()), tables, InventorySlotType.STORAGE, player().getLocation().getBlockX(), player().getLocation().getBlockY(), player().getLocation().getBlockZ(), 1);
|
||||
engine.addItems(true, inv, new RNG(RNG.r.imax()), tables, InventorySlotType.STORAGE, player.getWorld(), player.getLocation().getBlockX(), player.getLocation().getBlockY(), player.getLocation().getBlockZ(), 1);
|
||||
}, 0, fast ? 5 : 35));
|
||||
|
||||
sender().sendMessage(C.GREEN + "Opening inventory now!");
|
||||
player().openInventory(inv);
|
||||
}
|
||||
|
||||
@Decree(description = "Calculate the chance for each region to generate", origin = DecreeOrigin.PLAYER)
|
||||
public void regions(@Param(description = "The radius in chunks", defaultValue = "500") int radius) {
|
||||
var engine = engine();
|
||||
if (engine == null) {
|
||||
sender().sendMessage(C.RED + "Only works in an Iris world!");
|
||||
return;
|
||||
}
|
||||
var sender = sender();
|
||||
var player = player();
|
||||
Thread.ofVirtual()
|
||||
.start(() -> {
|
||||
int d = radius * 2;
|
||||
KMap<String, AtomicInteger> data = new KMap<>();
|
||||
engine.getDimension().getRegions().forEach(key -> data.put(key, new AtomicInteger(0)));
|
||||
var multiBurst = new MultiBurst("Region Sampler");
|
||||
var executor = multiBurst.burst(radius * radius);
|
||||
sender.sendMessage(C.GRAY + "Generating data...");
|
||||
var loc = player.getLocation();
|
||||
int totalTasks = d * d;
|
||||
AtomicInteger completedTasks = new AtomicInteger(0);
|
||||
int c = J.ar(() -> sender.sendProgress((double) completedTasks.get() / totalTasks, "Finding regions"), 0);
|
||||
new Spiraler(d, d, (x, z) -> executor.queue(() -> {
|
||||
var region = engine.getRegion((x << 4) + 8, (z << 4) + 8);
|
||||
data.computeIfAbsent(region.getLoadKey(), (k) -> new AtomicInteger(0))
|
||||
.incrementAndGet();
|
||||
completedTasks.incrementAndGet();
|
||||
})).setOffset(loc.getBlockX(), loc.getBlockZ()).drain();
|
||||
executor.complete();
|
||||
multiBurst.close();
|
||||
J.car(c);
|
||||
|
||||
sender.sendMessage(C.GREEN + "Done!");
|
||||
var loader = engine.getData().getRegionLoader();
|
||||
data.forEach((k, v) -> sender.sendMessage(C.GREEN + k + ": " + loader.load(k).getRarity() + " / " + Form.f((double) v.get() / totalTasks * 100, 2) + "%"));
|
||||
});
|
||||
}
|
||||
|
||||
@Decree(description = "Get all structures in a radius of chunks", aliases = "dist", origin = DecreeOrigin.PLAYER)
|
||||
public void distances(@Param(description = "The radius in chunks") int radius) {
|
||||
var engine = engine();
|
||||
if (engine == null) {
|
||||
sender().sendMessage(C.RED + "Only works in an Iris world!");
|
||||
return;
|
||||
}
|
||||
var sender = sender();
|
||||
int d = radius * 2;
|
||||
KMap<String, KList<Position2>> data = new KMap<>();
|
||||
var multiBurst = new MultiBurst("Distance Sampler");
|
||||
var executor = multiBurst.burst(radius * radius);
|
||||
|
||||
sender.sendMessage(C.GRAY + "Generating data...");
|
||||
var loc = player().getLocation();
|
||||
int totalTasks = d * d;
|
||||
AtomicInteger completedTasks = new AtomicInteger(0);
|
||||
int c = J.ar(() -> {
|
||||
sender.sendProgress((double) completedTasks.get() / totalTasks, "Finding structures");
|
||||
}, 0);
|
||||
|
||||
new Spiraler(d, d, (x, z) -> executor.queue(() -> {
|
||||
var struct = engine.getStructureAt(x, z);
|
||||
if (struct != null) {
|
||||
data.computeIfAbsent(struct.getLoadKey(), (k) -> new KList<>()).add(new Position2(x, z));
|
||||
}
|
||||
completedTasks.incrementAndGet();
|
||||
})).setOffset(loc.getBlockX(), loc.getBlockZ()).drain();
|
||||
|
||||
executor.complete();
|
||||
multiBurst.close();
|
||||
J.car(c);
|
||||
|
||||
for (var key : data.keySet()) {
|
||||
var list = data.get(key);
|
||||
KList<Long> distances = new KList<>(list.size() - 1);
|
||||
for (int i = 0; i < list.size(); i++) {
|
||||
var pos = list.get(i);
|
||||
double dist = Integer.MAX_VALUE;
|
||||
for (var p : list) {
|
||||
if (p.equals(pos)) continue;
|
||||
dist = Math.min(dist, Math.sqrt(Math.pow(pos.getX() - p.getX(), 2) + Math.pow(pos.getZ() - p.getZ(), 2)));
|
||||
}
|
||||
if (dist == Integer.MAX_VALUE) continue;
|
||||
distances.add(Math.round(dist * 16));
|
||||
}
|
||||
long[] array = new long[distances.size()];
|
||||
for (int i = 0; i < distances.size(); i++) {
|
||||
array[i] = distances.get(i);
|
||||
}
|
||||
Arrays.sort(array);
|
||||
long min = array.length > 0 ? array[0] : 0;
|
||||
long max = array.length > 0 ? array[array.length - 1] : 0;
|
||||
long sum = Arrays.stream(array).sum();
|
||||
long avg = array.length > 0 ? Math.round(sum / (double) array.length) : 0;
|
||||
String msg = "%s: %s => min: %s/max: %s -> avg: %s".formatted(key, list.size(), min, max, avg);
|
||||
sender.sendMessage(msg);
|
||||
}
|
||||
if (data.isEmpty()) {
|
||||
sender.sendMessage(C.RED + "No data found!");
|
||||
} else {
|
||||
sender.sendMessage(C.GREEN + "Done!");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Decree(description = "Render a world map (External GUI)", aliases = "render")
|
||||
public void map(
|
||||
@Param(name = "world", description = "The world to open the generator for", contextual = true)
|
||||
@@ -260,7 +483,15 @@ public class CommandStudio implements DecreeExecutor {
|
||||
return;
|
||||
}
|
||||
|
||||
VisionGUI.launch(IrisToolbelt.access(world).getEngine(), 0);
|
||||
Location center = sender().isPlayer() && player().getWorld().equals(world) ? player().getLocation() : null;
|
||||
MapWindow.openMap(new PaletteExplorerStart(
|
||||
IrisToolbelt.access(world).getEngine(),
|
||||
world.getUID(),
|
||||
sender().isPlayer() ? player().getUniqueId() : null,
|
||||
null,
|
||||
center == null ? 0D : center.getX(),
|
||||
center == null ? 0D : center.getZ()
|
||||
));
|
||||
sender().sendMessage(C.GREEN + "Opening map!");
|
||||
}
|
||||
|
||||
@@ -281,6 +512,7 @@ public class CommandStudio implements DecreeExecutor {
|
||||
@Param(description = "The dimension to profile", contextual = true, defaultValue = "default")
|
||||
IrisDimension dimension
|
||||
) {
|
||||
// Todo: Make this more accurate
|
||||
File pack = dimension.getLoadFile().getParentFile().getParentFile();
|
||||
File report = Iris.instance.getDataFile("profile.txt");
|
||||
IrisProject project = new IrisProject(pack);
|
||||
@@ -490,8 +722,14 @@ public class CommandStudio implements DecreeExecutor {
|
||||
}
|
||||
|
||||
sender().sendMessage(C.GREEN + "Sending you to the studio world!");
|
||||
player().teleport(Iris.service(StudioSVC.class).getActiveProject().getActiveProvider().getTarget().getWorld().spawnLocation());
|
||||
player().setGameMode(GameMode.SPECTATOR);
|
||||
var player = player();
|
||||
player.teleportAsync(Iris.service(StudioSVC.class)
|
||||
.getActiveProject()
|
||||
.getActiveProvider()
|
||||
.getTarget()
|
||||
.getWorld()
|
||||
.spawnLocation()
|
||||
).thenRun(() -> player.setGameMode(GameMode.SPECTATOR));
|
||||
}
|
||||
|
||||
@Decree(description = "Update your dimension projects VSCode workspace")
|
||||
@@ -544,7 +782,6 @@ public class CommandStudio implements DecreeExecutor {
|
||||
pw.println("Iris Version: " + Iris.instance.getDescription().getVersion());
|
||||
pw.println("Bukkit Version: " + Bukkit.getBukkitVersion());
|
||||
pw.println("MC Version: " + Bukkit.getVersion());
|
||||
pw.println("PaperSpigot: " + (PaperLib.isPaper() ? "Yup!" : "Nope!"));
|
||||
pw.println("Report Captured At: " + new Date());
|
||||
pw.println("Chunks: (" + chunks.size() + "): ");
|
||||
|
||||
@@ -675,6 +912,10 @@ public class CommandStudio implements DecreeExecutor {
|
||||
sender().sendMessage(C.RED + "You must have server launched GUIs enabled in the settings!");
|
||||
return true;
|
||||
}
|
||||
if (!GuiHost.isAvailable()) {
|
||||
sender().sendMessage(C.RED + "This server runs without a desktop, so it cannot open windows!");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.volmit.iris.core.commands;
|
||||
|
||||
import com.volmit.iris.Iris;
|
||||
import com.volmit.iris.core.pregenerator.LazyPregenerator;
|
||||
import com.volmit.iris.core.pregenerator.TurboPregenerator;
|
||||
import com.volmit.iris.core.pregenerator.TurboPregenerator;
|
||||
import com.volmit.iris.util.decree.DecreeExecutor;
|
||||
import com.volmit.iris.util.decree.annotations.Decree;
|
||||
import com.volmit.iris.util.decree.annotations.Param;
|
||||
import com.volmit.iris.util.format.C;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.util.Vector;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
|
||||
@Decree(name = "turbopregen", aliases = "turbo", description = "Pregenerate your Iris worlds!")
|
||||
public class CommandTurboPregen implements DecreeExecutor {
|
||||
public String worldName;
|
||||
@Decree(description = "Pregenerate a world")
|
||||
public void start(
|
||||
@Param(description = "The radius of the pregen in blocks", aliases = "size")
|
||||
int radius,
|
||||
@Param(description = "The world to pregen", contextual = true)
|
||||
World world,
|
||||
@Param(aliases = "middle", description = "The center location of the pregen. Use \"me\" for your current location", defaultValue = "0,0")
|
||||
Vector center
|
||||
) {
|
||||
|
||||
worldName = world.getName();
|
||||
File worldDirectory = world.getWorldFolder();
|
||||
File TurboFile = new File(worldDirectory, "turbogen.json");
|
||||
if (TurboFile.exists()) {
|
||||
if (TurboPregenerator.getInstance() != null) {
|
||||
sender().sendMessage(C.BLUE + "Turbo pregen is already in progress");
|
||||
Iris.info(C.YELLOW + "Turbo pregen is already in progress");
|
||||
return;
|
||||
} else {
|
||||
try {
|
||||
TurboFile.delete();
|
||||
} catch (Exception e){
|
||||
Iris.error("Failed to delete the old instance file of Turbo Pregen!");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (sender().isPlayer() && access() == null) {
|
||||
sender().sendMessage(C.RED + "The engine access for this world is null!");
|
||||
sender().sendMessage(C.RED + "Please make sure the world is loaded & the engine is initialized. Generate a new chunk, for example.");
|
||||
}
|
||||
|
||||
TurboPregenerator.TurboPregenJob pregenJob = TurboPregenerator.TurboPregenJob.builder()
|
||||
.world(worldName)
|
||||
.radiusBlocks(radius)
|
||||
.position(0)
|
||||
.build();
|
||||
|
||||
File TurboGenFile = new File(worldDirectory, "turbogen.json");
|
||||
TurboPregenerator pregenerator = new TurboPregenerator(pregenJob, TurboGenFile);
|
||||
pregenerator.start();
|
||||
|
||||
String msg = C.GREEN + "TurboPregen started in " + C.GOLD + worldName + C.GREEN + " of " + C.GOLD + (radius * 2) + C.GREEN + " by " + C.GOLD + (radius * 2) + C.GREEN + " blocks from " + C.GOLD + center.getX() + "," + center.getZ();
|
||||
sender().sendMessage(msg);
|
||||
Iris.info(msg);
|
||||
} catch (Throwable e) {
|
||||
sender().sendMessage(C.RED + "Epic fail. See console.");
|
||||
Iris.reportError(e);
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
@Decree(description = "Stop the active pregeneration task", aliases = "x")
|
||||
public void stop(@Param(aliases = "world", description = "The world to pause") World world) throws IOException {
|
||||
TurboPregenerator turboPregenInstance = TurboPregenerator.getInstance();
|
||||
File worldDirectory = world.getWorldFolder();
|
||||
File turboFile = new File(worldDirectory, "turbogen.json");
|
||||
|
||||
if (turboPregenInstance != null) {
|
||||
turboPregenInstance.shutdownInstance(world);
|
||||
sender().sendMessage(C.LIGHT_PURPLE + "Closed Turbogen instance for " + world.getName());
|
||||
} else if (turboFile.exists() && turboFile.delete()) {
|
||||
sender().sendMessage(C.LIGHT_PURPLE + "Closed Turbogen instance for " + world.getName());
|
||||
} else if (turboFile.exists()) {
|
||||
Iris.error("Failed to delete the old instance file of Turbo Pregen!");
|
||||
} else {
|
||||
sender().sendMessage(C.YELLOW + "No active pregeneration tasks to stop");
|
||||
}
|
||||
}
|
||||
|
||||
@Decree(description = "Pause / continue the active pregeneration task", aliases = {"t", "resume", "unpause"})
|
||||
public void pause(
|
||||
@Param(aliases = "world", description = "The world to pause")
|
||||
World world
|
||||
) {
|
||||
if (TurboPregenerator.getInstance() != null) {
|
||||
TurboPregenerator.setPausedTurbo(world);
|
||||
sender().sendMessage(C.GREEN + "Paused/unpaused Turbo Pregen, now: " + (TurboPregenerator.isPausedTurbo(world) ? "Paused" : "Running") + ".");
|
||||
} else {
|
||||
File worldDirectory = world.getWorldFolder();
|
||||
File TurboFile = new File(worldDirectory, "turbogen.json");
|
||||
if (TurboFile.exists()){
|
||||
TurboPregenerator.loadTurboGenerator(world.getName());
|
||||
sender().sendMessage(C.YELLOW + "Started Turbo Pregen back up!");
|
||||
} else {
|
||||
sender().sendMessage(C.YELLOW + "No active Turbo Pregen tasks to pause/unpause.");
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.volmit.iris.core.commands;
|
||||
|
||||
import lombok.Synchronized;
|
||||
import org.bukkit.World;
|
||||
|
||||
import com.volmit.iris.Iris;
|
||||
import com.volmit.iris.core.pregenerator.ChunkUpdater;
|
||||
import com.volmit.iris.core.tools.IrisToolbelt;
|
||||
import com.volmit.iris.util.decree.DecreeExecutor;
|
||||
import com.volmit.iris.util.decree.DecreeOrigin;
|
||||
import com.volmit.iris.util.decree.annotations.Decree;
|
||||
import com.volmit.iris.util.decree.annotations.Param;
|
||||
import com.volmit.iris.util.format.C;
|
||||
import com.volmit.iris.util.format.Form;
|
||||
|
||||
@Decree(name = "updater", origin = DecreeOrigin.BOTH, description = "Iris World Updater")
|
||||
public class CommandUpdater implements DecreeExecutor {
|
||||
private final Object lock = new Object();
|
||||
private transient ChunkUpdater chunkUpdater;
|
||||
|
||||
@Decree(description = "Updates all chunk in the specified world")
|
||||
public void start(
|
||||
@Param(description = "World to update chunks at", contextual = true)
|
||||
World world
|
||||
) {
|
||||
if (!IrisToolbelt.isIrisWorld(world)) {
|
||||
sender().sendMessage(C.GOLD + "This is not an Iris world");
|
||||
return;
|
||||
}
|
||||
synchronized (lock) {
|
||||
if (chunkUpdater != null) {
|
||||
chunkUpdater.stop();
|
||||
}
|
||||
|
||||
chunkUpdater = new ChunkUpdater(world);
|
||||
if (sender().isPlayer()) {
|
||||
sender().sendMessage(C.GREEN + "Updating " + world.getName() + C.GRAY + " Total chunks: " + Form.f(chunkUpdater.getChunks()));
|
||||
} else {
|
||||
Iris.info(C.GREEN + "Updating " + world.getName() + C.GRAY + " Total chunks: " + Form.f(chunkUpdater.getChunks()));
|
||||
}
|
||||
chunkUpdater.start();
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized("lock")
|
||||
@Decree(description = "Pause the updater")
|
||||
public void pause( ) {
|
||||
if (chunkUpdater == null) {
|
||||
sender().sendMessage(C.GOLD + "You cant pause something that doesnt exist?");
|
||||
return;
|
||||
}
|
||||
boolean status = chunkUpdater.pause();
|
||||
if (sender().isPlayer()) {
|
||||
if (status) {
|
||||
sender().sendMessage(C.IRIS + "Paused task for: " + C.GRAY + chunkUpdater.getName());
|
||||
} else {
|
||||
sender().sendMessage(C.IRIS + "Unpause task for: " + C.GRAY + chunkUpdater.getName());
|
||||
}
|
||||
} else {
|
||||
if (status) {
|
||||
Iris.info(C.IRIS + "Paused task for: " + C.GRAY + chunkUpdater.getName());
|
||||
} else {
|
||||
Iris.info(C.IRIS + "Unpause task for: " + C.GRAY + chunkUpdater.getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized("lock")
|
||||
@Decree(description = "Stops the updater")
|
||||
public void stop() {
|
||||
if (chunkUpdater == null) {
|
||||
sender().sendMessage(C.GOLD + "You cant stop something that doesnt exist?");
|
||||
return;
|
||||
}
|
||||
if (sender().isPlayer()) {
|
||||
sender().sendMessage("Stopping Updater for: " + C.GRAY + chunkUpdater.getName());
|
||||
} else {
|
||||
Iris.info("Stopping Updater for: " + C.GRAY + chunkUpdater.getName());
|
||||
}
|
||||
chunkUpdater.stop();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+17
-1
@@ -22,7 +22,9 @@ import com.volmit.iris.Iris;
|
||||
import com.volmit.iris.core.edit.BlockSignal;
|
||||
import com.volmit.iris.core.nms.INMS;
|
||||
import com.volmit.iris.core.tools.IrisToolbelt;
|
||||
import com.volmit.iris.engine.framework.Engine;
|
||||
import com.volmit.iris.engine.object.IrisBiome;
|
||||
import com.volmit.iris.engine.object.IrisRegion;
|
||||
import com.volmit.iris.util.data.B;
|
||||
import com.volmit.iris.util.decree.DecreeExecutor;
|
||||
import com.volmit.iris.util.decree.DecreeOrigin;
|
||||
@@ -37,6 +39,7 @@ import org.bukkit.Material;
|
||||
import org.bukkit.block.Biome;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
@Decree(name = "what", origin = DecreeOrigin.PLAYER, studio = true, description = "Iris What?")
|
||||
@@ -82,6 +85,19 @@ public class CommandWhat implements DecreeExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
@Decree(description = "What region am i in?", origin = DecreeOrigin.PLAYER)
|
||||
public void region() {
|
||||
try {
|
||||
Chunk chunk = world().getChunkAt(player().getLocation().getBlockZ() / 16, player().getLocation().getBlockZ() / 16);
|
||||
IrisRegion r = engine().getRegion(chunk);
|
||||
sender().sendMessage("IRegion: " + r.getLoadKey() + " (" + r.getName() + ")");
|
||||
|
||||
} catch (Throwable e) {
|
||||
Iris.reportError(e);
|
||||
sender().sendMessage(C.IRIS + "Iris worlds only.");
|
||||
}
|
||||
}
|
||||
|
||||
@Decree(description = "What block am i looking at?", origin = DecreeOrigin.PLAYER)
|
||||
public void block() {
|
||||
BlockData bd;
|
||||
@@ -147,7 +163,7 @@ public class CommandWhat implements DecreeExecutor {
|
||||
|
||||
sender().sendMessage("Found " + v.get() + " Nearby Markers (" + marker + ")");
|
||||
} else {
|
||||
sender().sendMessage("Iris worlds only.");
|
||||
sender().sendMessage(C.IRIS + "Iris worlds only.");
|
||||
}
|
||||
}
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+7
-7
@@ -61,14 +61,14 @@ public class JigsawEditor implements Listener {
|
||||
private Location target;
|
||||
|
||||
public JigsawEditor(Player player, IrisJigsawPiece piece, IrisObject object, File saveLocation) {
|
||||
if (editors.containsKey(player)) {
|
||||
editors.get(player).close();
|
||||
}
|
||||
if (object == null) throw new RuntimeException("Object is null! " + piece.getObject());
|
||||
editors.compute(player, ($, current) -> {
|
||||
if (current != null) {
|
||||
current.exit();
|
||||
}
|
||||
return this;
|
||||
});
|
||||
|
||||
editors.put(player, this);
|
||||
if (object == null) {
|
||||
throw new RuntimeException("Object is null! " + piece.getObject());
|
||||
}
|
||||
this.object = object;
|
||||
this.player = player;
|
||||
origin = player.getLocation().clone().add(0, 7, 0);
|
||||
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,113 @@
|
||||
package com.volmit.iris.core.events;
|
||||
|
||||
import com.volmit.iris.Iris;
|
||||
import com.volmit.iris.engine.framework.Engine;
|
||||
import com.volmit.iris.engine.object.InventorySlotType;
|
||||
import com.volmit.iris.engine.object.IrisLootTable;
|
||||
import com.volmit.iris.util.collection.KList;
|
||||
import com.volmit.iris.util.scheduling.J;
|
||||
import lombok.Getter;
|
||||
import org.bukkit.*;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.event.Event;
|
||||
import org.bukkit.event.HandlerList;
|
||||
import org.bukkit.event.world.LootGenerateEvent;
|
||||
|
||||
import org.bukkit.inventory.Inventory;
|
||||
import org.bukkit.inventory.InventoryHolder;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.loot.LootContext;
|
||||
import org.bukkit.loot.LootTable;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Random;
|
||||
|
||||
@Getter
|
||||
public class IrisLootEvent extends Event {
|
||||
private static final HandlerList handlers = new HandlerList();
|
||||
private static final LootTable EMPTY = new LootTable() {
|
||||
@NotNull
|
||||
@Override
|
||||
public NamespacedKey getKey() {
|
||||
return new NamespacedKey(Iris.instance, "empty");
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Override
|
||||
public Collection<ItemStack> populateLoot(@Nullable Random random, @NotNull LootContext context) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void fillInventory(@NotNull Inventory inventory, @Nullable Random random, @NotNull LootContext context) {
|
||||
}
|
||||
};
|
||||
|
||||
private final Engine engine;
|
||||
private final Block block;
|
||||
private final InventorySlotType slot;
|
||||
private final KList<IrisLootTable> tables;
|
||||
|
||||
/**
|
||||
* Constructor for IrisLootEvent with mode selection.
|
||||
*
|
||||
* @param engine The engine instance.
|
||||
* @param block The block associated with the event.
|
||||
* @param slot The inventory slot type.
|
||||
* @param tables The list of IrisLootTables. (mutable*)
|
||||
*/
|
||||
public IrisLootEvent(Engine engine, Block block, InventorySlotType slot, KList<IrisLootTable> tables) {
|
||||
this.engine = engine;
|
||||
this.block = block;
|
||||
this.slot = slot;
|
||||
this.tables = tables;
|
||||
}
|
||||
|
||||
@Override
|
||||
public HandlerList getHandlers() {
|
||||
return handlers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Required method to get the HandlerList for this event.
|
||||
*
|
||||
* @return The HandlerList.
|
||||
*/
|
||||
public static HandlerList getHandlerList() {
|
||||
return handlers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers the corresponding Bukkit loot event.
|
||||
* This method integrates your custom IrisLootTables with Bukkit's LootGenerateEvent,
|
||||
* allowing other plugins to modify or cancel the loot generation.
|
||||
*
|
||||
* @return true when the event was canceled
|
||||
*/
|
||||
public static boolean callLootEvent(KList<ItemStack> loot, Inventory inv, World world, int x, int y, int z) {
|
||||
InventoryHolder holder = inv.getHolder();
|
||||
Location loc = new Location(world, x, y, z);
|
||||
if (holder == null) {
|
||||
holder = new InventoryHolder() {
|
||||
@NotNull
|
||||
@Override
|
||||
public Inventory getInventory() {
|
||||
return inv;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
LootContext context = new LootContext.Builder(loc).build();
|
||||
LootGenerateEvent event = new LootGenerateEvent(world, null, holder, EMPTY, context, loot, true);
|
||||
if (!Bukkit.isPrimaryThread()) {
|
||||
Iris.warn("LootGenerateEvent was not called on the main thread, please report this issue.");
|
||||
Thread.dumpStack();
|
||||
J.sfut(() -> Bukkit.getPluginManager().callEvent(event)).join();
|
||||
} else Bukkit.getPluginManager().callEvent(event);
|
||||
|
||||
return event.isCancelled();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
import java.util.Random;
|
||||
|
||||
// A copy of Minecraft's Biome.BIOME_INFO_NOISE, the 2D simplex noise swamps use to pick between their two grass colours.
|
||||
// Minecraft seeds it through a LegacyRandomSource, which draws the same numbers as java.util.Random.
|
||||
final class BiomeInfoNoise {
|
||||
private static final long SEED = 2345L;
|
||||
private static final int PERMUTATION_SIZE = 256;
|
||||
private static final int GRADIENT_COUNT = 12;
|
||||
private static final double SKEW = 0.5D * (Math.sqrt(3D) - 1D);
|
||||
private static final double UNSKEW = (3D - Math.sqrt(3D)) / 6D;
|
||||
private static final double CORNER_RADIUS = 0.5D;
|
||||
private static final double OUTPUT_SCALE = 70D;
|
||||
private static final int[][] GRADIENTS = {
|
||||
{1, 1}, {-1, 1}, {1, -1}, {-1, -1},
|
||||
{1, 0}, {-1, 0}, {1, 0}, {-1, 0},
|
||||
{0, 1}, {0, -1}, {0, 1}, {0, -1}
|
||||
};
|
||||
private static final int[] PERMUTATION = createPermutation();
|
||||
|
||||
private BiomeInfoNoise() {
|
||||
}
|
||||
|
||||
static float at(double x, double z) {
|
||||
double skewOffset = (x + z) * SKEW;
|
||||
int cellX = (int) Math.floor(x + skewOffset);
|
||||
int cellZ = (int) Math.floor(z + skewOffset);
|
||||
double unskewOffset = (cellX + cellZ) * UNSKEW;
|
||||
double x0 = x - (cellX - unskewOffset);
|
||||
double z0 = z - (cellZ - unskewOffset);
|
||||
int stepX = x0 > z0 ? 1 : 0;
|
||||
int stepZ = 1 - stepX;
|
||||
double x1 = x0 - stepX + UNSKEW;
|
||||
double z1 = z0 - stepZ + UNSKEW;
|
||||
double x2 = x0 - 1D + 2D * UNSKEW;
|
||||
double z2 = z0 - 1D + 2D * UNSKEW;
|
||||
int wrappedX = cellX & 0xFF;
|
||||
int wrappedZ = cellZ & 0xFF;
|
||||
int gradient0 = permute(wrappedX + permute(wrappedZ)) % GRADIENT_COUNT;
|
||||
int gradient1 = permute(wrappedX + stepX + permute(wrappedZ + stepZ)) % GRADIENT_COUNT;
|
||||
int gradient2 = permute(wrappedX + 1 + permute(wrappedZ + 1)) % GRADIENT_COUNT;
|
||||
return (float) (OUTPUT_SCALE * (corner(gradient0, x0, z0) + corner(gradient1, x1, z1) + corner(gradient2, x2, z2)));
|
||||
}
|
||||
|
||||
private static double corner(int gradient, double x, double z) {
|
||||
double falloff = CORNER_RADIUS - x * x - z * z;
|
||||
if (falloff < 0D) {
|
||||
return 0D;
|
||||
}
|
||||
falloff *= falloff;
|
||||
return falloff * falloff * (GRADIENTS[gradient][0] * x + GRADIENTS[gradient][1] * z);
|
||||
}
|
||||
|
||||
private static int permute(int index) {
|
||||
return PERMUTATION[index & 0xFF];
|
||||
}
|
||||
|
||||
// Minecraft draws three offsets first and discards them for this noise, so they are drawn here too to stay in step.
|
||||
private static int[] createPermutation() {
|
||||
Random random = new Random(SEED);
|
||||
for (int discardedOffset = 0; discardedOffset < 3; discardedOffset++) {
|
||||
random.nextDouble();
|
||||
}
|
||||
int[] permutation = new int[PERMUTATION_SIZE];
|
||||
for (int i = 0; i < PERMUTATION_SIZE; i++) {
|
||||
permutation[i] = i;
|
||||
}
|
||||
for (int i = 0; i < PERMUTATION_SIZE; i++) {
|
||||
int swap = i + random.nextInt(PERMUTATION_SIZE - i);
|
||||
int previous = permutation[i];
|
||||
permutation[i] = permutation[swap];
|
||||
permutation[swap] = previous;
|
||||
}
|
||||
return permutation;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
import com.volmit.iris.engine.object.IrisBiome;
|
||||
import com.volmit.iris.engine.object.IrisBiomePaletteLayer;
|
||||
import com.volmit.iris.engine.object.IrisBlockData;
|
||||
import com.volmit.iris.engine.object.IrisDecorator;
|
||||
import com.volmit.iris.util.collection.KList;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* One of the block palettes a biome places: a surface, sea or cave ceiling layer, its wall or slab blocks, or a
|
||||
* decorator.
|
||||
*/
|
||||
record BiomePalette(String biomeKey, Kind kind, int index) {
|
||||
private static final String ID_PREFIX = "palette:";
|
||||
private static final char ID_SEPARATOR = '#';
|
||||
private static final String SNIPPET_PREFIX = "snippet/";
|
||||
|
||||
enum Kind {
|
||||
LAYER("Layer", "layers", SnippetFolder.BLOCK_PALETTES, true, true),
|
||||
SEA_LAYER("Sea layer", "seaLayers", SnippetFolder.BLOCK_PALETTES, true, true),
|
||||
CAVE_CEILING_LAYER("Cave ceiling layer", "caveCeilingLayers", SnippetFolder.BLOCK_PALETTES, true, true),
|
||||
WALL("Wall", "wall", SnippetFolder.BLOCK_PALETTES, false, false),
|
||||
SLAB("Slab", "slab", SnippetFolder.BLOCK_PALETTES, false, false),
|
||||
DECORATOR("Decorator", "decorators", SnippetFolder.DECORATORS, false, true);
|
||||
|
||||
private final String label;
|
||||
private final String jsonKey;
|
||||
private final SnippetFolder snippetFolder;
|
||||
private final boolean stacked;
|
||||
private final boolean numbered;
|
||||
|
||||
Kind(String label, String jsonKey, SnippetFolder snippetFolder, boolean stacked, boolean numbered) {
|
||||
this.label = label;
|
||||
this.jsonKey = jsonKey;
|
||||
this.snippetFolder = snippetFolder;
|
||||
this.stacked = stacked;
|
||||
this.numbered = numbered;
|
||||
}
|
||||
|
||||
SnippetFolder snippetFolder() {
|
||||
return snippetFolder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the palette is a stack of blocks with a thickness, rather than single blocks on walls or slopes.
|
||||
*/
|
||||
boolean isStacked() {
|
||||
return stacked;
|
||||
}
|
||||
|
||||
/**
|
||||
* The biome file's key holding these palettes: a list of them, or a single one for walls and slabs.
|
||||
*/
|
||||
String jsonKey() {
|
||||
return jsonKey;
|
||||
}
|
||||
|
||||
// A biome without wall or slab blocks still has those palettes, just empty.
|
||||
List<IrisBiomePaletteLayer> layersOf(IrisBiome biome) {
|
||||
return switch (this) {
|
||||
case LAYER -> biome.getLayers();
|
||||
case SEA_LAYER -> biome.getSeaLayers();
|
||||
case CAVE_CEILING_LAYER -> biome.getCaveCeilingLayers();
|
||||
case WALL -> biome.getWall().getPalette().isEmpty() ? List.of() : List.of(biome.getWall());
|
||||
case SLAB -> biome.getSlab().getPalette().isEmpty() ? List.of() : List.of(biome.getSlab());
|
||||
case DECORATOR -> List.of();
|
||||
};
|
||||
}
|
||||
|
||||
// A decorator snippet that couldn't be found loads as an empty entry, which places nothing.
|
||||
List<KList<IrisBlockData>> blockListsOf(IrisBiome biome) {
|
||||
if (this != DECORATOR) {
|
||||
return layersOf(biome).stream().map(IrisBiomePaletteLayer::getPalette).toList();
|
||||
}
|
||||
return biome.getDecorators().stream()
|
||||
.map(decorator -> decorator == null ? new KList<IrisBlockData>() : decorator.getPalette())
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The folders under snippet/ that palettes can come from, named after the Iris type they hold.
|
||||
*/
|
||||
enum SnippetFolder {
|
||||
BLOCK_PALETTES("biome-palette", "Block palettes"),
|
||||
DECORATORS("decorator", "Decorators");
|
||||
|
||||
private final String folder;
|
||||
private final String label;
|
||||
|
||||
SnippetFolder(String folder, String label) {
|
||||
this.folder = folder;
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
String folder() {
|
||||
return folder;
|
||||
}
|
||||
|
||||
String label() {
|
||||
return label;
|
||||
}
|
||||
|
||||
/**
|
||||
* The snippet's path inside this folder. Packs may leave the folder out, the way the snippet loader allows.
|
||||
*/
|
||||
String pathOf(String reference) {
|
||||
String path = reference.startsWith(SNIPPET_PREFIX) ? reference.substring(SNIPPET_PREFIX.length()) : reference;
|
||||
return path.startsWith(folder + "/") ? path.substring(folder.length() + 1) : path;
|
||||
}
|
||||
}
|
||||
|
||||
static List<BiomePalette> allOf(String biomeKey, IrisBiome biome) {
|
||||
List<BiomePalette> palettes = new ArrayList<>();
|
||||
for (Kind kind : Kind.values()) {
|
||||
List<KList<IrisBlockData>> blockLists = kind.blockListsOf(biome);
|
||||
for (int index = 0; index < blockLists.size(); index++) {
|
||||
if (!blockLists.get(index).isEmpty()) {
|
||||
palettes.add(new BiomePalette(biomeKey, kind, index));
|
||||
}
|
||||
}
|
||||
}
|
||||
return List.copyOf(palettes);
|
||||
}
|
||||
|
||||
static BiomePalette fromId(String id) {
|
||||
if (id == null || !id.startsWith(ID_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
int indexSeparator = id.lastIndexOf(ID_SEPARATOR);
|
||||
int kindSeparator = id.lastIndexOf(ID_SEPARATOR, indexSeparator - 1);
|
||||
if (kindSeparator < ID_PREFIX.length()) {
|
||||
return null;
|
||||
}
|
||||
return new BiomePalette(
|
||||
id.substring(ID_PREFIX.length(), kindSeparator),
|
||||
Kind.valueOf(id.substring(kindSeparator + 1, indexSeparator)),
|
||||
Integer.parseInt(id.substring(indexSeparator + 1))
|
||||
);
|
||||
}
|
||||
|
||||
static boolean belongsTo(String id, String biomeKey) {
|
||||
return id != null && id.startsWith(ID_PREFIX + biomeKey + ID_SEPARATOR);
|
||||
}
|
||||
|
||||
String id() {
|
||||
return ID_PREFIX + biomeKey + ID_SEPARATOR + kind.name() + ID_SEPARATOR + index;
|
||||
}
|
||||
|
||||
String label() {
|
||||
return kind.numbered ? kind.label + " " + index : kind.label;
|
||||
}
|
||||
|
||||
String lowercaseLabel() {
|
||||
return label().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
/**
|
||||
* The palette's blocks in the given copy of the biome, or null when a hotload removed it.
|
||||
*/
|
||||
KList<IrisBlockData> blocksIn(IrisBiome biome) {
|
||||
List<KList<IrisBlockData>> blockLists = kind.blockListsOf(biome);
|
||||
return index < blockLists.size() ? blockLists.get(index) : null;
|
||||
}
|
||||
|
||||
IrisBiomePaletteLayer layerIn(IrisBiome biome) {
|
||||
return kind.layersOf(biome).get(index);
|
||||
}
|
||||
|
||||
IrisDecorator decoratorIn(IrisBiome biome) {
|
||||
return biome.getDecorators().get(index);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
import com.volmit.iris.core.loader.IrisData;
|
||||
import com.volmit.iris.engine.decorator.IrisEngineDecorator;
|
||||
import com.volmit.iris.engine.framework.Engine;
|
||||
import com.volmit.iris.engine.object.IrisBiome;
|
||||
import com.volmit.iris.engine.object.IrisBiomePaletteLayer;
|
||||
import com.volmit.iris.engine.object.IrisBlockData;
|
||||
import com.volmit.iris.engine.object.IrisDecorationPart;
|
||||
import com.volmit.iris.engine.object.IrisDecorator;
|
||||
import com.volmit.iris.engine.object.IrisDimension;
|
||||
import com.volmit.iris.util.collection.KList;
|
||||
import com.volmit.iris.util.math.RNG;
|
||||
import com.volmit.iris.util.noise.CNG;
|
||||
import org.bukkit.block.data.Bisected;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* How thick one biome palette is at a spot and which noise picks its block there, sampled exactly the way the
|
||||
* generator does for that kind of palette. The generator seeds each palette's noise from the first call it makes,
|
||||
* so these use the same random sources the terrain, carving, post and decorator passes use.
|
||||
*/
|
||||
abstract class BiomePalettePlacement {
|
||||
protected final IrisData data;
|
||||
|
||||
private BiomePalettePlacement(IrisData data) {
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
static BiomePalettePlacement of(Engine engine, IrisBiome biome, BiomePalette palette) {
|
||||
IrisData data = engine.getData();
|
||||
RNG terrainRandom = new RNG(engine.getSeedManager().getTerrain());
|
||||
RNG carveRandom = new RNG(engine.getSeedManager().getCarve());
|
||||
RNG postRandom = new RNG(engine.getSeedManager().getPost());
|
||||
int seaLevel = seaLevel(engine.getDimension());
|
||||
return switch (palette.kind()) {
|
||||
case LAYER -> new SurfaceLayer(biome, palette.index(), palette.layerIn(biome), terrainRandom, data);
|
||||
case SEA_LAYER -> new LegacyStackedLayer(palette.layerIn(biome), data, terrainRandom.nextParallelRNG(palette.index()),
|
||||
biome.getLayerSeaHeightGenerators(terrainRandom, data), palette.index());
|
||||
case CAVE_CEILING_LAYER -> new LegacyStackedLayer(palette.layerIn(biome), data, carveRandom.nextParallelRNG(palette.index()),
|
||||
biome.getLayerHeightGenerators(terrainRandom, data), palette.index());
|
||||
case WALL -> new SingleBlocks(palette.layerIn(biome), data, carveRandom, seaLevel);
|
||||
case SLAB -> new SingleBlocks(palette.layerIn(biome), data, postRandom, seaLevel);
|
||||
case DECORATOR -> new Decorations(biome, palette.decoratorIn(biome), engine.getSeedManager().getComponent(),
|
||||
seaLevel, data);
|
||||
};
|
||||
}
|
||||
|
||||
private static int seaLevel(IrisDimension dimension) {
|
||||
return dimension == null ? 0 : dimension.getFluidHeight();
|
||||
}
|
||||
|
||||
abstract KList<IrisBlockData> blocks();
|
||||
|
||||
abstract double zoom();
|
||||
|
||||
int minThickness() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
int maxThickness() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
abstract boolean hasSlopeCondition();
|
||||
|
||||
/**
|
||||
* How many blocks of this palette are stacked at the spot.
|
||||
*/
|
||||
abstract int thickness(int blockX, int blockZ);
|
||||
|
||||
abstract double noise(int blockX, int blockZ, int depth);
|
||||
|
||||
/**
|
||||
* What the preview simplifies about this palette, or null when it shows exactly what the world places.
|
||||
*/
|
||||
String simplification() {
|
||||
return null;
|
||||
}
|
||||
|
||||
private abstract static class LayerPlacement extends BiomePalettePlacement {
|
||||
protected final IrisBiomePaletteLayer layer;
|
||||
|
||||
private LayerPlacement(IrisBiomePaletteLayer layer, IrisData data) {
|
||||
super(data);
|
||||
this.layer = layer;
|
||||
}
|
||||
|
||||
@Override
|
||||
KList<IrisBlockData> blocks() {
|
||||
return layer.getPalette();
|
||||
}
|
||||
|
||||
@Override
|
||||
double zoom() {
|
||||
return layer.getZoom();
|
||||
}
|
||||
|
||||
@Override
|
||||
int minThickness() {
|
||||
return layer.getMinHeight();
|
||||
}
|
||||
|
||||
@Override
|
||||
int maxThickness() {
|
||||
return layer.getMaxHeight();
|
||||
}
|
||||
|
||||
@Override
|
||||
boolean hasSlopeCondition() {
|
||||
return !layer.getSlopeCondition().isDefault();
|
||||
}
|
||||
}
|
||||
|
||||
private static final class SurfaceLayer extends LayerPlacement {
|
||||
private final IrisBiome biome;
|
||||
private final int index;
|
||||
private final RNG random;
|
||||
|
||||
private SurfaceLayer(IrisBiome biome, int index, IrisBiomePaletteLayer layer, RNG random, IrisData data) {
|
||||
super(layer, data);
|
||||
this.biome = biome;
|
||||
this.index = index;
|
||||
this.random = random;
|
||||
}
|
||||
|
||||
@Override
|
||||
int thickness(int blockX, int blockZ) {
|
||||
return biome.getLayerThickness(index, blockX, blockZ, random, data);
|
||||
}
|
||||
|
||||
@Override
|
||||
double noise(int blockX, int blockZ, int depth) {
|
||||
return biome.getLayerNoise(index, blockX, blockZ, depth, random, data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sea and cave ceiling layers go through the older sampling path, which divides by the zoom twice and borrows
|
||||
* its thickness noise from another list: a cave ceiling layer uses the surface layer at the same position's.
|
||||
*/
|
||||
private static final class LegacyStackedLayer extends LayerPlacement {
|
||||
private final RNG random;
|
||||
private final CNG thicknessNoise;
|
||||
|
||||
private LegacyStackedLayer(IrisBiomePaletteLayer layer, IrisData data, RNG random, KList<CNG> thicknessNoises,
|
||||
int index) {
|
||||
super(layer, data);
|
||||
this.random = random;
|
||||
this.thicknessNoise = index < thicknessNoises.size() ? thicknessNoises.get(index) : null;
|
||||
}
|
||||
|
||||
@Override
|
||||
int thickness(int blockX, int blockZ) {
|
||||
if (thicknessNoise == null) {
|
||||
return 0;
|
||||
}
|
||||
double zoom = layer.getZoom();
|
||||
return Math.max(0, thicknessNoise.fit(layer.getMinHeight(), layer.getMaxHeight(), blockX / zoom, blockZ / zoom));
|
||||
}
|
||||
|
||||
@Override
|
||||
double noise(int blockX, int blockZ, int depth) {
|
||||
double zoom = layer.getZoom();
|
||||
return layer.getLayerGenerator(random, data)
|
||||
.noise((blockX + depth) / zoom / zoom, depth / zoom, (blockZ - depth) / zoom / zoom);
|
||||
}
|
||||
|
||||
@Override
|
||||
String simplification() {
|
||||
return thicknessNoise == null
|
||||
? "The generator takes this layer's thickness from the surface layer at the same position, which doesn't exist, so it is never placed."
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wall and slab blocks are picked one at a time with noise that also changes with height.
|
||||
*/
|
||||
private static final class SingleBlocks extends LayerPlacement {
|
||||
private final RNG random;
|
||||
private final int sampleHeight;
|
||||
|
||||
private SingleBlocks(IrisBiomePaletteLayer layer, IrisData data, RNG random, int sampleHeight) {
|
||||
super(layer, data);
|
||||
this.random = random;
|
||||
this.sampleHeight = sampleHeight;
|
||||
}
|
||||
|
||||
@Override
|
||||
int minThickness() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
int maxThickness() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
int thickness(int blockX, int blockZ) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
double noise(int blockX, int blockZ, int depth) {
|
||||
double zoom = layer.getZoom();
|
||||
return layer.getLayerGenerator(random, data).noise(blockX / zoom, sampleHeight / zoom, blockZ / zoom);
|
||||
}
|
||||
|
||||
@Override
|
||||
String simplification() {
|
||||
return "These blocks also change with height; the preview shows them at sea level (Y " + sampleHeight + ").";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A decorator places where its style noise stays under its chance, and its variance noise picks the block there.
|
||||
* The generator seeds the variance noise from whichever block it decorates first, so the preview reuses that noise
|
||||
* once the world has generated some decorations.
|
||||
*/
|
||||
private static final class Decorations extends BiomePalettePlacement {
|
||||
private final IrisDecorator decorator;
|
||||
private final CNG placementNoise;
|
||||
private final CNG blockNoise;
|
||||
private final int sampleHeight;
|
||||
private final boolean sharesSpotsWithOtherDecorators;
|
||||
|
||||
private Decorations(IrisBiome biome, IrisDecorator decorator, long componentSeed, int sampleHeight, IrisData data) {
|
||||
super(data);
|
||||
this.decorator = decorator;
|
||||
RNG partRandom = new RNG(IrisEngineDecorator.partSeed(componentSeed, decorator.getPartOf()));
|
||||
this.placementNoise = decorator.getGenerator(partRandom, data);
|
||||
this.blockNoise = decorator.getVarianceGenerator(partRandom, data);
|
||||
this.sampleHeight = sampleHeight;
|
||||
this.sharesSpotsWithOtherDecorators = biome.getDecorators().stream()
|
||||
.anyMatch(other -> other != null && other != decorator && other.getPartOf() == decorator.getPartOf());
|
||||
}
|
||||
|
||||
@Override
|
||||
KList<IrisBlockData> blocks() {
|
||||
return decorator.getPalette();
|
||||
}
|
||||
|
||||
@Override
|
||||
double zoom() {
|
||||
return decorator.getStyle().getZoom();
|
||||
}
|
||||
|
||||
@Override
|
||||
boolean hasSlopeCondition() {
|
||||
return !decorator.getSlopeCondition().isDefault();
|
||||
}
|
||||
|
||||
@Override
|
||||
int thickness(int blockX, int blockZ) {
|
||||
double zoom = zoom();
|
||||
return placementNoise.fitDouble(0D, 1D, blockX / zoom, blockZ / zoom) <= decorator.getChance() ? 1 : 0;
|
||||
}
|
||||
|
||||
// The generator swaps X and Z when it picks the block.
|
||||
@Override
|
||||
double noise(int blockX, int blockZ, int depth) {
|
||||
return blockNoise.noise(blockZ, sampleHeight, blockX);
|
||||
}
|
||||
|
||||
@Override
|
||||
String simplification() {
|
||||
List<String> notes = new ArrayList<>();
|
||||
String partNote = partNote(decorator.getPartOf());
|
||||
if (partNote != null) {
|
||||
notes.add(partNote);
|
||||
}
|
||||
if (sharesSpotsWithOtherDecorators) {
|
||||
notes.add("Where other decorators of this biome could place too, the world picks one of them at random.");
|
||||
}
|
||||
if (placesTwoBlockTallPlants()) {
|
||||
notes.add("Two block tall plants stand up on the map: their top half covers the block above them.");
|
||||
}
|
||||
if (decorator.isStacking()) {
|
||||
notes.add("It stacks " + decorator.getStackMin() + "–" + decorator.getStackMax()
|
||||
+ " blocks high; the preview shows the bottom block.");
|
||||
}
|
||||
if (blocks().size() > 1) {
|
||||
notes.add("The block also changes with height; the preview picks it at sea level (Y " + sampleHeight + ").");
|
||||
}
|
||||
return notes.isEmpty() ? null : String.join(" ", notes);
|
||||
}
|
||||
|
||||
// The generator places any two-part block as a bottom half with its top half above it.
|
||||
private boolean placesTwoBlockTallPlants() {
|
||||
return !decorator.isStacking() && blocks().stream().anyMatch(block -> block.getBlockData(data) instanceof Bisected);
|
||||
}
|
||||
|
||||
private static String partNote(IrisDecorationPart part) {
|
||||
return switch (part) {
|
||||
case NONE -> null;
|
||||
case SHORE_LINE -> "It only places along shore lines.";
|
||||
case SEA_SURFACE -> "It only places on the sea surface.";
|
||||
case SEA_FLOOR -> "It only places on the sea floor.";
|
||||
case CEILING -> "It places on cave ceilings and the undersides of overhangs, not on the ground.";
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-5
@@ -16,10 +16,12 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.volmit.iris.util.reflect;
|
||||
|
||||
public class ShadeFix {
|
||||
public static void fix(Class<?> c) {
|
||||
c.getCanonicalName();
|
||||
}
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
enum BiomeTint {
|
||||
GRASS,
|
||||
FOLIAGE,
|
||||
DRY_FOLIAGE,
|
||||
WATER
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
// The colours one Minecraft biome multiplies onto tinted blocks, as worked out by Biome#getGrassColor and friends.
|
||||
record BiomeTintColors(int grass, GrassColorModifier grassModifier, int foliage, int dryFoliage, int water) {
|
||||
int colorAt(BiomeTint tint, int blockX, int blockZ) {
|
||||
return switch (tint) {
|
||||
case GRASS -> grassModifier.modify(blockX, blockZ, grass);
|
||||
case FOLIAGE -> foliage;
|
||||
case DRY_FOLIAGE -> dryFoliage;
|
||||
case WATER -> water;
|
||||
};
|
||||
}
|
||||
|
||||
boolean variesByPosition() {
|
||||
return grassModifier == GrassColorModifier.SWAMP;
|
||||
}
|
||||
|
||||
enum GrassColorModifier {
|
||||
NONE,
|
||||
DARK_FOREST,
|
||||
SWAMP;
|
||||
|
||||
private static final double SWAMP_NOISE_SCALE = 0.0225D;
|
||||
private static final double SWAMP_WET_THRESHOLD = -0.1D;
|
||||
private static final int SWAMP_WET_GRASS = 0x4C763C;
|
||||
private static final int SWAMP_DRY_GRASS = 0x6A7039;
|
||||
private static final int DARK_FOREST_SHIFT = 0x28340A;
|
||||
|
||||
static GrassColorModifier named(String name) {
|
||||
return switch (name.toLowerCase(Locale.ROOT)) {
|
||||
case "dark_forest" -> DARK_FOREST;
|
||||
case "swamp" -> SWAMP;
|
||||
default -> NONE;
|
||||
};
|
||||
}
|
||||
|
||||
int modify(int blockX, int blockZ, int baseColor) {
|
||||
return switch (this) {
|
||||
case NONE -> baseColor;
|
||||
case DARK_FOREST -> ((baseColor & 0xFEFEFE) + DARK_FOREST_SHIFT) >> 1;
|
||||
case SWAMP -> BiomeInfoNoise.at(blockX * SWAMP_NOISE_SCALE, blockZ * SWAMP_NOISE_SCALE) < SWAMP_WET_THRESHOLD
|
||||
? SWAMP_WET_GRASS
|
||||
: SWAMP_DRY_GRASS;
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
import com.volmit.iris.engine.object.IrisBiome;
|
||||
import com.volmit.iris.engine.object.IrisBiomeCustom;
|
||||
import com.volmit.iris.util.collection.KList;
|
||||
import com.volmit.iris.util.math.RNG;
|
||||
import org.bukkit.block.Biome;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
// The tint Minecraft draws on each column of one Iris biome. Iris picks the Minecraft biome once per chunk
|
||||
// (see IrisBiomeActuator), and the client averages the colours of the columns around each block.
|
||||
final class BiomeTintField {
|
||||
private static final int MINECRAFT_DEFAULT_BLEND_RADIUS = 2;
|
||||
private static final int BLENDED_COLUMN_COUNT = (MINECRAFT_DEFAULT_BLEND_RADIUS * 2 + 1) * (MINECRAFT_DEFAULT_BLEND_RADIUS * 2 + 1);
|
||||
private static final int BLENDED_TINT_CACHE_SIZE = 1 << 16;
|
||||
private static final int CHUNK_COLORS_CACHE_SIZE = 1 << 10;
|
||||
|
||||
private final IrisBiome biome;
|
||||
private final RNG biomeRandom;
|
||||
private final MinecraftBiomeColors minecraftColors;
|
||||
private final BiomeTintColors sameColorsEverywhere;
|
||||
private final Map<IrisBiomeCustom, BiomeTintColors> colorsByCustomBiome = new ConcurrentHashMap<>();
|
||||
private final BlendedTint[] blendedTintCache = new BlendedTint[BLENDED_TINT_CACHE_SIZE];
|
||||
private final ChunkColors[] chunkColorsCache = new ChunkColors[CHUNK_COLORS_CACHE_SIZE];
|
||||
|
||||
BiomeTintField(IrisBiome biome, RNG biomeRandom, MinecraftBiomeColors minecraftColors) {
|
||||
this.biome = biome;
|
||||
this.biomeRandom = biomeRandom;
|
||||
this.minecraftColors = minecraftColors;
|
||||
BiomeTintColors onlyColors = onlyPossibleColors();
|
||||
this.sameColorsEverywhere = onlyColors == null || onlyColors.variesByPosition() ? null : onlyColors;
|
||||
}
|
||||
|
||||
int colorAt(BiomeTint tint, int blockX, int blockZ) {
|
||||
if (sameColorsEverywhere != null) {
|
||||
return sameColorsEverywhere.colorAt(tint, blockX, blockZ);
|
||||
}
|
||||
int cacheSlot = cacheSlot(blockX, blockZ, tint.ordinal(), BLENDED_TINT_CACHE_SIZE);
|
||||
BlendedTint cached = blendedTintCache[cacheSlot];
|
||||
if (cached != null && cached.x() == blockX && cached.z() == blockZ && cached.tint() == tint) {
|
||||
return cached.color();
|
||||
}
|
||||
int color = blendedColor(tint, blockX, blockZ);
|
||||
blendedTintCache[cacheSlot] = new BlendedTint(blockX, blockZ, tint, color);
|
||||
return color;
|
||||
}
|
||||
|
||||
// Minecraft's ClientLevel#calculateBlockTint at the default biome blend radius.
|
||||
private int blendedColor(BiomeTint tint, int blockX, int blockZ) {
|
||||
int red = 0;
|
||||
int green = 0;
|
||||
int blue = 0;
|
||||
for (int x = blockX - MINECRAFT_DEFAULT_BLEND_RADIUS; x <= blockX + MINECRAFT_DEFAULT_BLEND_RADIUS; x++) {
|
||||
for (int z = blockZ - MINECRAFT_DEFAULT_BLEND_RADIUS; z <= blockZ + MINECRAFT_DEFAULT_BLEND_RADIUS; z++) {
|
||||
int color = chunkColors(x >> 4, z >> 4).colorAt(tint, x, z);
|
||||
red += (color >>> 16) & 0xFF;
|
||||
green += (color >>> 8) & 0xFF;
|
||||
blue += color & 0xFF;
|
||||
}
|
||||
}
|
||||
return (red / BLENDED_COLUMN_COUNT) << 16 | (green / BLENDED_COLUMN_COUNT) << 8 | (blue / BLENDED_COLUMN_COUNT);
|
||||
}
|
||||
|
||||
private BiomeTintColors chunkColors(int chunkX, int chunkZ) {
|
||||
int cacheSlot = cacheSlot(chunkX, chunkZ, 0, CHUNK_COLORS_CACHE_SIZE);
|
||||
ChunkColors cached = chunkColorsCache[cacheSlot];
|
||||
if (cached != null && cached.chunkX() == chunkX && cached.chunkZ() == chunkZ) {
|
||||
return cached.colors();
|
||||
}
|
||||
BiomeTintColors colors = colorsForChunkAt(chunkX << 4, chunkZ << 4);
|
||||
chunkColorsCache[cacheSlot] = new ChunkColors(chunkX, chunkZ, colors);
|
||||
return colors;
|
||||
}
|
||||
|
||||
private BiomeTintColors colorsForChunkAt(int originX, int originZ) {
|
||||
if (biome.isCustom()) {
|
||||
return customColors(biome.getCustomBiome(biomeRandom, originX, 0, originZ));
|
||||
}
|
||||
return vanillaColors(biome.getSkyBiome(biomeRandom, originX, 0, originZ));
|
||||
}
|
||||
|
||||
// Follows the same choices as IrisBiome#getSkyBiome and #getCustomBiome, returning null when chunks can differ.
|
||||
private BiomeTintColors onlyPossibleColors() {
|
||||
if (biome.isCustom()) {
|
||||
KList<IrisBiomeCustom> variants = biome.getCustomDerivitives();
|
||||
return variants.size() == 1 ? customColors(variants.get(0)) : null;
|
||||
}
|
||||
KList<Biome> skyBiomes = biome.getBiomeSkyScatter();
|
||||
if (!skyBiomes.isEmpty()) {
|
||||
return skyBiomes.size() == 1 ? vanillaColors(skyBiomes.get(0)) : null;
|
||||
}
|
||||
KList<Biome> groundBiomes = biome.getBiomeScatter();
|
||||
if (groundBiomes.isEmpty()) {
|
||||
return vanillaColors(biome.getDerivative());
|
||||
}
|
||||
return groundBiomes.size() == 1 ? vanillaColors(groundBiomes.get(0)) : null;
|
||||
}
|
||||
|
||||
private BiomeTintColors customColors(IrisBiomeCustom customBiome) {
|
||||
return colorsByCustomBiome.computeIfAbsent(customBiome, minecraftColors::custom);
|
||||
}
|
||||
|
||||
private BiomeTintColors vanillaColors(Biome vanillaBiome) {
|
||||
return minecraftColors.vanilla(vanillaBiome.getKey());
|
||||
}
|
||||
|
||||
private static int cacheSlot(int x, int z, int salt, int cacheSize) {
|
||||
long hash = x * 0x9E3779B97F4A7C15L ^ z * 0xC2B2AE3D27D4EB4FL ^ salt * 0x165667B19E3779F9L;
|
||||
return (int) ((hash ^ (hash >>> 29)) & (cacheSize - 1));
|
||||
}
|
||||
|
||||
private record BlendedTint(int x, int z, BiomeTint tint, int color) {
|
||||
}
|
||||
|
||||
private record ChunkColors(int chunkX, int chunkZ, BiomeTintColors colors) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
import com.volmit.iris.core.gui.BlockTextures.BlockTexture;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumSet;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.function.UnaryOperator;
|
||||
|
||||
/**
|
||||
* A block as the object viewer draws it: the textured faces of its model, in block units, already turned the way the
|
||||
* blockstate faces.
|
||||
*
|
||||
* @param faces every face of every box in the model
|
||||
* @param coveredSides the block's sides that a solid face fills completely, which hide whatever touches them
|
||||
* @param hidesSameBlockSides whether the model fills the whole block, so like glass it hides the sides between two of
|
||||
* the same blocks
|
||||
*/
|
||||
record BlockModel(List<Face> faces, Set<CubeFace> coveredSides, boolean hidesSameBlockSides) {
|
||||
static final BlockModel EMPTY = new BlockModel(List.of(), Set.of(), false);
|
||||
private static final int SIDE_CELLS = 16;
|
||||
private static final double ON_SIDE_TOLERANCE = 1e-4D;
|
||||
|
||||
static BlockModel of(List<Face> faces) {
|
||||
Set<CubeFace> coveredSides = EnumSet.noneOf(CubeFace.class);
|
||||
boolean fillsEverySide = true;
|
||||
for (CubeFace side : CubeFace.values()) {
|
||||
if (sideIsFilled(faces, side, true)) {
|
||||
coveredSides.add(side);
|
||||
}
|
||||
fillsEverySide &= sideIsFilled(faces, side, false);
|
||||
}
|
||||
return new BlockModel(List.copyOf(faces), Set.copyOf(coveredSides), fillsEverySide);
|
||||
}
|
||||
|
||||
static BlockModel solidColor(int color) {
|
||||
BlockTexture texture = BlockTexture.solid(color);
|
||||
List<Face> faces = new ArrayList<>();
|
||||
for (CubeFace side : CubeFace.values()) {
|
||||
faces.add(Face.boxSide(Vec3.ZERO, new Vec3(1D, 1D, 1D), side, texture, side));
|
||||
}
|
||||
return of(faces);
|
||||
}
|
||||
|
||||
boolean isEmpty() {
|
||||
return faces.isEmpty();
|
||||
}
|
||||
|
||||
BlockModel withTextures(UnaryOperator<BlockTexture> change) {
|
||||
Map<BlockTexture, BlockTexture> changed = new IdentityHashMap<>();
|
||||
List<Face> changedFaces = new ArrayList<>(faces.size());
|
||||
for (Face face : faces) {
|
||||
changedFaces.add(face.withTexture(changed.computeIfAbsent(face.texture(), change)));
|
||||
}
|
||||
return new BlockModel(List.copyOf(changedFaces), coveredSides, hidesSameBlockSides);
|
||||
}
|
||||
|
||||
// The side is split into a grid, and every cell has to lie under a face pressed flat against it.
|
||||
private static boolean sideIsFilled(List<Face> faces, CubeFace side, boolean onlySolidFaces) {
|
||||
boolean[] filled = new boolean[SIDE_CELLS * SIDE_CELLS];
|
||||
for (Face face : faces) {
|
||||
if (face.liesOn(side) && (!onlySolidFaces || face.texture().isFullyOpaque())) {
|
||||
face.fillCells(side, filled, SIDE_CELLS);
|
||||
}
|
||||
}
|
||||
for (boolean cell : filled) {
|
||||
if (!cell) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* A parallelogram from {@code origin} along {@code edgeU} and {@code edgeV}. The texture point at the origin and
|
||||
* how it moves along each edge are given as fractions of the texture.
|
||||
*
|
||||
* @param normal the way the face looks out; it is hidden when seen from behind
|
||||
* @param cullFace the block side whose neighbour hides this face when it covers that side, or null
|
||||
*/
|
||||
record Face(Vec3 origin, Vec3 edgeU, Vec3 edgeV, Vec3 normal, BlockTexture texture, TextureMapping mapping,
|
||||
double shade, CubeFace cullFace) {
|
||||
|
||||
// Laid out as seen from outside: u to the right and v downwards, matching Minecraft's UVs for a whole texture.
|
||||
static Face boxSide(Vec3 from, Vec3 to, CubeFace side, BlockTexture texture, CubeFace cullFace) {
|
||||
Vec3[] corners = BoxCorners.of(from, to, side);
|
||||
return new Face(corners[0], corners[3].minus(corners[0]), corners[1].minus(corners[0]), side.direction(),
|
||||
texture, TextureMapping.WHOLE, side.shade(), cullFace);
|
||||
}
|
||||
|
||||
Face withTexture(BlockTexture changedTexture) {
|
||||
return new Face(origin, edgeU, edgeV, normal, changedTexture, mapping, shade, cullFace);
|
||||
}
|
||||
|
||||
Face transformed(Transform transform) {
|
||||
Vec3 turnedNormal = transform.direction(normal);
|
||||
double length = Math.sqrt(turnedNormal.x() * turnedNormal.x() + turnedNormal.y() * turnedNormal.y() + turnedNormal.z() * turnedNormal.z());
|
||||
return new Face(transform.point(origin), transform.direction(edgeU), transform.direction(edgeV),
|
||||
length == 0D ? turnedNormal : turnedNormal.times(1D / length), texture, mapping, shade,
|
||||
cullFace == null ? null : CubeFace.nearest(transform.direction(cullFace.direction())));
|
||||
}
|
||||
|
||||
Face withCullFace(CubeFace side) {
|
||||
return new Face(origin, edgeU, edgeV, normal, texture, mapping, shade, side);
|
||||
}
|
||||
|
||||
Face withShade(double changedShade) {
|
||||
return new Face(origin, edgeU, edgeV, normal, texture, mapping, changedShade, cullFace);
|
||||
}
|
||||
|
||||
private boolean liesOn(CubeFace side) {
|
||||
int axis = side.axis();
|
||||
double plane = side.pointsPositive() ? 1D : 0D;
|
||||
return CubeFace.nearest(normal) == side
|
||||
&& Math.abs(normal.component(axis)) > 1D - ON_SIDE_TOLERANCE
|
||||
&& Math.abs(origin.component(axis) - plane) < ON_SIDE_TOLERANCE
|
||||
&& Math.abs(edgeU.component(axis)) < ON_SIDE_TOLERANCE
|
||||
&& Math.abs(edgeV.component(axis)) < ON_SIDE_TOLERANCE;
|
||||
}
|
||||
|
||||
private void fillCells(CubeFace side, boolean[] filled, int cells) {
|
||||
int firstAxis = side.axis() == 0 ? 1 : 0;
|
||||
int secondAxis = side.axis() == 2 ? 1 : 2;
|
||||
double[] firstRange = range(firstAxis);
|
||||
double[] secondRange = range(secondAxis);
|
||||
for (int first = 0; first < cells; first++) {
|
||||
double firstCenter = (first + 0.5D) / cells;
|
||||
if (firstCenter < firstRange[0] || firstCenter > firstRange[1]) {
|
||||
continue;
|
||||
}
|
||||
for (int second = 0; second < cells; second++) {
|
||||
double secondCenter = (second + 0.5D) / cells;
|
||||
if (secondCenter >= secondRange[0] && secondCenter <= secondRange[1]) {
|
||||
filled[first * cells + second] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private double[] range(int axis) {
|
||||
double start = origin.component(axis);
|
||||
double alongU = edgeU.component(axis);
|
||||
double alongV = edgeV.component(axis);
|
||||
double low = start + Math.min(0D, alongU) + Math.min(0D, alongV);
|
||||
double high = start + Math.max(0D, alongU) + Math.max(0D, alongV);
|
||||
return new double[]{low, high};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a face's corner sits in its texture and how the texture point moves along the face's two edges, all as
|
||||
* fractions of the texture's width and height.
|
||||
*/
|
||||
record TextureMapping(double u, double v, double uAlongU, double vAlongU, double uAlongV, double vAlongV) {
|
||||
static final TextureMapping WHOLE = new TextureMapping(0D, 0D, 1D, 0D, 0D, 1D);
|
||||
}
|
||||
|
||||
/**
|
||||
* A box side's corners in Minecraft's order: top left, bottom left, bottom right and top right, as seen from
|
||||
* outside the box.
|
||||
*/
|
||||
static final class BoxCorners {
|
||||
private BoxCorners() {
|
||||
}
|
||||
|
||||
static Vec3[] of(Vec3 from, Vec3 to, CubeFace side) {
|
||||
double minX = from.x();
|
||||
double minY = from.y();
|
||||
double minZ = from.z();
|
||||
double maxX = to.x();
|
||||
double maxY = to.y();
|
||||
double maxZ = to.z();
|
||||
return switch (side) {
|
||||
case DOWN -> corners(minX, minY, maxZ, minX, minY, minZ, maxX, minY, minZ, maxX, minY, maxZ);
|
||||
case UP -> corners(minX, maxY, minZ, minX, maxY, maxZ, maxX, maxY, maxZ, maxX, maxY, minZ);
|
||||
case NORTH -> corners(maxX, maxY, minZ, maxX, minY, minZ, minX, minY, minZ, minX, maxY, minZ);
|
||||
case SOUTH -> corners(minX, maxY, maxZ, minX, minY, maxZ, maxX, minY, maxZ, maxX, maxY, maxZ);
|
||||
case WEST -> corners(minX, maxY, minZ, minX, minY, minZ, minX, minY, maxZ, minX, maxY, maxZ);
|
||||
case EAST -> corners(maxX, maxY, maxZ, maxX, minY, maxZ, maxX, minY, minZ, maxX, maxY, minZ);
|
||||
};
|
||||
}
|
||||
|
||||
private static Vec3[] corners(double... coordinates) {
|
||||
Vec3[] corners = new Vec3[4];
|
||||
for (int corner = 0; corner < 4; corner++) {
|
||||
corners[corner] = new Vec3(coordinates[corner * 3], coordinates[corner * 3 + 1], coordinates[corner * 3 + 2]);
|
||||
}
|
||||
return corners;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.volmit.iris.core.gui.BlockModel.BoxCorners;
|
||||
import com.volmit.iris.core.gui.BlockModel.Face;
|
||||
import com.volmit.iris.core.gui.BlockModel.TextureMapping;
|
||||
import com.volmit.iris.core.gui.BlockTextures.BlockStateId;
|
||||
import com.volmit.iris.core.gui.BlockTextures.BlockTexture;
|
||||
import com.volmit.iris.core.gui.BlockTextures.ModelDefinition;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Reads a block's look the way Minecraft does: every model part the blockstate picks, every box with its own texture
|
||||
* and UVs, tilted boxes, the blockstate's turn with or without locked textures, and which faces a neighbour hides.
|
||||
*/
|
||||
final class BlockModelBuilder {
|
||||
private static final double MODEL_UNITS = 16D;
|
||||
private static final double BLOCK_CENTER = 0.5D;
|
||||
private static final int QUARTER_TURN = 90;
|
||||
|
||||
private final BlockTextures textures;
|
||||
private final EntityBlockShapes entityShapes;
|
||||
|
||||
BlockModelBuilder(BlockTextures textures) {
|
||||
this.textures = textures;
|
||||
this.entityShapes = new EntityBlockShapes(textures);
|
||||
}
|
||||
|
||||
Optional<BlockModel> build(String blockState) {
|
||||
try {
|
||||
BlockStateId state = BlockStateId.parse(blockState);
|
||||
List<JsonObject> placements = placementsOf(state);
|
||||
if (placements == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
List<Face> faces = new ArrayList<>();
|
||||
for (JsonObject placement : placements) {
|
||||
faces.addAll(placedFaces(state, placement));
|
||||
}
|
||||
faces.addAll(entityShapes.facesAddedTo(state));
|
||||
return Optional.of(BlockModel.of(faces));
|
||||
} catch (IOException | RuntimeException error) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A box with Minecraft's default UVs on each side, for shapes the game draws in code.
|
||||
*/
|
||||
static List<Face> box(Vec3 fromPixels, Vec3 toPixels, BlockTexture texture, boolean culledBySides) {
|
||||
List<Face> faces = new ArrayList<>();
|
||||
for (CubeFace side : CubeFace.values()) {
|
||||
FaceUv uv = FaceUv.byDefault(side, fromPixels, toPixels);
|
||||
Face face = boxFace(fromPixels, toPixels, side, texture, uv).withShade(side.shade());
|
||||
faces.add(culledBySides ? face.withCullFace(side) : face);
|
||||
}
|
||||
return faces;
|
||||
}
|
||||
|
||||
// A variant names one model; a multipart blockstate, like a fence's, adds a part for every condition that holds.
|
||||
private List<JsonObject> placementsOf(BlockStateId state) throws IOException {
|
||||
JsonObject definition = textures.readJson(BlockTextures.assetPath(state.namespace() + ":" + state.name(), "blockstates", ".json"));
|
||||
if (definition == null) {
|
||||
return null;
|
||||
}
|
||||
List<JsonObject> placements = new ArrayList<>();
|
||||
if (definition.has("variants")) {
|
||||
for (Map.Entry<String, JsonElement> variant : definition.getAsJsonObject("variants").entrySet()) {
|
||||
if (BlockTextures.variantMatches(variant.getKey(), state.properties())) {
|
||||
addPlacement(placements, variant.getValue());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (definition.has("multipart")) {
|
||||
for (JsonElement part : definition.getAsJsonArray("multipart")) {
|
||||
JsonObject rule = part.getAsJsonObject();
|
||||
if (!rule.has("when") || BlockTextures.conditionMatches(rule.getAsJsonObject("when"), state.properties())) {
|
||||
addPlacement(placements, rule.get("apply"));
|
||||
}
|
||||
}
|
||||
}
|
||||
return placements;
|
||||
}
|
||||
|
||||
private static void addPlacement(List<JsonObject> placements, JsonElement models) {
|
||||
JsonObject first = BlockTextures.firstModel(models);
|
||||
if (first != null && first.has("model")) {
|
||||
placements.add(first);
|
||||
}
|
||||
}
|
||||
|
||||
private List<Face> placedFaces(BlockStateId state, JsonObject placement) throws IOException {
|
||||
ModelDefinition model = textures.readModel(placement.get("model").getAsString());
|
||||
if (model.elements() == null) {
|
||||
return entityShapes.facesOf(state, model);
|
||||
}
|
||||
BlockRotation rotation = BlockRotation.of(placement);
|
||||
List<Face> faces = new ArrayList<>();
|
||||
for (JsonElement element : model.elements()) {
|
||||
addElementFaces(faces, state, model, element.getAsJsonObject(), rotation);
|
||||
}
|
||||
return faces;
|
||||
}
|
||||
|
||||
private void addElementFaces(List<Face> faces, BlockStateId state, ModelDefinition model, JsonObject element, BlockRotation rotation) {
|
||||
JsonObject elementFaces = element.getAsJsonObject("faces");
|
||||
if (elementFaces == null) {
|
||||
return;
|
||||
}
|
||||
Vec3 from = vector(element.getAsJsonArray("from"));
|
||||
Vec3 to = vector(element.getAsJsonArray("to"));
|
||||
Transform tilt = tiltOf(element);
|
||||
boolean shaded = !element.has("shade") || element.get("shade").getAsBoolean();
|
||||
CubeFace shadeSide = element.has("shade_direction_override")
|
||||
? CubeFace.named(element.get("shade_direction_override").getAsString()) : null;
|
||||
for (Map.Entry<String, JsonElement> entry : elementFaces.entrySet()) {
|
||||
CubeFace side = CubeFace.named(entry.getKey());
|
||||
JsonObject faceJson = entry.getValue().getAsJsonObject();
|
||||
if (side == null || !faceJson.has("texture")) {
|
||||
continue;
|
||||
}
|
||||
String textureName = BlockTextures.dereference(faceJson.get("texture").getAsString(), model.textures());
|
||||
if (textureName == null) {
|
||||
continue;
|
||||
}
|
||||
Optional<BlockTexture> texture = textures.faceTexture(state, textureName, BlockTextures.tintIndexOf(faceJson));
|
||||
if (texture.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
FaceUv uv = FaceUv.of(faceJson, side, from, to);
|
||||
if (rotation.locksTextures()) {
|
||||
uv = uv.lockedTo(side, rotation);
|
||||
}
|
||||
Face face = boxFace(from, to, side, texture.get(), uv).transformed(tilt);
|
||||
if (faceJson.has("cullface")) {
|
||||
face = face.withCullFace(CubeFace.named(faceJson.get("cullface").getAsString()));
|
||||
}
|
||||
face = face.transformed(rotation.transform());
|
||||
faces.add(face.withShade(shadeOf(face, shaded, shadeSide, rotation)));
|
||||
}
|
||||
}
|
||||
|
||||
private static Face boxFace(Vec3 fromPixels, Vec3 toPixels, CubeFace side, BlockTexture texture, FaceUv uv) {
|
||||
Vec3[] corners = BoxCorners.of(fromPixels.times(1D / MODEL_UNITS), toPixels.times(1D / MODEL_UNITS), side);
|
||||
return new Face(corners[0], corners[3].minus(corners[0]), corners[1].minus(corners[0]), side.direction(),
|
||||
texture, uv.mapping(), side.shade(), null);
|
||||
}
|
||||
|
||||
private static double shadeOf(Face face, boolean shaded, CubeFace shadeSide, BlockRotation rotation) {
|
||||
if (!shaded) {
|
||||
return CubeFace.UP.shade();
|
||||
}
|
||||
if (shadeSide != null) {
|
||||
return CubeFace.nearest(rotation.transform().direction(shadeSide.direction())).shade();
|
||||
}
|
||||
return CubeFace.nearest(face.normal()).shade();
|
||||
}
|
||||
|
||||
// A box can be tilted around a point, on one axis or, since 1.21.?, on all three; rescaling stretches a tilted
|
||||
// plant back to the full block width.
|
||||
private static Transform tiltOf(JsonObject element) {
|
||||
if (!element.has("rotation")) {
|
||||
return Transform.IDENTITY;
|
||||
}
|
||||
JsonObject rotation = element.getAsJsonObject("rotation");
|
||||
Vec3 origin = rotation.has("origin") ? vector(rotation.getAsJsonArray("origin")).times(1D / MODEL_UNITS) : new Vec3(BLOCK_CENTER, BLOCK_CENTER, BLOCK_CENTER);
|
||||
Transform around = Transform.IDENTITY.translate(origin.x(), origin.y(), origin.z());
|
||||
Transform back = Transform.IDENTITY.translate(-origin.x(), -origin.y(), -origin.z());
|
||||
if (!rotation.has("axis")) {
|
||||
return around
|
||||
.rotateX(numberOr(rotation, "x", 0D))
|
||||
.rotateY(numberOr(rotation, "y", 0D))
|
||||
.rotateZ(numberOr(rotation, "z", 0D))
|
||||
.then(back);
|
||||
}
|
||||
String axis = rotation.get("axis").getAsString();
|
||||
double angle = numberOr(rotation, "angle", 0D);
|
||||
boolean rescale = rotation.has("rescale") && rotation.get("rescale").getAsBoolean();
|
||||
double stretch = rescale ? 1D / Math.cos(Math.toRadians(angle)) : 1D;
|
||||
Transform stretched = around.scale(axis.equals("x") ? 1D : stretch, axis.equals("y") ? 1D : stretch, axis.equals("z") ? 1D : stretch);
|
||||
Transform tilted = switch (axis) {
|
||||
case "x" -> stretched.rotateX(angle);
|
||||
case "y" -> stretched.rotateY(angle);
|
||||
default -> stretched.rotateZ(angle);
|
||||
};
|
||||
return tilted.then(back);
|
||||
}
|
||||
|
||||
private static double numberOr(JsonObject object, String key, double fallback) {
|
||||
return object.has(key) ? object.get(key).getAsDouble() : fallback;
|
||||
}
|
||||
|
||||
private static Vec3 vector(JsonArray values) {
|
||||
return new Vec3(values.get(0).getAsDouble(), values.get(1).getAsDouble(), values.get(2).getAsDouble());
|
||||
}
|
||||
|
||||
/**
|
||||
* How a blockstate turns its model: around the x axis first, then the y axis, both in quarter turns against the
|
||||
* right-hand rule. With uvlock the textures stay put while the shape turns.
|
||||
*/
|
||||
private record BlockRotation(int x, int y, boolean uvlock) {
|
||||
static BlockRotation of(JsonObject placement) {
|
||||
int x = placement.has("x") ? Math.floorMod(placement.get("x").getAsInt(), 360) : 0;
|
||||
int y = placement.has("y") ? Math.floorMod(placement.get("y").getAsInt(), 360) : 0;
|
||||
return new BlockRotation(x, y, placement.has("uvlock") && placement.get("uvlock").getAsBoolean());
|
||||
}
|
||||
|
||||
boolean locksTextures() {
|
||||
return uvlock && (x != 0 || y != 0);
|
||||
}
|
||||
|
||||
Transform transform() {
|
||||
return Transform.IDENTITY
|
||||
.translate(BLOCK_CENTER, BLOCK_CENTER, BLOCK_CENTER)
|
||||
.then(turn())
|
||||
.translate(-BLOCK_CENTER, -BLOCK_CENTER, -BLOCK_CENTER);
|
||||
}
|
||||
|
||||
Transform turn() {
|
||||
return Transform.IDENTITY.rotateY(-y).rotateX(-x);
|
||||
}
|
||||
|
||||
Transform inverseTurn() {
|
||||
return Transform.IDENTITY.rotateX(x).rotateY(y);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A face's corner UVs in model pixels and the quarter turn of the texture on it.
|
||||
*/
|
||||
private record FaceUv(double u1, double v1, double u2, double v2, int rotation) {
|
||||
static FaceUv of(JsonObject face, CubeFace side, Vec3 from, Vec3 to) {
|
||||
int rotation = face.has("rotation") ? Math.floorMod(face.get("rotation").getAsInt(), 360) : 0;
|
||||
if (!face.has("uv")) {
|
||||
return byDefault(side, from, to).withRotation(rotation);
|
||||
}
|
||||
JsonArray uv = face.getAsJsonArray("uv");
|
||||
return new FaceUv(uv.get(0).getAsDouble(), uv.get(1).getAsDouble(), uv.get(2).getAsDouble(), uv.get(3).getAsDouble(), rotation);
|
||||
}
|
||||
|
||||
// The part of the texture the face would cover if the texture were laid over the whole block side.
|
||||
static FaceUv byDefault(CubeFace side, Vec3 from, Vec3 to) {
|
||||
double full = MODEL_UNITS;
|
||||
return switch (side) {
|
||||
case DOWN -> new FaceUv(from.x(), full - to.z(), to.x(), full - from.z(), 0);
|
||||
case UP -> new FaceUv(from.x(), from.z(), to.x(), to.z(), 0);
|
||||
case NORTH -> new FaceUv(full - to.x(), full - to.y(), full - from.x(), full - from.y(), 0);
|
||||
case SOUTH -> new FaceUv(from.x(), full - to.y(), to.x(), full - from.y(), 0);
|
||||
case WEST -> new FaceUv(from.z(), full - to.y(), to.z(), full - from.y(), 0);
|
||||
case EAST -> new FaceUv(full - to.z(), full - to.y(), full - from.z(), full - from.y(), 0);
|
||||
};
|
||||
}
|
||||
|
||||
FaceUv withRotation(int changedRotation) {
|
||||
return new FaceUv(u1, v1, u2, v2, changedRotation);
|
||||
}
|
||||
|
||||
// Each corner takes the UV a quarter turn further along for every quarter turn of the texture.
|
||||
TextureMapping mapping() {
|
||||
double[] origin = cornerUv(0);
|
||||
double[] alongU = cornerUv(3);
|
||||
double[] alongV = cornerUv(1);
|
||||
return new TextureMapping(origin[0] / MODEL_UNITS, origin[1] / MODEL_UNITS,
|
||||
(alongU[0] - origin[0]) / MODEL_UNITS, (alongU[1] - origin[1]) / MODEL_UNITS,
|
||||
(alongV[0] - origin[0]) / MODEL_UNITS, (alongV[1] - origin[1]) / MODEL_UNITS);
|
||||
}
|
||||
|
||||
private double[] cornerUv(int corner) {
|
||||
return switch ((corner + rotation / QUARTER_TURN) % 4) {
|
||||
case 0 -> new double[]{u1, v1};
|
||||
case 1 -> new double[]{u1, v2};
|
||||
case 2 -> new double[]{u2, v2};
|
||||
default -> new double[]{u2, v1};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Minecraft's uvlock: the UVs are carried through the reverse of the blockstate's turn, so the texture keeps
|
||||
* its place in the world while the face moves to another side.
|
||||
*/
|
||||
FaceUv lockedTo(CubeFace side, BlockRotation blockRotation) {
|
||||
CubeFace turnedSide = CubeFace.nearest(blockRotation.turn().direction(side.direction()));
|
||||
Transform uvTurn = Transform.IDENTITY
|
||||
.translate(BLOCK_CENTER, BLOCK_CENTER, BLOCK_CENTER)
|
||||
.then(faceFromWorld(side))
|
||||
.then(blockRotation.inverseTurn())
|
||||
.then(worldFromFace(turnedSide))
|
||||
.translate(-BLOCK_CENTER, -BLOCK_CENTER, -BLOCK_CENTER);
|
||||
Vec3 first = uvTurn.point(new Vec3(u1 / MODEL_UNITS, v1 / MODEL_UNITS, 0D)).times(MODEL_UNITS);
|
||||
Vec3 second = uvTurn.point(new Vec3(u2 / MODEL_UNITS, v2 / MODEL_UNITS, 0D)).times(MODEL_UNITS);
|
||||
boolean keepsUOrder = Math.signum(u2 - u1) == Math.signum(second.x() - first.x());
|
||||
boolean keepsVOrder = Math.signum(v2 - v1) == Math.signum(second.y() - first.y());
|
||||
Vec3 textureRight = uvTurn.direction(new Vec3(Math.cos(Math.toRadians(rotation)), Math.sin(Math.toRadians(rotation)), 0D));
|
||||
long quarterTurns = Math.round(Math.toDegrees(Math.atan2(textureRight.y(), textureRight.x())) / QUARTER_TURN);
|
||||
return new FaceUv(
|
||||
keepsUOrder ? first.x() : second.x(), keepsVOrder ? first.y() : second.y(),
|
||||
keepsUOrder ? second.x() : first.x(), keepsVOrder ? second.y() : first.y(),
|
||||
(int) Math.floorMod(-quarterTurns * QUARTER_TURN, 360L));
|
||||
}
|
||||
|
||||
// Minecraft lays every side's texture out as if it were the south side, turned into place.
|
||||
private static Transform worldFromFace(CubeFace side) {
|
||||
return switch (side) {
|
||||
case SOUTH -> Transform.IDENTITY;
|
||||
case EAST -> Transform.IDENTITY.rotateY(90D);
|
||||
case WEST -> Transform.IDENTITY.rotateY(-90D);
|
||||
case NORTH -> Transform.IDENTITY.rotateY(180D);
|
||||
case UP -> Transform.IDENTITY.rotateX(-90D);
|
||||
case DOWN -> Transform.IDENTITY.rotateX(90D);
|
||||
};
|
||||
}
|
||||
|
||||
private static Transform faceFromWorld(CubeFace side) {
|
||||
return switch (side) {
|
||||
case SOUTH -> Transform.IDENTITY;
|
||||
case EAST -> Transform.IDENTITY.rotateY(-90D);
|
||||
case WEST -> Transform.IDENTITY.rotateY(90D);
|
||||
case NORTH -> Transform.IDENTITY.rotateY(180D);
|
||||
case UP -> Transform.IDENTITY.rotateX(90D);
|
||||
case DOWN -> Transform.IDENTITY.rotateX(-90D);
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
import org.bukkit.block.data.BlockData;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
final class BlockPreviewColors {
|
||||
private static final Method MAP_COLOR_METHOD = findMapColorMethod();
|
||||
private static final double[] MAP_SHADES = {1D, 0.86D, 0.71D, 0.53D};
|
||||
private static final int NO_MAP_COLOR = 0;
|
||||
|
||||
private BlockPreviewColors() {
|
||||
}
|
||||
|
||||
static Map<String, Integer> colorsByBlock(List<BlockData> blocks) {
|
||||
Map<String, Integer> colors = new HashMap<>();
|
||||
List<Integer> usedColors = new ArrayList<>();
|
||||
for (BlockData block : blocks) {
|
||||
String key = block.getAsString(true);
|
||||
if (colors.containsKey(key)) {
|
||||
continue;
|
||||
}
|
||||
int baseColor = baseColor(block);
|
||||
int color = firstUnusedShade(baseColor, usedColors);
|
||||
usedColors.add(color);
|
||||
colors.put(key, color);
|
||||
}
|
||||
return colors;
|
||||
}
|
||||
|
||||
// Blocks that share a map colour, like sand and sandstone, get darker shades the way Minecraft maps shade them.
|
||||
private static int firstUnusedShade(int baseColor, List<Integer> usedColors) {
|
||||
for (double shade : MAP_SHADES) {
|
||||
int shaded = shade(baseColor, shade);
|
||||
if (!usedColors.contains(shaded)) {
|
||||
return shaded;
|
||||
}
|
||||
}
|
||||
return baseColor;
|
||||
}
|
||||
|
||||
private static int baseColor(BlockData block) {
|
||||
int mapColor = mapColor(block);
|
||||
return mapColor == NO_MAP_COLOR ? colorFromName(block.getMaterial().getKey().getKey()) : mapColor;
|
||||
}
|
||||
|
||||
private static int mapColor(BlockData block) {
|
||||
if (MAP_COLOR_METHOD == null) {
|
||||
return NO_MAP_COLOR;
|
||||
}
|
||||
try {
|
||||
return ((org.bukkit.Color) MAP_COLOR_METHOD.invoke(block)).asRGB();
|
||||
} catch (ReflectiveOperationException | RuntimeException error) {
|
||||
return NO_MAP_COLOR;
|
||||
}
|
||||
}
|
||||
|
||||
private static int colorFromName(String name) {
|
||||
float hue = (name.hashCode() & 0xFFFF) / (float) 0xFFFF;
|
||||
return Color.HSBtoRGB(hue, 0.45F, 0.75F) & 0xFFFFFF;
|
||||
}
|
||||
|
||||
static int shade(int color, double shade) {
|
||||
int red = (int) Math.min(255, ((color >>> 16) & 0xFF) * shade);
|
||||
int green = (int) Math.min(255, ((color >>> 8) & 0xFF) * shade);
|
||||
int blue = (int) Math.min(255, (color & 0xFF) * shade);
|
||||
return (red << 16) | (green << 8) | blue;
|
||||
}
|
||||
|
||||
// Iris compiles against the 1.20.1 API, which predates BlockData#getMapColor.
|
||||
private static Method findMapColorMethod() {
|
||||
try {
|
||||
return BlockData.class.getMethod("getMapColor");
|
||||
} catch (NoSuchMethodException error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import org.bukkit.block.data.Bisected;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.IOException;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.zip.ZipFile;
|
||||
|
||||
final class BlockTextures {
|
||||
private static final String DEFAULT_NAMESPACE = "minecraft";
|
||||
private static final int MAX_MODEL_PARENTS = 16;
|
||||
private static final int MAX_TEXTURE_REFERENCES = 16;
|
||||
static final int NO_TINT_INDEX = -1;
|
||||
private static final Object SHARED_LOCK = new Object();
|
||||
private static CompletableFuture<BlockTextures> shared;
|
||||
|
||||
private final ZipFile clientJar;
|
||||
private final MinecraftBiomeColors biomeColors;
|
||||
private final Map<String, Optional<ResolvedTexture>> resolvedTextures = new ConcurrentHashMap<>();
|
||||
private final Map<String, Optional<BlockModel>> resolvedModels = new ConcurrentHashMap<>();
|
||||
private final Map<String, Optional<BlockTexture>> faceTextures = new ConcurrentHashMap<>();
|
||||
private final BlockModelBuilder modelBuilder = new BlockModelBuilder(this);
|
||||
|
||||
private BlockTextures(ZipFile clientJar, MinecraftBiomeColors biomeColors) {
|
||||
this.clientJar = clientJar;
|
||||
this.biomeColors = biomeColors;
|
||||
}
|
||||
|
||||
static CompletableFuture<BlockTextures> shared() {
|
||||
synchronized (SHARED_LOCK) {
|
||||
if (shared == null || shared.isCompletedExceptionally()) {
|
||||
shared = MinecraftClientJar.open().thenApply(BlockTextures::load);
|
||||
}
|
||||
return shared;
|
||||
}
|
||||
}
|
||||
|
||||
private static BlockTextures load(ZipFile clientJar) {
|
||||
try {
|
||||
return new BlockTextures(clientJar, MinecraftBiomeColors.load(clientJar));
|
||||
} catch (IOException error) {
|
||||
throw new IllegalStateException("Could not read Minecraft's biome colour maps", error);
|
||||
}
|
||||
}
|
||||
|
||||
MinecraftBiomeColors biomeColors() {
|
||||
return biomeColors;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the block looks like from above. Plants and other side-on sprites become their average colour, because
|
||||
* their sprite would read as stripes across a stack of ground blocks.
|
||||
*/
|
||||
Optional<BlockTexture> topTexture(BlockData block) {
|
||||
return resolve(block).map(resolved -> resolved.isSideOnSprite() ? resolved.texture().flattened() : resolved.texture());
|
||||
}
|
||||
|
||||
/**
|
||||
* Like {@link #topTexture}, but plants keep their sprite, so single decorations on the ground stay recognisable.
|
||||
* Two block tall plants give their bottom half, the part standing on the ground.
|
||||
*/
|
||||
Optional<BlockTexture> iconTexture(BlockData block) {
|
||||
BlockData standing = block instanceof Bisected ? withHalf(block, Bisected.Half.BOTTOM) : block;
|
||||
return resolve(standing).map(ResolvedTexture::texture);
|
||||
}
|
||||
|
||||
/**
|
||||
* The sprite of a two block tall plant's top half, or nothing for any other block.
|
||||
*/
|
||||
Optional<BlockTexture> upperHalfIcon(BlockData block) {
|
||||
if (!(block instanceof Bisected)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return resolve(withHalf(block, Bisected.Half.TOP)).filter(ResolvedTexture::isSideOnSprite).map(ResolvedTexture::texture);
|
||||
}
|
||||
|
||||
/**
|
||||
* What the block looks like from every side, for drawing it in 3D.
|
||||
*/
|
||||
Optional<BlockModel> model(BlockData block) {
|
||||
return resolvedModels.computeIfAbsent(block.getAsString(false), modelBuilder::build);
|
||||
}
|
||||
|
||||
private static BlockData withHalf(BlockData block, Bisected.Half half) {
|
||||
BlockData copy = block.clone();
|
||||
((Bisected) copy).setHalf(half);
|
||||
return copy;
|
||||
}
|
||||
|
||||
// Blockstate files match on every property, so defaults the pack left out have to be spelled out too.
|
||||
private Optional<ResolvedTexture> resolve(BlockData block) {
|
||||
return resolvedTextures.computeIfAbsent(block.getAsString(false), this::resolveTopTexture);
|
||||
}
|
||||
|
||||
private Optional<ResolvedTexture> resolveTopTexture(String blockState) {
|
||||
try {
|
||||
BlockStateId state = BlockStateId.parse(blockState);
|
||||
JsonObject variant = variantOf(state);
|
||||
if (variant == null || !variant.has("model")) {
|
||||
return Optional.empty();
|
||||
}
|
||||
ModelFace face = readModel(variant.get("model").getAsString()).visibleFace(faceSeenFromAbove(variant));
|
||||
if (face == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return readTexture(state, face).map(texture -> new ResolvedTexture(texture, face.isSideOnSprite()));
|
||||
} catch (IOException | RuntimeException error) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private JsonObject variantOf(BlockStateId state) throws IOException {
|
||||
JsonObject definition = readJson(assetPath(state.namespace() + ":" + state.name(), "blockstates", ".json"));
|
||||
return definition == null ? null : pickVariant(definition, state.properties());
|
||||
}
|
||||
|
||||
private static int rotationOf(JsonObject variant, String axis) {
|
||||
return variant.has(axis) ? Math.floorMod(variant.get(axis).getAsInt(), 360) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* One model face's texture with the block's tint, shared by every block state that uses it.
|
||||
*/
|
||||
Optional<BlockTexture> faceTexture(BlockStateId state, String texture, int tintIndex) {
|
||||
int fixedTint = MinecraftBlockTints.fixedTint(state.name(), state.properties(), tintIndex);
|
||||
BiomeTint biomeTint = MinecraftBlockTints.biomeTint(state.name(), tintIndex).orElse(null);
|
||||
return faceTextures.computeIfAbsent(texture + "|" + fixedTint + "|" + biomeTint, key -> {
|
||||
try {
|
||||
return readTexture(state, new ModelFace(List.of(texture), tintIndex, false));
|
||||
} catch (IOException | RuntimeException error) {
|
||||
return Optional.empty();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A texture drawn on a block entity, like a chest, whole and tinted with a fixed colour.
|
||||
*/
|
||||
Optional<BlockTexture> entityTexture(String texture, int tint) {
|
||||
return faceTextures.computeIfAbsent(texture + "|entity|" + tint, key -> {
|
||||
try {
|
||||
BufferedImage image = readImage(assetPath(texture, "textures", ".png"));
|
||||
return image == null ? Optional.empty() : Optional.of(BlockTexture.fromWholeImage(image, tint));
|
||||
} catch (IOException | RuntimeException error) {
|
||||
return Optional.empty();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private Optional<BlockTexture> readTexture(BlockStateId state, ModelFace face) throws IOException {
|
||||
BufferedImage image = readLayeredImage(face.textures());
|
||||
if (image == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
return Optional.of(BlockTexture.fromImage(
|
||||
image,
|
||||
MinecraftBlockTints.fixedTint(state.name(), state.properties(), face.tintIndex()),
|
||||
MinecraftBlockTints.biomeTint(state.name(), face.tintIndex()).orElse(null)
|
||||
));
|
||||
} catch (IllegalArgumentException invisibleTexture) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
// A block rotated on its x axis, like a sideways log, shows a side face on top. Blockstates turn the model
|
||||
// against the right-hand rule, so 90 degrees brings the south side up.
|
||||
private static String faceSeenFromAbove(JsonObject variant) {
|
||||
return switch (rotationOf(variant, "x")) {
|
||||
case 90 -> "south";
|
||||
case 270 -> "north";
|
||||
case 180 -> "down";
|
||||
default -> "up";
|
||||
};
|
||||
}
|
||||
|
||||
ModelDefinition readModel(String modelReference) throws IOException {
|
||||
Map<String, String> textures = new HashMap<>();
|
||||
JsonArray elements = null;
|
||||
String current = modelReference;
|
||||
for (int depth = 0; current != null && depth < MAX_MODEL_PARENTS; depth++) {
|
||||
JsonObject model = readJson(assetPath(current, "models", ".json"));
|
||||
if (model == null) {
|
||||
break;
|
||||
}
|
||||
if (model.has("textures")) {
|
||||
for (Map.Entry<String, JsonElement> texture : model.getAsJsonObject("textures").entrySet()) {
|
||||
textures.putIfAbsent(texture.getKey(), spriteName(texture.getValue()));
|
||||
}
|
||||
}
|
||||
if (elements == null && model.has("elements")) {
|
||||
elements = model.getAsJsonArray("elements");
|
||||
}
|
||||
current = model.has("parent") ? model.get("parent").getAsString() : null;
|
||||
}
|
||||
return new ModelDefinition(textures, elements);
|
||||
}
|
||||
|
||||
// Since 1.21.? a texture can also be an object naming its sprite, like glass's {"sprite": ..., "force_translucent": true}.
|
||||
private static String spriteName(JsonElement texture) {
|
||||
return texture.isJsonObject() ? texture.getAsJsonObject().get("sprite").getAsString() : texture.getAsString();
|
||||
}
|
||||
|
||||
// Some plants draw a second sprite over the first, like the glowing fireflies on a firefly bush.
|
||||
private static List<String> spriteLayers(JsonArray elements, Map<String, String> textures) {
|
||||
Set<String> layers = new LinkedHashSet<>();
|
||||
for (JsonElement element : elements) {
|
||||
JsonObject faces = element.getAsJsonObject().getAsJsonObject("faces");
|
||||
if (faces == null) {
|
||||
continue;
|
||||
}
|
||||
for (Map.Entry<String, JsonElement> face : faces.entrySet()) {
|
||||
String texture = dereference(face.getValue().getAsJsonObject().get("texture").getAsString(), textures);
|
||||
if (texture != null) {
|
||||
layers.add(texture);
|
||||
}
|
||||
}
|
||||
}
|
||||
return List.copyOf(layers);
|
||||
}
|
||||
|
||||
private static int firstTintIndex(JsonArray elements) {
|
||||
for (JsonElement element : elements) {
|
||||
JsonObject faces = element.getAsJsonObject().getAsJsonObject("faces");
|
||||
if (faces == null) {
|
||||
continue;
|
||||
}
|
||||
for (Map.Entry<String, JsonElement> face : faces.entrySet()) {
|
||||
int tintIndex = tintIndexOf(face.getValue().getAsJsonObject());
|
||||
if (tintIndex != NO_TINT_INDEX) {
|
||||
return tintIndex;
|
||||
}
|
||||
}
|
||||
}
|
||||
return NO_TINT_INDEX;
|
||||
}
|
||||
|
||||
static int tintIndexOf(JsonObject face) {
|
||||
return face.has("tintindex") ? face.get("tintindex").getAsInt() : NO_TINT_INDEX;
|
||||
}
|
||||
|
||||
private static JsonObject topmostFace(JsonArray elements, String faceName) {
|
||||
JsonObject best = null;
|
||||
double bestHeight = Double.NEGATIVE_INFINITY;
|
||||
double bestArea = -1D;
|
||||
for (JsonElement element : elements) {
|
||||
JsonObject cuboid = element.getAsJsonObject();
|
||||
JsonObject faces = cuboid.getAsJsonObject("faces");
|
||||
if (faces == null || !faces.has(faceName)) {
|
||||
continue;
|
||||
}
|
||||
JsonArray from = cuboid.getAsJsonArray("from");
|
||||
JsonArray to = cuboid.getAsJsonArray("to");
|
||||
double height = to.get(1).getAsDouble();
|
||||
double area = (to.get(0).getAsDouble() - from.get(0).getAsDouble()) * (to.get(2).getAsDouble() - from.get(2).getAsDouble());
|
||||
if (height > bestHeight || (height == bestHeight && area > bestArea)) {
|
||||
best = faces.getAsJsonObject(faceName);
|
||||
bestHeight = height;
|
||||
bestArea = area;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
static String dereference(String reference, Map<String, String> textures) {
|
||||
String resolved = reference;
|
||||
for (int hop = 0; resolved != null && resolved.startsWith("#") && hop < MAX_TEXTURE_REFERENCES; hop++) {
|
||||
resolved = textures.get(resolved.substring(1));
|
||||
}
|
||||
return resolved == null || resolved.startsWith("#") ? null : resolved;
|
||||
}
|
||||
|
||||
private static JsonObject pickVariant(JsonObject definition, Map<String, String> properties) {
|
||||
if (definition.has("variants")) {
|
||||
for (Map.Entry<String, JsonElement> variant : definition.getAsJsonObject("variants").entrySet()) {
|
||||
if (variantMatches(variant.getKey(), properties)) {
|
||||
return firstModel(variant.getValue());
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (definition.has("multipart")) {
|
||||
for (JsonElement part : definition.getAsJsonArray("multipart")) {
|
||||
JsonObject rule = part.getAsJsonObject();
|
||||
if (!rule.has("when") || conditionMatches(rule.getAsJsonObject("when"), properties)) {
|
||||
return firstModel(rule.get("apply"));
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static boolean variantMatches(String variantKey, Map<String, String> properties) {
|
||||
if (variantKey.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
for (String condition : variantKey.split(",")) {
|
||||
String[] pair = condition.split("=", 2);
|
||||
if (pair.length != 2 || !pair[1].equals(properties.get(pair[0]))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static boolean conditionMatches(JsonObject condition, Map<String, String> properties) {
|
||||
if (condition.has("OR")) {
|
||||
for (JsonElement option : condition.getAsJsonArray("OR")) {
|
||||
if (conditionMatches(option.getAsJsonObject(), properties)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (condition.has("AND")) {
|
||||
for (JsonElement requirement : condition.getAsJsonArray("AND")) {
|
||||
if (!conditionMatches(requirement.getAsJsonObject(), properties)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
for (Map.Entry<String, JsonElement> requirement : condition.entrySet()) {
|
||||
String actual = properties.get(requirement.getKey());
|
||||
boolean anyAllowed = false;
|
||||
for (String allowed : requirement.getValue().getAsString().split("\\|")) {
|
||||
anyAllowed |= allowed.equals(actual);
|
||||
}
|
||||
if (!anyAllowed) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static JsonObject firstModel(JsonElement models) {
|
||||
if (models.isJsonArray()) {
|
||||
JsonArray choices = models.getAsJsonArray();
|
||||
return choices.isEmpty() ? null : choices.get(0).getAsJsonObject();
|
||||
}
|
||||
return models.getAsJsonObject();
|
||||
}
|
||||
|
||||
static String assetPath(String resource, String folder, String extension) {
|
||||
int separator = resource.indexOf(':');
|
||||
String namespace = separator < 0 ? DEFAULT_NAMESPACE : resource.substring(0, separator);
|
||||
String path = separator < 0 ? resource : resource.substring(separator + 1);
|
||||
return "assets/" + namespace + "/" + folder + "/" + path + extension;
|
||||
}
|
||||
|
||||
JsonObject readJson(String path) throws IOException {
|
||||
return ClientJarFiles.readJson(clientJar, path);
|
||||
}
|
||||
|
||||
// Each layer is drawn over the ones before it, using only its first frame when it is animated.
|
||||
private BufferedImage readLayeredImage(List<String> layers) throws IOException {
|
||||
BufferedImage bottom = readImage(assetPath(layers.get(0), "textures", ".png"));
|
||||
if (bottom == null || layers.size() == 1) {
|
||||
return bottom;
|
||||
}
|
||||
int size = bottom.getWidth();
|
||||
BufferedImage combined = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB);
|
||||
Graphics2D canvas = combined.createGraphics();
|
||||
try {
|
||||
for (String layer : layers) {
|
||||
BufferedImage image = readImage(assetPath(layer, "textures", ".png"));
|
||||
if (image != null) {
|
||||
int frameSize = image.getWidth();
|
||||
canvas.drawImage(image, 0, 0, size, size, 0, 0, frameSize, frameSize, null);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
canvas.dispose();
|
||||
}
|
||||
return combined;
|
||||
}
|
||||
|
||||
private BufferedImage readImage(String path) throws IOException {
|
||||
return ClientJarFiles.readImage(clientJar, path);
|
||||
}
|
||||
|
||||
// Plants and other blocks without a top face only have a side-on sprite, which would read as stripes from above.
|
||||
private record ModelFace(List<String> textures, int tintIndex, boolean isSideOnSprite) {
|
||||
}
|
||||
|
||||
/**
|
||||
* A block model with its parents' textures merged in. Models drawn in code, like chests, have no elements.
|
||||
*/
|
||||
record ModelDefinition(Map<String, String> textures, JsonArray elements) {
|
||||
ModelFace visibleFace(String faceName) {
|
||||
ModelFace face = sideFace(faceName);
|
||||
if (face != null) {
|
||||
return face;
|
||||
}
|
||||
return elements != null && !spriteLayers(elements, textures).isEmpty() ? spriteFace() : particleFace();
|
||||
}
|
||||
|
||||
// The face's texture on the model's topmost box, the particle texture for models without boxes, or null when
|
||||
// the model leaves that side open.
|
||||
ModelFace sideFace(String faceName) {
|
||||
if (elements == null) {
|
||||
return particleFace();
|
||||
}
|
||||
JsonObject face = topmostFace(elements, faceName);
|
||||
if (face == null) {
|
||||
return null;
|
||||
}
|
||||
String texture = dereference(face.get("texture").getAsString(), textures);
|
||||
return texture == null ? null : new ModelFace(List.of(texture), tintIndexOf(face), false);
|
||||
}
|
||||
|
||||
ModelFace spriteFace() {
|
||||
return new ModelFace(spriteLayers(elements, textures), firstTintIndex(elements), true);
|
||||
}
|
||||
|
||||
private ModelFace particleFace() {
|
||||
String particle = dereference("#particle", textures);
|
||||
return particle == null ? null : new ModelFace(List.of(particle), NO_TINT_INDEX, false);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private record ResolvedTexture(BlockTexture texture, boolean isSideOnSprite) {
|
||||
}
|
||||
|
||||
record BlockStateId(String namespace, String name, Map<String, String> properties) {
|
||||
static BlockStateId parse(String blockState) {
|
||||
int propertiesStart = blockState.indexOf('[');
|
||||
String id = propertiesStart < 0 ? blockState : blockState.substring(0, propertiesStart);
|
||||
int separator = id.indexOf(':');
|
||||
String namespace = separator < 0 ? DEFAULT_NAMESPACE : id.substring(0, separator);
|
||||
String name = separator < 0 ? id : id.substring(separator + 1);
|
||||
Map<String, String> properties = new HashMap<>();
|
||||
if (propertiesStart >= 0) {
|
||||
String body = blockState.substring(propertiesStart + 1, blockState.length() - 1);
|
||||
for (String pair : body.split(",")) {
|
||||
String[] keyValue = pair.split("=", 2);
|
||||
if (keyValue.length == 2) {
|
||||
properties.put(keyValue[0], keyValue[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return new BlockStateId(namespace, name, properties);
|
||||
}
|
||||
}
|
||||
|
||||
// Biome tinted textures keep their grey pixels, because the biome colour changes from column to column.
|
||||
// See-through pixels get a darker shade of the average colour, and the opaque mask remembers which they were.
|
||||
// Textures with partly see-through pixels, like stained glass, ice and water, keep each pixel's alpha too.
|
||||
record BlockTexture(int width, int height, int[] pixels, boolean[] opaque, int[] alphas, int averageColor,
|
||||
BiomeTint biomeTint) {
|
||||
private static final double GAP_SHADE = 0.55D;
|
||||
private static final int FULL_ALPHA = 255;
|
||||
|
||||
// Animated textures stack their frames vertically, so only the first square frame is used.
|
||||
static BlockTexture fromImage(BufferedImage image, int fixedTint, BiomeTint biomeTint) {
|
||||
return fromPixels(image, image.getWidth(), Math.min(image.getWidth(), image.getHeight()), fixedTint, biomeTint);
|
||||
}
|
||||
|
||||
// Entity textures, like a chest's, are laid out for a model and can be wider than they are tall.
|
||||
static BlockTexture fromWholeImage(BufferedImage image, int fixedTint) {
|
||||
return fromPixels(image, image.getWidth(), image.getHeight(), fixedTint, null);
|
||||
}
|
||||
|
||||
static BlockTexture solid(int color) {
|
||||
return new BlockTexture(1, 1, new int[]{color}, new boolean[]{true}, null, color, null);
|
||||
}
|
||||
|
||||
private static BlockTexture fromPixels(BufferedImage image, int width, int height, int fixedTint, BiomeTint biomeTint) {
|
||||
int[] pixels = new int[width * height];
|
||||
boolean[] opaque = new boolean[pixels.length];
|
||||
int[] alphas = new int[pixels.length];
|
||||
boolean translucent = false;
|
||||
long red = 0;
|
||||
long green = 0;
|
||||
long blue = 0;
|
||||
int opaqueCount = 0;
|
||||
for (int y = 0; y < height; y++) {
|
||||
for (int x = 0; x < width; x++) {
|
||||
int argb = image.getRGB(x, y);
|
||||
int index = y * width + x;
|
||||
int alpha = argb >>> 24;
|
||||
alphas[index] = alpha;
|
||||
if (alpha == 0) {
|
||||
continue;
|
||||
}
|
||||
translucent |= alpha < FULL_ALPHA;
|
||||
int color = multiply(argb & 0xFFFFFF, fixedTint);
|
||||
pixels[index] = color;
|
||||
opaque[index] = true;
|
||||
red += (color >>> 16) & 0xFF;
|
||||
green += (color >>> 8) & 0xFF;
|
||||
blue += color & 0xFF;
|
||||
opaqueCount++;
|
||||
}
|
||||
}
|
||||
if (opaqueCount == 0) {
|
||||
throw new IllegalArgumentException("Texture has no visible pixels");
|
||||
}
|
||||
int average = (int) (red / opaqueCount) << 16 | (int) (green / opaqueCount) << 8 | (int) (blue / opaqueCount);
|
||||
int gapColor = BlockPreviewColors.shade(average, GAP_SHADE);
|
||||
for (int index = 0; index < pixels.length; index++) {
|
||||
if (!opaque[index]) {
|
||||
pixels[index] = gapColor;
|
||||
}
|
||||
}
|
||||
return new BlockTexture(width, height, pixels, opaque, translucent ? alphas : null, average, biomeTint);
|
||||
}
|
||||
|
||||
boolean isTranslucent() {
|
||||
return alphas != null;
|
||||
}
|
||||
|
||||
boolean isFullyOpaque() {
|
||||
if (isTranslucent()) {
|
||||
return false;
|
||||
}
|
||||
for (boolean pixelOpaque : opaque) {
|
||||
if (!pixelOpaque) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
BlockTexture flattened() {
|
||||
int[] flat = new int[pixels.length];
|
||||
Arrays.fill(flat, averageColor);
|
||||
boolean[] allOpaque = new boolean[pixels.length];
|
||||
Arrays.fill(allOpaque, true);
|
||||
return new BlockTexture(width, height, flat, allOpaque, null, averageColor, biomeTint);
|
||||
}
|
||||
|
||||
BlockTexture tinted(int tint) {
|
||||
int[] tintedPixels = new int[pixels.length];
|
||||
for (int index = 0; index < pixels.length; index++) {
|
||||
tintedPixels[index] = multiply(pixels[index], tint);
|
||||
}
|
||||
return new BlockTexture(width, height, tintedPixels, opaque, alphas, multiply(averageColor, tint), null);
|
||||
}
|
||||
|
||||
int texelAt(double worldX, double worldZ) {
|
||||
return pixels[texelIndex(worldX, worldZ)];
|
||||
}
|
||||
|
||||
boolean isOpaqueAt(double worldX, double worldZ) {
|
||||
return opaque[texelIndex(worldX, worldZ)];
|
||||
}
|
||||
|
||||
private int texelIndex(double worldX, double worldZ) {
|
||||
int x = Math.min(width - 1, (int) ((worldX - Math.floor(worldX)) * width));
|
||||
int z = Math.min(height - 1, (int) ((worldZ - Math.floor(worldZ)) * height));
|
||||
return z * width + x;
|
||||
}
|
||||
|
||||
BufferedImage toImage() {
|
||||
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
|
||||
image.setRGB(0, 0, width, height, pixels, 0, width);
|
||||
return image;
|
||||
}
|
||||
|
||||
// The GPU multiplies the texture by the tint in floating point and rounds back to 8 bits per channel.
|
||||
static int multiply(int color, int tint) {
|
||||
return multiplyChannel(color, tint, 16) << 16 | multiplyChannel(color, tint, 8) << 8 | multiplyChannel(color, tint, 0);
|
||||
}
|
||||
|
||||
private static int multiplyChannel(int color, int tint, int shift) {
|
||||
return (((color >>> shift) & 0xFF) * ((tint >>> shift) & 0xFF) + 127) / 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.color.ColorSpace;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.awt.image.Raster;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipFile;
|
||||
|
||||
final class ClientJarFiles {
|
||||
private ClientJarFiles() {
|
||||
}
|
||||
|
||||
static JsonObject readJson(ZipFile clientJar, String path) throws IOException {
|
||||
ZipEntry entry = clientJar.getEntry(path);
|
||||
if (entry == null) {
|
||||
return null;
|
||||
}
|
||||
try (InputStreamReader reader = new InputStreamReader(clientJar.getInputStream(entry), StandardCharsets.UTF_8)) {
|
||||
return JsonParser.parseReader(reader).getAsJsonObject();
|
||||
}
|
||||
}
|
||||
|
||||
static BufferedImage readImage(ZipFile clientJar, String path) throws IOException {
|
||||
ZipEntry entry = clientJar.getEntry(path);
|
||||
if (entry == null) {
|
||||
return null;
|
||||
}
|
||||
try (InputStream input = clientJar.getInputStream(entry)) {
|
||||
BufferedImage image = ImageIO.read(input);
|
||||
return image != null && isGrey(image) ? withGreyKept(image) : image;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isGrey(BufferedImage image) {
|
||||
return image.getColorModel().getColorSpace().getType() == ColorSpace.TYPE_GRAY;
|
||||
}
|
||||
|
||||
// Java treats grey PNGs, like stone's, as linear light and brightens them when converting to RGB; Minecraft reads
|
||||
// the stored values as they are.
|
||||
private static BufferedImage withGreyKept(BufferedImage image) {
|
||||
Raster raster = image.getRaster();
|
||||
boolean hasAlpha = raster.getNumBands() > 1;
|
||||
int greyMax = (1 << raster.getSampleModel().getSampleSize(0)) - 1;
|
||||
int alphaMax = hasAlpha ? (1 << raster.getSampleModel().getSampleSize(1)) - 1 : 1;
|
||||
BufferedImage converted = new BufferedImage(image.getWidth(), image.getHeight(), BufferedImage.TYPE_INT_ARGB);
|
||||
for (int y = 0; y < image.getHeight(); y++) {
|
||||
for (int x = 0; x < image.getWidth(); x++) {
|
||||
int grey = raster.getSample(x, y, 0) * 255 / greyMax;
|
||||
int alpha = hasAlpha ? raster.getSample(x, y, 1) * 255 / alphaMax : 255;
|
||||
converted.setRGB(x, y, alpha << 24 | grey << 16 | grey << 8 | grey);
|
||||
}
|
||||
}
|
||||
return converted;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
/**
|
||||
* The six sides of a block, named as in Minecraft's model files, with the light Minecraft gives each side.
|
||||
*/
|
||||
enum CubeFace {
|
||||
UP("up", 0, 1, 0, 1.0D),
|
||||
DOWN("down", 0, -1, 0, 0.5D),
|
||||
NORTH("north", 0, 0, -1, 0.8D),
|
||||
SOUTH("south", 0, 0, 1, 0.8D),
|
||||
EAST("east", 1, 0, 0, 0.6D),
|
||||
WEST("west", -1, 0, 0, 0.6D);
|
||||
|
||||
private final String modelName;
|
||||
private final int stepX;
|
||||
private final int stepY;
|
||||
private final int stepZ;
|
||||
private final double shade;
|
||||
|
||||
CubeFace(String modelName, int stepX, int stepY, int stepZ, double shade) {
|
||||
this.modelName = modelName;
|
||||
this.stepX = stepX;
|
||||
this.stepY = stepY;
|
||||
this.stepZ = stepZ;
|
||||
this.shade = shade;
|
||||
}
|
||||
|
||||
static CubeFace named(String modelName) {
|
||||
for (CubeFace face : values()) {
|
||||
if (face.modelName.equals(modelName)) {
|
||||
return face;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The side a direction points at most, like Minecraft picks for a tilted face.
|
||||
*/
|
||||
static CubeFace nearest(Vec3 direction) {
|
||||
CubeFace nearest = UP;
|
||||
double best = Double.NEGATIVE_INFINITY;
|
||||
for (CubeFace face : values()) {
|
||||
double alignment = face.stepX * direction.x() + face.stepY * direction.y() + face.stepZ * direction.z();
|
||||
if (alignment > best) {
|
||||
best = alignment;
|
||||
nearest = face;
|
||||
}
|
||||
}
|
||||
return nearest;
|
||||
}
|
||||
|
||||
String modelName() {
|
||||
return modelName;
|
||||
}
|
||||
|
||||
int stepX() {
|
||||
return stepX;
|
||||
}
|
||||
|
||||
int stepY() {
|
||||
return stepY;
|
||||
}
|
||||
|
||||
int stepZ() {
|
||||
return stepZ;
|
||||
}
|
||||
|
||||
double shade() {
|
||||
return shade;
|
||||
}
|
||||
|
||||
Vec3 direction() {
|
||||
return new Vec3(stepX, stepY, stepZ);
|
||||
}
|
||||
|
||||
CubeFace opposite() {
|
||||
return switch (this) {
|
||||
case UP -> DOWN;
|
||||
case DOWN -> UP;
|
||||
case NORTH -> SOUTH;
|
||||
case SOUTH -> NORTH;
|
||||
case EAST -> WEST;
|
||||
case WEST -> EAST;
|
||||
};
|
||||
}
|
||||
|
||||
int axis() {
|
||||
return stepX != 0 ? 0 : stepY != 0 ? 1 : 2;
|
||||
}
|
||||
|
||||
boolean pointsPositive() {
|
||||
return stepX + stepY + stepZ > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
import com.volmit.iris.core.gui.BlockModel.Face;
|
||||
import com.volmit.iris.core.gui.BlockModel.TextureMapping;
|
||||
import com.volmit.iris.core.gui.BlockTextures.BlockStateId;
|
||||
import com.volmit.iris.core.gui.BlockTextures.BlockTexture;
|
||||
import com.volmit.iris.core.gui.BlockTextures.ModelDefinition;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Shapes for the blocks Minecraft draws in code instead of from a model file, like chests, heads, banners and
|
||||
* fluids, which fill their block. Chests, heads, banners and the bell use their entity textures laid out the way Minecraft's model parts are;
|
||||
* rarer ones are a simple box in their particle texture.
|
||||
*/
|
||||
final class EntityBlockShapes {
|
||||
private static final Set<String> INVISIBLE = Set.of("air", "cave_air", "void_air", "barrier", "light",
|
||||
"structure_void", "moving_piston", "pitcher_crop");
|
||||
private static final int END_PORTAL_COLOR = 0x0E1219;
|
||||
private static final double SKULL_WALL_OFFSET = 0.25D;
|
||||
private static final double ROTATION_SEGMENT_DEGREES = 22.5D;
|
||||
private static final double BANNER_SCALE = 2D / 3D;
|
||||
private static final double MODEL_UNITS = 16D;
|
||||
private static final Map<String, Integer> DYE_COLORS = Map.ofEntries(
|
||||
Map.entry("white", 0xF9FFFE), Map.entry("orange", 0xF9801D), Map.entry("magenta", 0xC74EBD),
|
||||
Map.entry("light_blue", 0x3AB3DA), Map.entry("yellow", 0xFED83D), Map.entry("lime", 0x80C71F),
|
||||
Map.entry("pink", 0xF38BAA), Map.entry("gray", 0x474F52), Map.entry("light_gray", 0x9D9D97),
|
||||
Map.entry("cyan", 0x169C9C), Map.entry("purple", 0x8932B8), Map.entry("blue", 0x3C44AA),
|
||||
Map.entry("brown", 0x835432), Map.entry("green", 0x5E7C16), Map.entry("red", 0xB02E26),
|
||||
Map.entry("black", 0x1D1D21));
|
||||
|
||||
private final BlockTextures textures;
|
||||
|
||||
EntityBlockShapes(BlockTextures textures) {
|
||||
this.textures = textures;
|
||||
}
|
||||
|
||||
/**
|
||||
* The faces of a block whose model has no boxes of its own.
|
||||
*/
|
||||
List<Face> facesOf(BlockStateId state, ModelDefinition model) {
|
||||
String name = state.name();
|
||||
if (INVISIBLE.contains(name)) {
|
||||
return List.of();
|
||||
}
|
||||
if (name.endsWith("chest")) {
|
||||
return chest(state);
|
||||
}
|
||||
if (name.endsWith("_skull") || name.endsWith("_head")) {
|
||||
return head(state, model);
|
||||
}
|
||||
if (name.endsWith("banner")) {
|
||||
return banner(state);
|
||||
}
|
||||
return switch (name) {
|
||||
case "conduit" -> particleBox(state, model, new Vec3(5, 5, 5), new Vec3(11, 11, 11), false);
|
||||
case "decorated_pot" -> decoratedPot(state, model);
|
||||
case "end_portal" -> BlockModelBuilder.box(new Vec3(0, 0, 0), new Vec3(16, 12, 16), BlockTexture.solid(END_PORTAL_COLOR), true);
|
||||
case "end_gateway" -> BlockModelBuilder.box(new Vec3(0, 0, 0), new Vec3(16, 16, 16), BlockTexture.solid(END_PORTAL_COLOR), true);
|
||||
default -> name.endsWith("copper_golem_statue")
|
||||
? particleBox(state, model, new Vec3(4, 0, 4), new Vec3(12, 14, 12), false)
|
||||
: particleBox(state, model, new Vec3(0, 0, 0), new Vec3(16, 16, 16), true);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parts a block draws in code on top of its model, like the bell hanging in its frame.
|
||||
*/
|
||||
List<Face> facesAddedTo(BlockStateId state) {
|
||||
if (!state.name().equals("bell")) {
|
||||
return List.of();
|
||||
}
|
||||
Optional<BlockTexture> texture = textures.entityTexture("entity/bell/bell_body", MinecraftBlockTints.NO_TINT);
|
||||
if (texture.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<Face> faces = new ArrayList<>();
|
||||
faces.addAll(modelPartBox(texture.get(), 0, 0, new Vec3(5, 6, 5), new Vec3(6, 7, 6), Transform.IDENTITY));
|
||||
faces.addAll(modelPartBox(texture.get(), 0, 13, new Vec3(4, 4, 4), new Vec3(8, 2, 8), Transform.IDENTITY));
|
||||
return faces;
|
||||
}
|
||||
|
||||
private List<Face> particleBox(BlockStateId state, ModelDefinition model, Vec3 from, Vec3 to, boolean culledBySides) {
|
||||
String particle = BlockTextures.dereference("#particle", model.textures());
|
||||
if (particle == null) {
|
||||
return List.of();
|
||||
}
|
||||
return textures.faceTexture(state, particle, BlockTextures.NO_TINT_INDEX)
|
||||
.map(texture -> BlockModelBuilder.box(from, to, texture, culledBySides))
|
||||
.orElse(List.of());
|
||||
}
|
||||
|
||||
private List<Face> decoratedPot(BlockStateId state, ModelDefinition model) {
|
||||
List<Face> faces = new ArrayList<>(particleBox(state, model, new Vec3(1, 0, 1), new Vec3(15, 16, 15), false));
|
||||
faces.addAll(particleBox(state, model, new Vec3(4, 16, 4), new Vec3(12, 20, 12), false));
|
||||
return faces;
|
||||
}
|
||||
|
||||
// Single chests are 14 pixels wide; each half of a double chest is 15, reaching over to its partner.
|
||||
private List<Face> chest(BlockStateId state) {
|
||||
String type = state.properties().getOrDefault("type", "single");
|
||||
String suffix = type.equals("single") ? "" : "_" + type;
|
||||
Optional<BlockTexture> texture = textures.entityTexture("entity/chest/" + chestTextureName(state.name()) + suffix, MinecraftBlockTints.NO_TINT);
|
||||
if (texture.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
Transform place = Transform.IDENTITY
|
||||
.translate(0.5D, 0.5D, 0.5D)
|
||||
.rotateY(-yRotationOf(state.properties().getOrDefault("facing", "north")))
|
||||
.translate(-0.5D, -0.5D, -0.5D);
|
||||
double left = type.equals("left") ? 0D : 1D;
|
||||
double width = type.equals("single") ? 14D : 15D;
|
||||
double lockX = switch (type) {
|
||||
case "left" -> 0D;
|
||||
case "right" -> 15D;
|
||||
default -> 7D;
|
||||
};
|
||||
double lockWidth = type.equals("single") ? 2D : 1D;
|
||||
List<Face> faces = new ArrayList<>();
|
||||
faces.addAll(modelPartBox(texture.get(), 0, 19, new Vec3(left, 0, 1), new Vec3(width, 10, 14), place));
|
||||
faces.addAll(modelPartBox(texture.get(), 0, 0, new Vec3(left, 9, 1), new Vec3(width, 5, 14), place));
|
||||
faces.addAll(modelPartBox(texture.get(), 0, 0, new Vec3(lockX, 7, 15), new Vec3(lockWidth, 4, 1), place));
|
||||
return faces;
|
||||
}
|
||||
|
||||
private static String chestTextureName(String blockName) {
|
||||
String name = blockName.startsWith("waxed_") ? blockName.substring("waxed_".length()) : blockName;
|
||||
return switch (name) {
|
||||
case "trapped_chest" -> "trapped";
|
||||
case "ender_chest" -> "ender";
|
||||
case "copper_chest" -> "copper";
|
||||
case "exposed_copper_chest" -> "copper_exposed";
|
||||
case "weathered_copper_chest" -> "copper_weathered";
|
||||
case "oxidized_copper_chest" -> "copper_oxidized";
|
||||
default -> "normal";
|
||||
};
|
||||
}
|
||||
|
||||
// Heads are drawn upside down and mirrored in their model, and turn in sixteenths like signs.
|
||||
private List<Face> head(BlockStateId state, ModelDefinition model) {
|
||||
boolean onWall = state.name().contains("_wall_");
|
||||
String kind = state.name().replace("_wall_", "_");
|
||||
Optional<HeadLook> look = HeadLook.of(kind);
|
||||
Optional<BlockTexture> texture = look.flatMap(found -> textures.entityTexture(found.texture(), MinecraftBlockTints.NO_TINT));
|
||||
if (texture.isEmpty()) {
|
||||
return onWall
|
||||
? particleBox(state, model, new Vec3(4, 4, 8), new Vec3(12, 12, 16), false)
|
||||
: particleBox(state, model, new Vec3(4, 0, 4), new Vec3(12, 8, 12), false);
|
||||
}
|
||||
Transform place;
|
||||
if (onWall) {
|
||||
CubeFace facing = CubeFace.named(state.properties().getOrDefault("facing", "north"));
|
||||
place = Transform.IDENTITY
|
||||
.translate(0.5D - facing.stepX() * SKULL_WALL_OFFSET, SKULL_WALL_OFFSET, 0.5D - facing.stepZ() * SKULL_WALL_OFFSET)
|
||||
.scale(-1D, -1D, 1D)
|
||||
.rotateY(yRotationOf(facing.opposite().modelName()));
|
||||
} else {
|
||||
int rotation = Integer.parseInt(state.properties().getOrDefault("rotation", "0"));
|
||||
place = Transform.IDENTITY
|
||||
.translate(0.5D, 0D, 0.5D)
|
||||
.scale(-1D, -1D, 1D)
|
||||
.rotateY(rotation * ROTATION_SEGMENT_DEGREES);
|
||||
}
|
||||
HeadLook found = look.get();
|
||||
return modelPartBox(texture.get(), 0, 0, new Vec3(-found.width() / 2D, -8, -4), new Vec3(found.width(), 8, 8), place);
|
||||
}
|
||||
|
||||
private List<Face> banner(BlockStateId state) {
|
||||
boolean onWall = state.name().endsWith("_wall_banner");
|
||||
String color = state.name().substring(0, state.name().length() - (onWall ? "_wall_banner" : "_banner").length());
|
||||
Optional<BlockTexture> frame = textures.entityTexture("entity/banner/banner_base", MinecraftBlockTints.NO_TINT);
|
||||
Optional<BlockTexture> cloth = textures.entityTexture("entity/banner/base", DYE_COLORS.getOrDefault(color, MinecraftBlockTints.NO_TINT));
|
||||
if (frame.isEmpty() || cloth.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
Transform place;
|
||||
if (onWall) {
|
||||
CubeFace facing = CubeFace.named(state.properties().getOrDefault("facing", "north"));
|
||||
place = Transform.IDENTITY
|
||||
.translate(0.5D, -1D / 6D, 0.5D)
|
||||
.rotateY(-yRotationOf(facing.modelName()))
|
||||
.translate(0D, -0.3125D, -0.4375D)
|
||||
.scale(BANNER_SCALE, -BANNER_SCALE, -BANNER_SCALE);
|
||||
} else {
|
||||
int rotation = Integer.parseInt(state.properties().getOrDefault("rotation", "0"));
|
||||
place = Transform.IDENTITY
|
||||
.translate(0.5D, 0.5D, 0.5D)
|
||||
.rotateY(-rotation * ROTATION_SEGMENT_DEGREES)
|
||||
.scale(BANNER_SCALE, -BANNER_SCALE, -BANNER_SCALE);
|
||||
}
|
||||
List<Face> faces = new ArrayList<>();
|
||||
if (!onWall) {
|
||||
faces.addAll(modelPartBox(frame.get(), 44, 0, new Vec3(-1, -30, -1), new Vec3(2, 42, 2), place));
|
||||
}
|
||||
faces.addAll(modelPartBox(frame.get(), 0, 42, new Vec3(-10, -32, -1), new Vec3(20, 2, 2), place));
|
||||
faces.addAll(modelPartBox(cloth.get(), 0, 0, new Vec3(-10, -32, -2), new Vec3(20, 40, 1), place));
|
||||
return faces;
|
||||
}
|
||||
|
||||
// Minecraft's facing angle: south is 0 and each quarter turn clockwise seen from above adds 90 degrees.
|
||||
private static double yRotationOf(String facing) {
|
||||
return switch (facing) {
|
||||
case "west" -> 90D;
|
||||
case "north" -> 180D;
|
||||
case "east" -> 270D;
|
||||
default -> 0D;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A box of a block entity's model, in model pixels, with its sides laid out on the texture the way Minecraft's
|
||||
* model parts are: the top and bottom in a row above the four sides.
|
||||
*/
|
||||
static List<Face> modelPartBox(BlockTexture texture, int textureU, int textureV, Vec3 from, Vec3 size, Transform place) {
|
||||
double minX = from.x() / MODEL_UNITS;
|
||||
double minY = from.y() / MODEL_UNITS;
|
||||
double minZ = from.z() / MODEL_UNITS;
|
||||
double maxX = (from.x() + size.x()) / MODEL_UNITS;
|
||||
double maxY = (from.y() + size.y()) / MODEL_UNITS;
|
||||
double maxZ = (from.z() + size.z()) / MODEL_UNITS;
|
||||
Vec3 v0 = new Vec3(minX, minY, minZ);
|
||||
Vec3 v1 = new Vec3(maxX, minY, minZ);
|
||||
Vec3 v2 = new Vec3(maxX, maxY, minZ);
|
||||
Vec3 v3 = new Vec3(minX, maxY, minZ);
|
||||
Vec3 v4 = new Vec3(minX, minY, maxZ);
|
||||
Vec3 v5 = new Vec3(maxX, minY, maxZ);
|
||||
Vec3 v6 = new Vec3(maxX, maxY, maxZ);
|
||||
Vec3 v7 = new Vec3(minX, maxY, maxZ);
|
||||
double u = textureU;
|
||||
double v = textureV;
|
||||
double width = size.x();
|
||||
double height = size.y();
|
||||
double depth = size.z();
|
||||
List<Face> faces = new ArrayList<>(6);
|
||||
faces.add(polygon(texture, place, CubeFace.DOWN, v5, v4, v0, u + depth, v, u + depth + width, v + depth));
|
||||
faces.add(polygon(texture, place, CubeFace.UP, v2, v3, v7, u + depth + width, v + depth, u + depth + width + width, v));
|
||||
faces.add(polygon(texture, place, CubeFace.WEST, v0, v4, v7, u, v + depth, u + depth, v + depth + height));
|
||||
faces.add(polygon(texture, place, CubeFace.NORTH, v1, v0, v3, u + depth, v + depth, u + depth + width, v + depth + height));
|
||||
faces.add(polygon(texture, place, CubeFace.EAST, v5, v1, v2, u + depth + width, v + depth, u + depth + width + depth, v + depth + height));
|
||||
faces.add(polygon(texture, place, CubeFace.SOUTH, v4, v5, v6, u + depth + width + depth, v + depth, u + depth + width + depth + width, v + depth + height));
|
||||
return faces;
|
||||
}
|
||||
|
||||
// A model part's polygon gives its first corner (u2, v1), its second (u1, v1) and its third (u1, v2).
|
||||
private static Face polygon(BlockTexture texture, Transform place, CubeFace side, Vec3 first, Vec3 second, Vec3 third,
|
||||
double u1, double v1, double u2, double v2) {
|
||||
double textureWidth = texture.width();
|
||||
double textureHeight = texture.height();
|
||||
TextureMapping mapping = new TextureMapping(u1 / textureWidth, v1 / textureHeight,
|
||||
(u2 - u1) / textureWidth, 0D, 0D, (v2 - v1) / textureHeight);
|
||||
Face face = new Face(second, first.minus(second), third.minus(second), side.direction(), texture, mapping, side.shade(), null)
|
||||
.transformed(place);
|
||||
return face.withShade(CubeFace.nearest(face.normal()).shade());
|
||||
}
|
||||
|
||||
/**
|
||||
* The entity texture of a mob head and how wide its head is.
|
||||
*/
|
||||
private record HeadLook(String texture, double width) {
|
||||
private static final double HEAD_WIDTH = 8D;
|
||||
private static final double PIGLIN_HEAD_WIDTH = 10D;
|
||||
|
||||
static Optional<HeadLook> of(String kind) {
|
||||
return Optional.ofNullable(switch (kind) {
|
||||
case "skeleton_skull" -> new HeadLook("entity/skeleton/skeleton", HEAD_WIDTH);
|
||||
case "wither_skeleton_skull" -> new HeadLook("entity/skeleton/wither_skeleton", HEAD_WIDTH);
|
||||
case "zombie_head" -> new HeadLook("entity/zombie/zombie", HEAD_WIDTH);
|
||||
case "creeper_head" -> new HeadLook("entity/creeper/creeper", HEAD_WIDTH);
|
||||
case "player_head" -> new HeadLook("entity/player/wide/steve", HEAD_WIDTH);
|
||||
case "piglin_head" -> new HeadLook("entity/piglin/piglin", PIGLIN_HEAD_WIDTH);
|
||||
default -> null;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
import com.volmit.iris.Iris;
|
||||
import com.volmit.iris.core.events.IrisEngineHotloadEvent;
|
||||
import com.volmit.iris.core.project.IrisProject;
|
||||
import com.volmit.iris.core.service.PreservationSVC;
|
||||
import com.volmit.iris.core.service.StudioSVC;
|
||||
import com.volmit.iris.core.tools.IrisToolbelt;
|
||||
import com.volmit.iris.engine.framework.Engine;
|
||||
import com.volmit.iris.engine.platform.PlatformChunkGenerator;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.Listener;
|
||||
|
||||
import javax.swing.JFrame;
|
||||
import java.awt.Desktop;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.EventQueue;
|
||||
import java.awt.GraphicsEnvironment;
|
||||
import java.awt.Rectangle;
|
||||
import java.awt.desktop.QuitResponse;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.awt.event.WindowAdapter;
|
||||
import java.awt.event.WindowEvent;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
public final class GuiHost {
|
||||
private static final AtomicBoolean DESKTOP_QUIT_GUARD_INSTALLED = new AtomicBoolean(false);
|
||||
private static final Set<JFrame> MANAGED_FRAMES = ConcurrentHashMap.newKeySet();
|
||||
private static final Map<Runnable, Listener> HOTLOAD_HOOKS = new ConcurrentHashMap<>();
|
||||
|
||||
private GuiHost() {
|
||||
}
|
||||
|
||||
public static boolean isAvailable() {
|
||||
return !GraphicsEnvironment.isHeadless();
|
||||
}
|
||||
|
||||
public static JFrame createFrame(String title) {
|
||||
XwaylandWindowManagerHint.apply();
|
||||
StudioTheme.install();
|
||||
JFrame frame = new JFrame(title);
|
||||
frame.setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE);
|
||||
MANAGED_FRAMES.add(frame);
|
||||
frame.addWindowListener(new WindowAdapter() {
|
||||
@Override
|
||||
public void windowClosed(WindowEvent event) {
|
||||
MANAGED_FRAMES.remove(frame);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void windowActivated(WindowEvent event) {
|
||||
StudioTheme.followSystem();
|
||||
}
|
||||
});
|
||||
installMacQuitGuard();
|
||||
return frame;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps rendered pixels in an image Java2D draws straight from memory. Left to itself Java2D copies every image it
|
||||
* draws into a texture inside the display server; with a map's hundreds of tiles that crashed Xwayland's Intel driver
|
||||
* and took every X window on the desktop down with it.
|
||||
*/
|
||||
public static BufferedImage uncachedImage(int[] pixels, int width, int height) {
|
||||
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
|
||||
image.getRaster().setDataElements(0, 0, width, height, pixels);
|
||||
image.setAccelerationPriority(0F);
|
||||
return image;
|
||||
}
|
||||
|
||||
// A scaled-up default size can be bigger than a small or low resolution screen.
|
||||
static Dimension fitToScreen(Dimension preferred) {
|
||||
Rectangle usable = GraphicsEnvironment.getLocalGraphicsEnvironment().getMaximumWindowBounds();
|
||||
return new Dimension(Math.min(preferred.width, usable.width), Math.min(preferred.height, usable.height));
|
||||
}
|
||||
|
||||
/**
|
||||
* The engine of the open studio project, which reads the pack folder you edit, or else the first Iris world.
|
||||
* A normal Iris world runs on its own copy of the pack, made when the world was created.
|
||||
*/
|
||||
public static Engine findActiveEngine() {
|
||||
Engine studioEngine = findStudioEngine();
|
||||
if (studioEngine != null) {
|
||||
return studioEngine;
|
||||
}
|
||||
try {
|
||||
for (World world : new ArrayList<>(Bukkit.getWorlds())) {
|
||||
Engine engine = engineOf(world);
|
||||
if (engine != null) {
|
||||
return engine;
|
||||
}
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Engine findStudioEngine() {
|
||||
try {
|
||||
StudioSVC studio = Iris.service(StudioSVC.class);
|
||||
IrisProject project = studio == null || !studio.isProjectOpen() ? null : studio.getActiveProject();
|
||||
PlatformChunkGenerator provider = project == null ? null : project.getActiveProvider();
|
||||
Engine engine = provider == null ? null : provider.getEngine();
|
||||
return engine == null || engine.isClosed() ? null : engine;
|
||||
} catch (Throwable ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static Engine findEngine(UUID worldId) {
|
||||
World world = Bukkit.getWorld(worldId);
|
||||
return world == null ? null : engineOf(world);
|
||||
}
|
||||
|
||||
public static void registerHotloadHook(Runnable onHotload) {
|
||||
Listener listener = new HotloadListener(onHotload);
|
||||
HOTLOAD_HOOKS.put(onHotload, listener);
|
||||
Iris.instance.registerListener(listener);
|
||||
}
|
||||
|
||||
public static void unregisterHotloadHook(Runnable onHotload) {
|
||||
Listener listener = HOTLOAD_HOOKS.remove(onHotload);
|
||||
if (listener != null) {
|
||||
Iris.instance.unregisterListener(listener);
|
||||
}
|
||||
}
|
||||
|
||||
static void interruptOnPluginShutdown(ExecutorService executor) {
|
||||
PreservationSVC preservation = Iris.service(PreservationSVC.class);
|
||||
if (preservation != null) {
|
||||
preservation.register(executor);
|
||||
}
|
||||
}
|
||||
|
||||
static void interruptOnPluginShutdown(Thread thread) {
|
||||
PreservationSVC preservation = Iris.service(PreservationSVC.class);
|
||||
if (preservation != null) {
|
||||
preservation.register(thread);
|
||||
}
|
||||
}
|
||||
|
||||
private static Engine engineOf(World world) {
|
||||
try {
|
||||
PlatformChunkGenerator access = IrisToolbelt.access(world);
|
||||
if (access != null && access.getEngine() != null && !access.getEngine().isClosed()) {
|
||||
return access.getEngine();
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// macOS Quit in the dock menu would otherwise stop the whole server.
|
||||
private static void installMacQuitGuard() {
|
||||
if (!isAvailable()
|
||||
|| !System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("mac")
|
||||
|| !DESKTOP_QUIT_GUARD_INSTALLED.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!Desktop.isDesktopSupported()) {
|
||||
return;
|
||||
}
|
||||
Desktop desktop = Desktop.getDesktop();
|
||||
if (!desktop.isSupported(Desktop.Action.APP_QUIT_HANDLER)) {
|
||||
return;
|
||||
}
|
||||
desktop.setQuitHandler((event, response) -> closeWindowsAndCancelQuit(response));
|
||||
} catch (Throwable e) {
|
||||
Iris.reportError(e);
|
||||
Iris.info("Unable to install the Iris desktop quit guard; use the server stop command instead of macOS Quit");
|
||||
}
|
||||
}
|
||||
|
||||
private static void closeWindowsAndCancelQuit(QuitResponse response) {
|
||||
response.cancelQuit();
|
||||
EventQueue.invokeLater(() -> {
|
||||
for (JFrame frame : MANAGED_FRAMES) {
|
||||
frame.dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static final class HotloadListener implements Listener {
|
||||
private final Runnable onHotload;
|
||||
|
||||
private HotloadListener(Runnable onHotload) {
|
||||
this.onHotload = onHotload;
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void on(IrisEngineHotloadEvent event) {
|
||||
onHotload.run();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
public record GuiMarker(String label, double worldX, double worldY, double worldZ, double health, double maxHealth) {
|
||||
public static GuiMarker player(String name, double worldX, double worldZ) {
|
||||
return new GuiMarker(name, worldX, 0, worldZ, 0, 0);
|
||||
}
|
||||
|
||||
public static GuiMarker entity(String label, double worldX, double worldY, double worldZ, double health, double maxHealth) {
|
||||
return new GuiMarker(label, worldX, worldY, worldZ, health, maxHealth);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.JLayeredPane;
|
||||
import java.awt.Cursor;
|
||||
import java.awt.event.MouseAdapter;
|
||||
import java.awt.event.MouseEvent;
|
||||
|
||||
/**
|
||||
* Lays an island over content that fills the whole area behind it. Dragging the island's right edge resizes it,
|
||||
* always leaving the content some room beside it.
|
||||
*/
|
||||
final class IslandOverlay extends JLayeredPane {
|
||||
private static final long serialVersionUID = -4182209135360718342L;
|
||||
|
||||
private final IslandPanel island;
|
||||
private final JComponent background;
|
||||
private final int minimumIslandWidth;
|
||||
private final int minimumUncoveredWidth;
|
||||
private int islandWidth;
|
||||
|
||||
IslandOverlay(IslandPanel island, JComponent background, int islandWidth, int minimumIslandWidth, int minimumUncoveredWidth) {
|
||||
this.island = island;
|
||||
this.background = background;
|
||||
this.islandWidth = islandWidth;
|
||||
this.minimumIslandWidth = minimumIslandWidth;
|
||||
this.minimumUncoveredWidth = minimumUncoveredWidth;
|
||||
add(background, JLayeredPane.DEFAULT_LAYER);
|
||||
add(island, JLayeredPane.PALETTE_LAYER);
|
||||
installResizing();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void doLayout() {
|
||||
background.setBounds(0, 0, getWidth(), getHeight());
|
||||
island.setBounds(0, 0, clampedIslandWidth(), getHeight());
|
||||
}
|
||||
|
||||
private int clampedIslandWidth() {
|
||||
int widest = Math.max(minimumIslandWidth, getWidth() - minimumUncoveredWidth);
|
||||
return Math.clamp(islandWidth, minimumIslandWidth, widest);
|
||||
}
|
||||
|
||||
private void installResizing() {
|
||||
MouseAdapter resizer = new MouseAdapter() {
|
||||
private boolean dragging;
|
||||
|
||||
@Override
|
||||
public void mouseMoved(MouseEvent event) {
|
||||
island.setCursor(island.isOnResizeEdge(event.getX(), event.getY())
|
||||
? Cursor.getPredefinedCursor(Cursor.E_RESIZE_CURSOR)
|
||||
: Cursor.getDefaultCursor());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseExited(MouseEvent event) {
|
||||
if (!dragging) {
|
||||
island.setCursor(Cursor.getDefaultCursor());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mousePressed(MouseEvent event) {
|
||||
dragging = island.isOnResizeEdge(event.getX(), event.getY());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseDragged(MouseEvent event) {
|
||||
if (!dragging) {
|
||||
return;
|
||||
}
|
||||
islandWidth = event.getX();
|
||||
islandWidth = clampedIslandWidth();
|
||||
revalidate();
|
||||
repaint();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseReleased(MouseEvent event) {
|
||||
dragging = false;
|
||||
}
|
||||
};
|
||||
island.addMouseListener(resizer);
|
||||
island.addMouseMotionListener(resizer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.JPanel;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Graphics;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.RenderingHints;
|
||||
import java.awt.geom.RoundRectangle2D;
|
||||
|
||||
/**
|
||||
* A rounded panel floating over the window content with a gap all around it, like the tool windows of IntelliJ's
|
||||
* islands theme. The gap belongs to the content behind it, except for the strip along the right edge, which grabs the
|
||||
* island to resize it.
|
||||
*/
|
||||
final class IslandPanel extends JPanel {
|
||||
private static final long serialVersionUID = 7313084027614358519L;
|
||||
private static final int GAP = 6;
|
||||
private static final int CORNER_ARC = 12;
|
||||
private static final int INNER_PADDING = 4;
|
||||
|
||||
IslandPanel(JComponent content) {
|
||||
super(new BorderLayout());
|
||||
setOpaque(false);
|
||||
int inset = GAP + INNER_PADDING;
|
||||
setBorder(StudioTheme.emptyBorder(inset, inset, inset, inset));
|
||||
add(content, BorderLayout.CENTER);
|
||||
}
|
||||
|
||||
boolean isOnResizeEdge(int x, int y) {
|
||||
int edgeWidth = StudioTheme.scale(GAP + INNER_PADDING);
|
||||
return x >= getWidth() - edgeWidth && x < getWidth() && y >= 0 && y < getHeight();
|
||||
}
|
||||
|
||||
// Clicks in the gap reach the content behind the island instead of the island itself.
|
||||
@Override
|
||||
public boolean contains(int x, int y) {
|
||||
return island().contains(x, y) || isOnResizeEdge(x, y);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void paintComponent(Graphics graphics) {
|
||||
Graphics2D canvas = (Graphics2D) graphics.create();
|
||||
try {
|
||||
canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
canvas.setColor(StudioTheme.PANEL_BACKGROUND);
|
||||
canvas.fill(island());
|
||||
} finally {
|
||||
canvas.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private RoundRectangle2D island() {
|
||||
double gap = StudioTheme.scale(GAP);
|
||||
double arc = StudioTheme.scale(CORNER_ARC);
|
||||
return new RoundRectangle2D.Double(gap, gap, getWidth() - gap * 2D, getHeight() - gap * 2D, arc, arc);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,183 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
import com.volmit.iris.core.gui.components.MapLabels;
|
||||
import com.volmit.iris.core.gui.components.MapStyle;
|
||||
import com.volmit.iris.core.gui.components.MapTile;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.ToIntFunction;
|
||||
|
||||
/**
|
||||
* Measures the visible part of the map from the tiles already drawn: how much each biome or region covers, and which
|
||||
* heights it spans.
|
||||
*/
|
||||
final class MapCensus {
|
||||
private static final long MAXIMUM_COUNTED_PIXELS = 250_000L;
|
||||
private static final int MINIMUM_HEIGHT_SAMPLES = 64;
|
||||
private static final double LOW_HEIGHT_PERCENTILE = 0.01D;
|
||||
private static final double HIGH_HEIGHT_PERCENTILE = 0.99D;
|
||||
private static final double HEIGHT_RANGE_STEP_BLOCKS = 8D;
|
||||
private static final double MINIMUM_WATER_DEPTH_BLOCKS = 8D;
|
||||
private static final double MINIMUM_LAND_HEIGHT_BLOCKS = 16D;
|
||||
|
||||
private MapCensus() {
|
||||
}
|
||||
|
||||
static List<Share> count(VisionRenderController.Frame frame, ViewArea area, ToIntFunction<MapLabels.Label> colorOf) {
|
||||
Map<Short, long[]> counts = new HashMap<>();
|
||||
MapLabels[] labels = new MapLabels[1];
|
||||
long counted = visitVisiblePixels(frame, area, (tile, pixelX, pixelZ) -> {
|
||||
labels[0] = tile.labels();
|
||||
counts.computeIfAbsent(tile.subjectAt(pixelX, pixelZ), id -> new long[1])[0]++;
|
||||
});
|
||||
return shares(counts, labels[0], counted, colorOf);
|
||||
}
|
||||
|
||||
/**
|
||||
* The heights on screen, ignoring the highest and lowest few percent so one spike doesn't wash out every colour.
|
||||
* Rounded to whole steps, so panning around doesn't recolour the map for every small change.
|
||||
*
|
||||
* @return the range, or null until enough of the map is drawn to tell
|
||||
*/
|
||||
static MapStyle.HeightRange heightRange(VisionRenderController.Frame frame, ViewArea area, double seaLevel) {
|
||||
DoubleList heights = new DoubleList();
|
||||
visitVisiblePixels(frame, area, (tile, pixelX, pixelZ) -> {
|
||||
if (tile.hasHeights()) {
|
||||
heights.add(tile.heightAt(pixelX, pixelZ));
|
||||
}
|
||||
});
|
||||
if (heights.size() < MINIMUM_HEIGHT_SAMPLES) {
|
||||
return null;
|
||||
}
|
||||
double[] sorted = heights.sorted();
|
||||
double low = sorted[(int) ((sorted.length - 1) * LOW_HEIGHT_PERCENTILE)];
|
||||
double high = sorted[(int) ((sorted.length - 1) * HIGH_HEIGHT_PERCENTILE)];
|
||||
double lowest = Math.floor(Math.min(low, seaLevel - MINIMUM_WATER_DEPTH_BLOCKS) / HEIGHT_RANGE_STEP_BLOCKS)
|
||||
* HEIGHT_RANGE_STEP_BLOCKS;
|
||||
double highest = Math.ceil(Math.max(high, seaLevel + MINIMUM_LAND_HEIGHT_BLOCKS) / HEIGHT_RANGE_STEP_BLOCKS)
|
||||
* HEIGHT_RANGE_STEP_BLOCKS;
|
||||
return new MapStyle.HeightRange(lowest, seaLevel, highest);
|
||||
}
|
||||
|
||||
private static long visitVisiblePixels(VisionRenderController.Frame frame, ViewArea area, PixelVisitor visitor) {
|
||||
if (frame == null) {
|
||||
return 0L;
|
||||
}
|
||||
double blocksPerPixel = frame.renderBlocksPerPixel();
|
||||
int stride = countingStride(area.maximumX() - area.minimumX(), area.maximumZ() - area.minimumZ(), blocksPerPixel);
|
||||
double tileSpan = VisionRenderController.TILE_PIXELS * blocksPerPixel;
|
||||
long visited = 0L;
|
||||
for (VisionRenderController.VisibleTile visible : frame.tiles()) {
|
||||
MapTile tile = visible.tile();
|
||||
if (tile == null) {
|
||||
continue;
|
||||
}
|
||||
double originX = visible.tileX() * tileSpan;
|
||||
double originZ = visible.tileZ() * tileSpan;
|
||||
int firstX = firstPixel(area.minimumX() - originX, blocksPerPixel, stride, visible.tileX());
|
||||
int firstZ = firstPixel(area.minimumZ() - originZ, blocksPerPixel, stride, visible.tileZ());
|
||||
int endX = endPixel(area.maximumX() - originX, blocksPerPixel, tile.size());
|
||||
int endZ = endPixel(area.maximumZ() - originZ, blocksPerPixel, tile.size());
|
||||
for (int pixelZ = firstZ; pixelZ < endZ; pixelZ += stride) {
|
||||
for (int pixelX = firstX; pixelX < endX; pixelX += stride) {
|
||||
visitor.visit(tile, pixelX, pixelZ);
|
||||
visited++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return visited;
|
||||
}
|
||||
|
||||
private static List<Share> shares(Map<Short, long[]> counts, MapLabels labels, long counted,
|
||||
ToIntFunction<MapLabels.Label> colorOf) {
|
||||
if (labels == null || counted == 0L) {
|
||||
return List.of();
|
||||
}
|
||||
List<Share> shares = new ArrayList<>(counts.size());
|
||||
for (Map.Entry<Short, long[]> entry : counts.entrySet()) {
|
||||
MapLabels.Label label = labels.label(entry.getKey());
|
||||
if (label != null) {
|
||||
shares.add(new Share(label, colorOf.applyAsInt(label), entry.getValue()[0] / (double) counted));
|
||||
}
|
||||
}
|
||||
shares.sort(Comparator.comparingDouble(Share::fraction).reversed().thenComparing(share -> share.label().name()));
|
||||
return List.copyOf(shares);
|
||||
}
|
||||
|
||||
// Counting every pixel of a big screen is wasted work; an evenly spread sample gives the same percentages.
|
||||
private static int countingStride(double widthBlocks, double heightBlocks, double blocksPerPixel) {
|
||||
double pixels = (widthBlocks / blocksPerPixel) * (heightBlocks / blocksPerPixel);
|
||||
return Math.max(1, (int) Math.ceil(Math.sqrt(pixels / MAXIMUM_COUNTED_PIXELS)));
|
||||
}
|
||||
|
||||
// Starting on the global stride grid keeps neighbouring tiles from sampling the same columns twice as densely.
|
||||
private static int firstPixel(double offsetBlocks, double blocksPerPixel, int stride, long tileIndex) {
|
||||
int first = Math.max(0, (int) Math.ceil(offsetBlocks / blocksPerPixel));
|
||||
long globalPixel = tileIndex * VisionRenderController.TILE_PIXELS + first;
|
||||
int misalignment = (int) Math.floorMod(globalPixel, (long) stride);
|
||||
return misalignment == 0 ? first : first + stride - misalignment;
|
||||
}
|
||||
|
||||
private static int endPixel(double offsetBlocks, double blocksPerPixel, int size) {
|
||||
return Math.min(size, (int) Math.ceil(offsetBlocks / blocksPerPixel));
|
||||
}
|
||||
|
||||
record Share(MapLabels.Label label, int color, double fraction) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The world area the screen shows, in blocks.
|
||||
*/
|
||||
record ViewArea(double minimumX, double minimumZ, double maximumX, double maximumZ) {
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface PixelVisitor {
|
||||
void visit(MapTile tile, int pixelX, int pixelZ);
|
||||
}
|
||||
|
||||
private static final class DoubleList {
|
||||
private double[] values = new double[1024];
|
||||
private int size;
|
||||
|
||||
void add(double value) {
|
||||
if (size == values.length) {
|
||||
values = Arrays.copyOf(values, size * 2);
|
||||
}
|
||||
values[size++] = value;
|
||||
}
|
||||
|
||||
int size() {
|
||||
return size;
|
||||
}
|
||||
|
||||
double[] sorted() {
|
||||
double[] sorted = Arrays.copyOf(values, size);
|
||||
Arrays.sort(sorted);
|
||||
return sorted;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
import com.volmit.iris.core.gui.components.MapLabels;
|
||||
import com.volmit.iris.core.gui.components.RenderType;
|
||||
import com.volmit.iris.core.loader.IrisRegistrant;
|
||||
import com.volmit.iris.engine.object.IrisBiome;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The text of the card that follows the cursor: what the drawn map shows there at once, and the rest once probed.
|
||||
*/
|
||||
final class MapHoverCard {
|
||||
private MapHoverCard() {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param subject the biome, or region in the region view, the drawn tile shows; null while that tile is drawing
|
||||
* @param details the probed facts about the spot, or null until they arrive
|
||||
* @param detailed whether Shift is held, which adds keys and files
|
||||
*/
|
||||
static List<String> lines(MapLabels.Label subject, MapLabels.Label region, MapHoverProbe.Details details,
|
||||
RenderType type, boolean detailed, double worldX, double worldZ) {
|
||||
List<String> lines = new ArrayList<>();
|
||||
lines.add(title(subject, details));
|
||||
if (type != RenderType.REGION) {
|
||||
String regionName = region != null ? region.name() : details != null ? details.region().getName() : null;
|
||||
if (regionName != null) {
|
||||
lines.add("Region " + regionName);
|
||||
}
|
||||
}
|
||||
if (details != null) {
|
||||
addTerrain(lines, details, type);
|
||||
}
|
||||
int blockX = MapCanvas.floorWorldCoordinate(worldX);
|
||||
int blockZ = MapCanvas.floorWorldCoordinate(worldZ);
|
||||
lines.add("Block " + blockX + ", " + blockZ + " · chunk " + (blockX >> 4) + ", " + (blockZ >> 4));
|
||||
if (!detailed) {
|
||||
lines.add("Shift for keys and files · H highlights");
|
||||
return lines;
|
||||
}
|
||||
addSource(lines, subject);
|
||||
if (subject != null && subject.kind() != MapLabels.Kind.REGION) {
|
||||
addSource(lines, region);
|
||||
}
|
||||
lines.add("Region file r." + (blockX >> 9) + "." + (blockZ >> 9) + ".mca");
|
||||
return lines;
|
||||
}
|
||||
|
||||
private static String title(MapLabels.Label subject, MapHoverProbe.Details details) {
|
||||
if (subject != null) {
|
||||
return subject.name();
|
||||
}
|
||||
if (details != null && details.surfaceBiome() != null) {
|
||||
return details.surfaceBiome().getName();
|
||||
}
|
||||
return "Drawing…";
|
||||
}
|
||||
|
||||
private static void addTerrain(List<String> lines, MapHoverProbe.Details details, RenderType type) {
|
||||
lines.add("Height " + Math.round(details.height()) + " · sea level " + Math.round(details.fluidHeight()));
|
||||
String slope = "slope " + Math.round(details.slopeDegrees()) + "°";
|
||||
lines.add(details.terrainKind() == null ? capitalize(slope) : details.terrainKind() + " · " + slope);
|
||||
IrisBiome surface = details.surfaceBiome();
|
||||
IrisBiome cave = details.caveBiome();
|
||||
if (type == RenderType.CAVE_LAND && surface != null) {
|
||||
lines.add("Surface biome " + surface.getName());
|
||||
} else if (type != RenderType.CAVE_LAND && cave != null && cave != surface) {
|
||||
lines.add("Cave biome " + cave.getName());
|
||||
}
|
||||
if (details.vanillaBiome() != null) {
|
||||
lines.add("Minecraft biome " + details.vanillaBiome().replace('_', ' '));
|
||||
}
|
||||
}
|
||||
|
||||
private static void addSource(List<String> lines, MapLabels.Label label) {
|
||||
if (label == null || !(label.subject() instanceof IrisRegistrant registrant)) {
|
||||
return;
|
||||
}
|
||||
String kind = label.kind() == MapLabels.Kind.REGION ? "Region" : "Biome";
|
||||
lines.add(kind + " key " + label.key());
|
||||
File file = registrant.getLoadFile();
|
||||
if (file != null) {
|
||||
File folder = file.getParentFile();
|
||||
lines.add(kind + " file " + (folder == null ? "" : folder.getName() + "/") + file.getName());
|
||||
}
|
||||
}
|
||||
|
||||
private static String capitalize(String text) {
|
||||
return Character.toUpperCase(text.charAt(0)) + text.substring(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
import com.volmit.iris.engine.IrisComplex;
|
||||
import com.volmit.iris.engine.framework.Engine;
|
||||
import com.volmit.iris.engine.object.InferredType;
|
||||
import com.volmit.iris.engine.object.IrisBiome;
|
||||
import com.volmit.iris.engine.object.IrisDimension;
|
||||
import com.volmit.iris.engine.object.IrisRegion;
|
||||
import org.bukkit.block.Biome;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Looks up everything the hover card can tell about one spot of the world; runs off the Swing thread.
|
||||
*/
|
||||
final class MapHoverProbe {
|
||||
// IrisComplex measures slope as the height change over this many blocks.
|
||||
private static final double SLOPE_STREAM_RANGE_BLOCKS = 3D;
|
||||
|
||||
private MapHoverProbe() {
|
||||
}
|
||||
|
||||
static Details sample(Engine engine, double worldX, double worldZ) {
|
||||
IrisComplex complex = engine.getComplex();
|
||||
IrisBiome surfaceBiome = complex.getBaseBiomeStream().get(worldX, worldZ);
|
||||
IrisBiome caveBiome = complex.getCaveBiomeStream().get(worldX, worldZ);
|
||||
IrisRegion region = complex.getRegionStream().get(worldX, worldZ);
|
||||
InferredType terrainKind = complex.getBridgeStream().get(worldX, worldZ);
|
||||
Biome vanillaBiome = complex.getTrueBiomeDerivativeStream().get(worldX, worldZ);
|
||||
double height = complex.getHeightStream().getDouble(worldX, worldZ);
|
||||
double slope = complex.getSlopeStream().getDouble(worldX, worldZ);
|
||||
IrisDimension dimension = engine.getDimension();
|
||||
return new Details(
|
||||
worldX,
|
||||
worldZ,
|
||||
surfaceBiome,
|
||||
caveBiome,
|
||||
region,
|
||||
terrainKindName(surfaceBiome, terrainKind),
|
||||
vanillaBiome == null ? null : vanillaBiome.getKey().getKey(),
|
||||
height,
|
||||
dimension == null ? 0D : dimension.getFluidHeight(),
|
||||
Math.toDegrees(Math.atan(slope / SLOPE_STREAM_RANGE_BLOCKS))
|
||||
);
|
||||
}
|
||||
|
||||
// A shore biome sits on land the continent stream calls land or sea, so the biome's own type says it best.
|
||||
private static String terrainKindName(IrisBiome surfaceBiome, InferredType continentKind) {
|
||||
InferredType kind = surfaceBiome != null && surfaceBiome.getInferredType() != null
|
||||
? surfaceBiome.getInferredType()
|
||||
: continentKind;
|
||||
if (kind == null) {
|
||||
return null;
|
||||
}
|
||||
String name = kind.name().toLowerCase(Locale.ROOT);
|
||||
return Character.toUpperCase(name.charAt(0)) + name.substring(1);
|
||||
}
|
||||
|
||||
record Details(
|
||||
double worldX,
|
||||
double worldZ,
|
||||
IrisBiome surfaceBiome,
|
||||
IrisBiome caveBiome,
|
||||
IrisRegion region,
|
||||
String terrainKind,
|
||||
String vanillaBiome,
|
||||
double height,
|
||||
double fluidHeight,
|
||||
double slopeDegrees
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
import com.volmit.iris.core.gui.components.MapStyle;
|
||||
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.DefaultListCellRenderer;
|
||||
import javax.swing.DefaultListModel;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JList;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.ListSelectionModel;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Color;
|
||||
import java.awt.Component;
|
||||
import java.awt.Graphics;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.Point;
|
||||
import java.awt.Rectangle;
|
||||
import java.awt.RenderingHints;
|
||||
import java.awt.event.MouseAdapter;
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* The biomes or regions on screen with how much of it each covers; clicking one highlights it on the map.
|
||||
*/
|
||||
final class MapLegendPanel extends JPanel {
|
||||
private static final long serialVersionUID = 4471297416386034128L;
|
||||
private static final int PANEL_WIDTH = 250;
|
||||
private static final int ROW_HEIGHT = 26;
|
||||
private static final int SWATCH_SIZE = 12;
|
||||
private static final int SWATCH_GAP = 8;
|
||||
private static final int ROW_PADDING = 10;
|
||||
private static final int PERCENT_WIDTH = 42;
|
||||
private static final String DRAWING_MESSAGE = "Drawing the map…";
|
||||
|
||||
private final JLabel title = new JLabel();
|
||||
private final DefaultListModel<MapCensus.Share> rows = new DefaultListModel<>();
|
||||
private final JList<MapCensus.Share> list = new JList<>(rows) {
|
||||
// Long names shorten with an ellipsis instead of pushing the percentages out of sight.
|
||||
@Override
|
||||
public boolean getScrollableTracksViewportWidth() {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
private final Set<String> sharedNames = new HashSet<>();
|
||||
private MapStyle.Highlight highlight;
|
||||
|
||||
MapLegendPanel(Consumer<MapCensus.Share> onRowClicked) {
|
||||
super(new BorderLayout());
|
||||
setPreferredSize(StudioTheme.scaledSize(PANEL_WIDTH, 0));
|
||||
setBorder(BorderFactory.createMatteBorder(0, 1, 0, 0, StudioTheme.SEPARATOR));
|
||||
setBackground(StudioTheme.PANEL_BACKGROUND);
|
||||
|
||||
title.setFont(StudioTheme.sectionFont());
|
||||
title.setForeground(StudioTheme.MUTED_TEXT);
|
||||
title.setBorder(StudioTheme.emptyBorder(10, 16, 6, 16));
|
||||
add(title, BorderLayout.NORTH);
|
||||
|
||||
list.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
|
||||
list.setFocusable(false);
|
||||
list.setFixedCellHeight(StudioTheme.scale(ROW_HEIGHT));
|
||||
list.setCellRenderer(new ShareRenderer());
|
||||
list.setBackground(StudioTheme.PANEL_BACKGROUND);
|
||||
list.setBorder(StudioTheme.emptyBorder(0, 6, 4, 6));
|
||||
list.addMouseListener(new MouseAdapter() {
|
||||
@Override
|
||||
public void mouseClicked(MouseEvent event) {
|
||||
MapCensus.Share share = shareAt(event.getPoint());
|
||||
if (share != null) {
|
||||
onRowClicked.accept(share);
|
||||
}
|
||||
}
|
||||
});
|
||||
add(StudioToolbar.scroll(list), BorderLayout.CENTER);
|
||||
|
||||
JLabel hint = new JLabel("Click to highlight · Esc clears");
|
||||
hint.setFont(StudioTheme.smallFont());
|
||||
hint.setForeground(StudioTheme.MUTED_TEXT);
|
||||
hint.setBorder(StudioTheme.emptyBorder(6, 16, 8, 16));
|
||||
add(hint, BorderLayout.SOUTH);
|
||||
}
|
||||
|
||||
void show(String subjectTitle, List<MapCensus.Share> shares, MapStyle.Highlight nextHighlight) {
|
||||
title.setText(subjectTitle.toUpperCase(Locale.ROOT));
|
||||
highlight = nextHighlight;
|
||||
rememberSharedNames(shares);
|
||||
if (!sameRows(shares)) {
|
||||
rows.clear();
|
||||
rows.addAll(shares);
|
||||
}
|
||||
list.repaint();
|
||||
}
|
||||
|
||||
// Two biomes can share a display name; those rows show their key so they can be told apart.
|
||||
private void rememberSharedNames(List<MapCensus.Share> shares) {
|
||||
Set<String> seen = new HashSet<>();
|
||||
sharedNames.clear();
|
||||
for (MapCensus.Share share : shares) {
|
||||
if (!seen.add(share.label().name())) {
|
||||
sharedNames.add(share.label().name());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String rowText(MapCensus.Share share) {
|
||||
String name = share.label().name();
|
||||
return sharedNames.contains(name) ? name + " (" + share.label().key() + ")" : name;
|
||||
}
|
||||
|
||||
private boolean sameRows(List<MapCensus.Share> shares) {
|
||||
if (rows.size() != shares.size()) {
|
||||
return false;
|
||||
}
|
||||
for (int index = 0; index < shares.size(); index++) {
|
||||
if (!rows.get(index).equals(shares.get(index))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private MapCensus.Share shareAt(Point point) {
|
||||
int index = list.locationToIndex(point);
|
||||
if (index < 0) {
|
||||
return null;
|
||||
}
|
||||
Rectangle bounds = list.getCellBounds(index, index);
|
||||
return bounds != null && bounds.contains(point) ? rows.get(index) : null;
|
||||
}
|
||||
|
||||
private boolean isHighlighted(MapCensus.Share share) {
|
||||
return highlight != null
|
||||
&& highlight.kind() == share.label().kind()
|
||||
&& highlight.key().equals(share.label().key());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void paintChildren(Graphics graphics) {
|
||||
super.paintChildren(graphics);
|
||||
if (!rows.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Graphics2D canvas = (Graphics2D) graphics.create();
|
||||
try {
|
||||
canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
|
||||
canvas.setFont(StudioTheme.font());
|
||||
canvas.setColor(StudioTheme.MUTED_TEXT);
|
||||
canvas.drawString(DRAWING_MESSAGE, StudioTheme.scale(16),
|
||||
title.getHeight() + StudioTheme.scale(4) + canvas.getFontMetrics().getAscent());
|
||||
} finally {
|
||||
canvas.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private final class ShareRenderer extends DefaultListCellRenderer {
|
||||
private static final long serialVersionUID = -2350212934617052618L;
|
||||
private Color swatch = Color.BLACK;
|
||||
private String percent = "";
|
||||
|
||||
@Override
|
||||
public Component getListCellRendererComponent(JList<?> owner, Object value, int index, boolean selected, boolean focused) {
|
||||
MapCensus.Share share = (MapCensus.Share) value;
|
||||
super.getListCellRendererComponent(owner, rowText(share), index, isHighlighted(share), false);
|
||||
setFont(StudioTheme.font());
|
||||
setBorder(StudioTheme.emptyBorder(0, ROW_PADDING + SWATCH_SIZE + SWATCH_GAP, 0, ROW_PADDING + PERCENT_WIDTH));
|
||||
setToolTipText(share.label().key());
|
||||
swatch = new Color(share.color());
|
||||
percent = formatPercent(share.fraction());
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void paintComponent(Graphics graphics) {
|
||||
super.paintComponent(graphics);
|
||||
Graphics2D canvas = (Graphics2D) graphics.create();
|
||||
try {
|
||||
canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
|
||||
int size = StudioTheme.scale(SWATCH_SIZE);
|
||||
int swatchX = StudioTheme.scale(ROW_PADDING);
|
||||
int swatchY = (getHeight() - size) / 2;
|
||||
int arc = StudioTheme.scale(4);
|
||||
canvas.setColor(swatch);
|
||||
canvas.fillRoundRect(swatchX, swatchY, size, size, arc, arc);
|
||||
canvas.setColor(StudioTheme.CONTROL_BORDER);
|
||||
canvas.drawRoundRect(swatchX, swatchY, size - 1, size - 1, arc, arc);
|
||||
canvas.setFont(StudioTheme.smallFont());
|
||||
canvas.setColor(StudioTheme.SECONDARY_TEXT);
|
||||
int textWidth = canvas.getFontMetrics().stringWidth(percent);
|
||||
int baseline = (getHeight() + canvas.getFontMetrics().getAscent() - canvas.getFontMetrics().getDescent()) / 2;
|
||||
canvas.drawString(percent, getWidth() - StudioTheme.scale(ROW_PADDING) - textWidth, baseline);
|
||||
} finally {
|
||||
canvas.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String formatPercent(double fraction) {
|
||||
double percent = fraction * 100D;
|
||||
if (percent < 0.1D) {
|
||||
return "<0.1%";
|
||||
}
|
||||
return percent < 10D
|
||||
? String.format(Locale.ROOT, "%.1f%%", percent)
|
||||
: String.format(Locale.ROOT, "%.0f%%", percent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
import com.volmit.iris.core.gui.components.RenderType;
|
||||
|
||||
import java.awt.event.KeyEvent;
|
||||
|
||||
/**
|
||||
* Everything the map window can show, in the order and groups of its view list.
|
||||
*/
|
||||
enum MapView {
|
||||
BIOMES("Biomes", "The biome at each spot, in the biome's colour", Group.WORLD, RenderType.BIOME, KeyEvent.VK_1),
|
||||
REGIONS("Regions", "The region each spot belongs to", Group.WORLD, RenderType.REGION, KeyEvent.VK_2),
|
||||
HEIGHT("Height", "Terrain height, from deep water to snowy peaks", Group.WORLD, RenderType.HEIGHT, KeyEvent.VK_3),
|
||||
CONTINENTS("Continents", "Land, coast or sea, judged by each biome's first height generator",
|
||||
Group.WORLD, RenderType.CONTINENT, KeyEvent.VK_4),
|
||||
LAND_BIOMES("Land biomes", "The land biome each spot would get, also where the sea is",
|
||||
Group.BIOME_STREAMS, RenderType.BIOME_LAND, KeyEvent.VK_5),
|
||||
SEA_BIOMES("Sea biomes", "The sea biome each spot would get, also where the land is",
|
||||
Group.BIOME_STREAMS, RenderType.BIOME_SEA, KeyEvent.VK_6),
|
||||
CAVE_BIOMES("Cave biomes", "The cave biome under each spot", Group.BIOME_STREAMS, RenderType.CAVE_LAND, KeyEvent.VK_7),
|
||||
PALETTES("Palettes", "The blocks each biome's palette layers place, with their textures",
|
||||
Group.BLOCKS, null, KeyEvent.VK_P),
|
||||
OBJECTS("Objects", "Every object in the pack in 3D, with the blocks it is made of",
|
||||
Group.BLOCKS, null, KeyEvent.VK_O),
|
||||
NOISE("Noise", "The pack's noise generators and Iris' built-in noise styles",
|
||||
Group.GENERATORS, null, KeyEvent.VK_N);
|
||||
|
||||
private final String label;
|
||||
private final String description;
|
||||
private final Group group;
|
||||
private final RenderType renderType;
|
||||
private final int shortcutKey;
|
||||
|
||||
MapView(String label, String description, Group group, RenderType renderType, int shortcutKey) {
|
||||
this.label = label;
|
||||
this.description = description;
|
||||
this.group = group;
|
||||
this.renderType = renderType;
|
||||
this.shortcutKey = shortcutKey;
|
||||
}
|
||||
|
||||
String label() {
|
||||
return label;
|
||||
}
|
||||
|
||||
String description() {
|
||||
return description;
|
||||
}
|
||||
|
||||
Group group() {
|
||||
return group;
|
||||
}
|
||||
|
||||
/**
|
||||
* The tile renderer's mode for this view, or null for the palette, object and noise views, which draw themselves.
|
||||
*/
|
||||
RenderType renderType() {
|
||||
return renderType;
|
||||
}
|
||||
|
||||
int shortcutKey() {
|
||||
return shortcutKey;
|
||||
}
|
||||
|
||||
String shortcutText() {
|
||||
return KeyEvent.getKeyText(shortcutKey);
|
||||
}
|
||||
|
||||
boolean isTileMap() {
|
||||
return renderType != null;
|
||||
}
|
||||
|
||||
static MapView forShortcut(int keyCode) {
|
||||
for (MapView view : values()) {
|
||||
if (view.shortcutKey == keyCode) {
|
||||
return view;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
enum Group {
|
||||
WORLD("World"),
|
||||
BIOME_STREAMS("Biome layers"),
|
||||
BLOCKS("Blocks"),
|
||||
GENERATORS("Generators");
|
||||
|
||||
private final String label;
|
||||
|
||||
Group(String label) {
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
String label() {
|
||||
return label;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
import com.formdev.flatlaf.extras.FlatSVGIcon;
|
||||
|
||||
import javax.swing.Icon;
|
||||
import java.util.EnumMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* The icon of each map view, taken from IntelliJ's new UI icon set (see the NOTICE next to the files).
|
||||
*/
|
||||
final class MapViewIcon {
|
||||
private static final String ICON_FOLDER = "iris/icons/views/";
|
||||
private static final int SIZE = 16;
|
||||
private static final Map<MapView, Icon> LOADED = new EnumMap<>(MapView.class);
|
||||
|
||||
private MapViewIcon() {
|
||||
}
|
||||
|
||||
static Icon of(MapView view) {
|
||||
return LOADED.computeIfAbsent(view, MapViewIcon::load);
|
||||
}
|
||||
|
||||
private static Icon load(MapView view) {
|
||||
String fileName = view.name().toLowerCase(Locale.ROOT).replace('_', '-') + ".svg";
|
||||
return new FlatSVGIcon(ICON_FOLDER + fileName, SIZE, SIZE, MapViewIcon.class.getClassLoader());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
import javax.swing.DefaultListCellRenderer;
|
||||
import javax.swing.DefaultListModel;
|
||||
import javax.swing.JList;
|
||||
import javax.swing.ListSelectionModel;
|
||||
import javax.swing.UIManager;
|
||||
import java.awt.Component;
|
||||
import java.awt.Graphics;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.Insets;
|
||||
import java.awt.Rectangle;
|
||||
import java.awt.RenderingHints;
|
||||
import java.awt.event.MouseAdapter;
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.util.Locale;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* The map window's list of views, grouped under headers, each row with a picture of the view and the key that opens
|
||||
* it.
|
||||
*/
|
||||
final class MapViewList extends JList<MapViewList.Row> {
|
||||
private static final long serialVersionUID = -4130872409914437415L;
|
||||
private static final int ROW_HEIGHT = 28;
|
||||
private static final int NO_ROW = -1;
|
||||
|
||||
private final DefaultListModel<Row> rows = new DefaultListModel<>();
|
||||
private int lastViewRow;
|
||||
private int hoveredRow = NO_ROW;
|
||||
|
||||
MapViewList(Consumer<MapView> onViewChosen) {
|
||||
fillRows();
|
||||
setModel(rows);
|
||||
setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
|
||||
setFixedCellHeight(StudioTheme.scale(ROW_HEIGHT));
|
||||
setCellRenderer(new RowRenderer());
|
||||
setBorder(StudioTheme.emptyBorder(4, 6, 4, 6));
|
||||
addListSelectionListener(event -> {
|
||||
if (event.getValueIsAdjusting() || skipHeaderRow()) {
|
||||
return;
|
||||
}
|
||||
Row selected = getSelectedValue();
|
||||
if (selected == null) {
|
||||
return;
|
||||
}
|
||||
lastViewRow = getSelectedIndex();
|
||||
onViewChosen.accept(selected.view());
|
||||
});
|
||||
MouseAdapter hover = new MouseAdapter() {
|
||||
@Override
|
||||
public void mouseMoved(MouseEvent event) {
|
||||
hover(viewRowAt(event));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseExited(MouseEvent event) {
|
||||
hover(NO_ROW);
|
||||
}
|
||||
};
|
||||
addMouseListener(hover);
|
||||
addMouseMotionListener(hover);
|
||||
}
|
||||
|
||||
private int viewRowAt(MouseEvent event) {
|
||||
int row = locationToIndex(event.getPoint());
|
||||
if (row < 0 || rows.get(row).isHeader() || !getCellBounds(row, row).contains(event.getPoint())) {
|
||||
return NO_ROW;
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
private void hover(int row) {
|
||||
if (row == hoveredRow) {
|
||||
return;
|
||||
}
|
||||
repaintRow(hoveredRow);
|
||||
hoveredRow = row;
|
||||
repaintRow(hoveredRow);
|
||||
}
|
||||
|
||||
private void repaintRow(int row) {
|
||||
Rectangle bounds = row == NO_ROW ? null : getCellBounds(row, row);
|
||||
if (bounds != null) {
|
||||
repaint(bounds);
|
||||
}
|
||||
}
|
||||
|
||||
void select(MapView view) {
|
||||
for (int row = 0; row < rows.size(); row++) {
|
||||
if (rows.get(row).view() == view) {
|
||||
lastViewRow = row;
|
||||
setSelectedIndex(row);
|
||||
ensureIndexIsVisible(row);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void fillRows() {
|
||||
MapView.Group currentGroup = null;
|
||||
for (MapView view : MapView.values()) {
|
||||
if (view.group() != currentGroup) {
|
||||
currentGroup = view.group();
|
||||
rows.addElement(Row.header(currentGroup));
|
||||
}
|
||||
rows.addElement(Row.of(view));
|
||||
}
|
||||
}
|
||||
|
||||
// Headers can't be chosen: clicking one keeps the current view, and the arrow keys carry on past it.
|
||||
private boolean skipHeaderRow() {
|
||||
int row = getSelectedIndex();
|
||||
if (row < 0 || !rows.get(row).isHeader()) {
|
||||
return false;
|
||||
}
|
||||
int step = row >= lastViewRow ? 1 : -1;
|
||||
int next = nextViewRow(row, step);
|
||||
if (next < 0) {
|
||||
next = nextViewRow(row, -step);
|
||||
}
|
||||
setSelectedIndex(next < 0 ? lastViewRow : next);
|
||||
ensureIndexIsVisible(getSelectedIndex());
|
||||
return true;
|
||||
}
|
||||
|
||||
private int nextViewRow(int from, int step) {
|
||||
for (int row = from + step; row >= 0 && row < rows.size(); row += step) {
|
||||
if (!rows.get(row).isHeader()) {
|
||||
return row;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
record Row(MapView.Group group, MapView view) {
|
||||
static Row header(MapView.Group group) {
|
||||
return new Row(group, null);
|
||||
}
|
||||
|
||||
static Row of(MapView view) {
|
||||
return new Row(view.group(), view);
|
||||
}
|
||||
|
||||
boolean isHeader() {
|
||||
return view == null;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RowRenderer extends DefaultListCellRenderer {
|
||||
private static final long serialVersionUID = 6302156810211578474L;
|
||||
private static final int ICON_TEXT_GAP = 8;
|
||||
private static final int KEYCAP_SIZE = 17;
|
||||
private static final int KEYCAP_ARC = 5;
|
||||
private String shortcut;
|
||||
private boolean hovered;
|
||||
|
||||
@Override
|
||||
public Component getListCellRendererComponent(JList<?> list, Object value, int index, boolean selected, boolean focused) {
|
||||
Row row = (Row) value;
|
||||
super.getListCellRendererComponent(list, row.isHeader() ? "" : row.view().label(), index, selected && !row.isHeader(), false);
|
||||
if (row.isHeader()) {
|
||||
setText(row.group().label().toUpperCase(Locale.ROOT));
|
||||
setFont(StudioTheme.sectionFont());
|
||||
setForeground(StudioTheme.MUTED_TEXT);
|
||||
setBorder(StudioTheme.emptyBorder(index == 0 ? 2 : 10, 8, 0, 6));
|
||||
setIcon(null);
|
||||
setToolTipText(null);
|
||||
shortcut = null;
|
||||
hovered = false;
|
||||
setOpaque(true);
|
||||
return this;
|
||||
}
|
||||
setFont(StudioTheme.font());
|
||||
setBorder(StudioTheme.emptyBorder(0, 8, 0, 8));
|
||||
setIcon(MapViewIcon.of(row.view()));
|
||||
setIconTextGap(StudioTheme.scale(ICON_TEXT_GAP));
|
||||
setToolTipText(row.view().description());
|
||||
shortcut = row.view().shortcutText();
|
||||
hovered = !selected && list instanceof MapViewList viewList && viewList.hoveredRow == index;
|
||||
setOpaque(!hovered);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void paintComponent(Graphics graphics) {
|
||||
Graphics2D canvas = (Graphics2D) graphics.create();
|
||||
try {
|
||||
canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
|
||||
if (hovered) {
|
||||
paintHoverBackground(canvas);
|
||||
}
|
||||
super.paintComponent(graphics);
|
||||
if (shortcut != null) {
|
||||
paintKeycap(canvas);
|
||||
}
|
||||
} finally {
|
||||
canvas.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// Shaped like the selection the look and feel paints, so moving between them doesn't jump.
|
||||
private void paintHoverBackground(Graphics2D canvas) {
|
||||
Insets inset = UIManager.getInsets("List.selectionInsets");
|
||||
int left = inset == null ? 0 : StudioTheme.scale(inset.left);
|
||||
int right = inset == null ? 0 : StudioTheme.scale(inset.right);
|
||||
int arc = StudioTheme.scale(UIManager.getInt("List.selectionArc"));
|
||||
canvas.setColor(StudioTheme.HOVER_BACKGROUND);
|
||||
canvas.fillRoundRect(left, 0, getWidth() - left - right, getHeight(), arc, arc);
|
||||
}
|
||||
|
||||
private void paintKeycap(Graphics2D canvas) {
|
||||
canvas.setFont(StudioTheme.smallFont());
|
||||
int textWidth = canvas.getFontMetrics().stringWidth(shortcut);
|
||||
int height = StudioTheme.scale(KEYCAP_SIZE);
|
||||
int width = Math.max(height, textWidth + StudioTheme.scale(8));
|
||||
int x = getWidth() - getInsets().right - width;
|
||||
int y = (getHeight() - height) / 2;
|
||||
int arc = StudioTheme.scale(KEYCAP_ARC);
|
||||
canvas.setColor(StudioTheme.CONTROL_BORDER);
|
||||
canvas.drawRoundRect(x, y, width - 1, height - 1, arc, arc);
|
||||
canvas.setColor(StudioTheme.SECONDARY_TEXT);
|
||||
int baseline = y + (height + canvas.getFontMetrics().getAscent() - canvas.getFontMetrics().getDescent()) / 2;
|
||||
canvas.drawString(shortcut, x + (width - textWidth) / 2, baseline);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
import com.volmit.iris.core.gui.components.MapStyle;
|
||||
|
||||
import javax.swing.AbstractAction;
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.Box;
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.JFrame;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JRootPane;
|
||||
import javax.swing.JSplitPane;
|
||||
import javax.swing.JToggleButton;
|
||||
import javax.swing.JToolBar;
|
||||
import javax.swing.KeyStroke;
|
||||
import javax.swing.SwingConstants;
|
||||
import javax.swing.text.JTextComponent;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.CardLayout;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.EventQueue;
|
||||
import java.awt.KeyboardFocusManager;
|
||||
import java.awt.event.ActionEvent;
|
||||
import java.awt.event.ComponentAdapter;
|
||||
import java.awt.event.ComponentEvent;
|
||||
import java.awt.event.KeyEvent;
|
||||
import java.awt.event.WindowAdapter;
|
||||
import java.awt.event.WindowEvent;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.function.IntConsumer;
|
||||
|
||||
/**
|
||||
* The studio map: one window showing the world as tiles in several views, with the palette, object and noise explorers
|
||||
* as its Palettes, Objects and Noise views.
|
||||
*/
|
||||
public final class MapWindow implements MapCanvas.Listener {
|
||||
private static final String MAP_CARD = "map";
|
||||
private static final String PALETTES_CARD = "palettes";
|
||||
private static final String OBJECTS_CARD = "objects";
|
||||
private static final String NOISE_CARD = "noise";
|
||||
private static final int SIDEBAR_WIDTH = 270;
|
||||
private static final int SIDEBAR_MINIMUM_WIDTH = 200;
|
||||
private static final int CONTENT_MINIMUM_WIDTH = 320;
|
||||
private static final double PALETTE_RECENTER_DISTANCE_BLOCKS = 1D;
|
||||
private static final int VIEW_LIST_SCROLL_INSETS = 4;
|
||||
private static final int EXPLORER_DIVIDER_HEIGHT = 7;
|
||||
|
||||
private final JFrame frame;
|
||||
private final PaletteExplorerStart start;
|
||||
private final MapCanvas canvas;
|
||||
private final MapViewList viewList;
|
||||
private final JLabel viewTitle = new JLabel();
|
||||
private final JPanel content = new JPanel(new CardLayout());
|
||||
private final JPanel statusBars = new JPanel(new CardLayout());
|
||||
private final JPanel sidebar = new JPanel(new BorderLayout());
|
||||
private final IslandPanel sidebarIsland = new IslandPanel(sidebar);
|
||||
private final JPanel inspectorSlot = new JPanel(new BorderLayout());
|
||||
private final JComponent viewListScroll;
|
||||
private final JSplitPane explorerSplit = new JSplitPane(JSplitPane.VERTICAL_SPLIT);
|
||||
private final List<JComponent> mapControls = new ArrayList<>();
|
||||
private final List<JComponent> explorerControls = new ArrayList<>();
|
||||
private final List<JComponent> noiseControls = new ArrayList<>();
|
||||
private final JToolBar toolbar = StudioToolbar.create();
|
||||
private final JComponent resetViewButton = StudioToolbar.button("Reset view", "Go back to the starting position and zoom (R)",
|
||||
this::resetExplorerView);
|
||||
private final JToggleButton gridToggle = StudioToolbar.toggle("Grid", "Show chunk and region borders (G)");
|
||||
private final JToggleButton entitiesToggle = StudioToolbar.toggle("Entities", "Show mobs and other entities (E)");
|
||||
private final JToggleButton followToggle = StudioToolbar.toggle("Follow", "Keep your player in the middle (F)");
|
||||
private final JToggleButton shadingToggle = StudioToolbar.toggle("Shading", "Shade hills and valleys on every view (S)");
|
||||
private final JToggleButton bordersToggle = StudioToolbar.toggle("Borders", "Outline the regions on every view (B)");
|
||||
private final MapLegendPanel legendPanel;
|
||||
|
||||
private NoiseExplorerGUI paletteExplorer;
|
||||
private ObjectExplorer objectExplorer;
|
||||
private NoiseExplorerGUI noiseExplorer;
|
||||
private JComponent noiseSidebar;
|
||||
private MapView currentView;
|
||||
private double paletteCenterX;
|
||||
private double paletteCenterZ;
|
||||
|
||||
private MapWindow(PaletteExplorerStart start) {
|
||||
this.start = start;
|
||||
this.frame = GuiHost.createFrame("Iris Map");
|
||||
this.canvas = new MapCanvas(start.engine(), start.worldId(), start.playerId(), this);
|
||||
this.viewList = new MapViewList(this::showView);
|
||||
this.viewListScroll = StudioToolbar.scroll(viewList);
|
||||
this.legendPanel = new MapLegendPanel(share -> canvas.toggleHighlight(share.label()));
|
||||
canvas.centerOn(start.centerX(), start.centerZ());
|
||||
buildFrame();
|
||||
}
|
||||
|
||||
public static void openMap(PaletteExplorerStart start) {
|
||||
EventQueue.invokeLater(() -> open(start, MapView.BIOMES));
|
||||
}
|
||||
|
||||
public static void openPalettes(PaletteExplorerStart start) {
|
||||
EventQueue.invokeLater(() -> open(start, MapView.PALETTES));
|
||||
}
|
||||
|
||||
public static void openNoise(PaletteExplorerStart start) {
|
||||
EventQueue.invokeLater(() -> open(start, MapView.NOISE));
|
||||
}
|
||||
|
||||
private static void open(PaletteExplorerStart start, MapView initialView) {
|
||||
StudioTheme.install();
|
||||
MapWindow window = new MapWindow(start);
|
||||
window.frame.setVisible(true);
|
||||
window.canvas.start();
|
||||
window.showView(initialView);
|
||||
}
|
||||
|
||||
private void buildFrame() {
|
||||
styleExplorerSplit();
|
||||
content.add(canvas, MAP_CARD);
|
||||
statusBars.add(canvas.statusBar(), MAP_CARD);
|
||||
sidebar.add(viewListScroll, BorderLayout.CENTER);
|
||||
|
||||
frame.setLayout(new BorderLayout());
|
||||
frame.add(buildToolbar(), BorderLayout.NORTH);
|
||||
frame.add(new IslandOverlay(sidebarIsland, content, StudioTheme.scale(SIDEBAR_WIDTH),
|
||||
StudioTheme.scale(SIDEBAR_MINIMUM_WIDTH), StudioTheme.scale(CONTENT_MINIMUM_WIDTH)), BorderLayout.CENTER);
|
||||
frame.add(inspectorSlot, BorderLayout.EAST);
|
||||
frame.add(statusBars, BorderLayout.SOUTH);
|
||||
installShortcuts(frame.getRootPane());
|
||||
keepClearOfIsland(canvas::setCoveredLeftWidth);
|
||||
frame.setSize(GuiHost.fitToScreen(StudioTheme.scaledSize(1440, 880)));
|
||||
frame.setMinimumSize(StudioTheme.scaledSize(820, 520));
|
||||
frame.setLocationRelativeTo(null);
|
||||
frame.addWindowListener(new WindowAdapter() {
|
||||
@Override
|
||||
public void windowClosed(WindowEvent event) {
|
||||
close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private JToolBar buildToolbar() {
|
||||
viewTitle.setFont(StudioTheme.titleFont());
|
||||
viewTitle.setBorder(StudioTheme.emptyBorder(0, 6, 0, 14));
|
||||
toolbar.add(viewTitle);
|
||||
|
||||
mapControls.add(StudioToolbar.button("Refresh", "Redraw the map with the latest pack files (R)", canvas::refreshContent));
|
||||
mapControls.add(StudioToolbar.button("Reset zoom", "Go back to the default zoom (\\)", canvas::resetZoom));
|
||||
mapControls.add(gridToggle);
|
||||
mapControls.add(entitiesToggle);
|
||||
mapControls.add(followToggle);
|
||||
mapControls.add(toolbarSeparator());
|
||||
mapControls.add(shadingToggle);
|
||||
mapControls.add(bordersToggle);
|
||||
gridToggle.addActionListener(event -> canvas.toggleGrid());
|
||||
entitiesToggle.addActionListener(event -> canvas.toggleEntities());
|
||||
followToggle.addActionListener(event -> canvas.toggleFollow());
|
||||
shadingToggle.addActionListener(event -> canvas.toggleShading());
|
||||
bordersToggle.addActionListener(event -> canvas.toggleRegionBorders());
|
||||
mapControls.forEach(toolbar::add);
|
||||
|
||||
explorerControls.add(resetViewButton);
|
||||
explorerControls.forEach(toolbar::add);
|
||||
|
||||
toolbar.add(Box.createHorizontalGlue());
|
||||
toolbar.add(StudioToolbar.themeButton());
|
||||
JComponent help = StudioToolbar.button("Shortcuts", "Show every mouse and key shortcut (/)", canvas::toggleHelp);
|
||||
mapControls.add(help);
|
||||
toolbar.add(help);
|
||||
return toolbar;
|
||||
}
|
||||
|
||||
private static JComponent toolbarSeparator() {
|
||||
JToolBar.Separator separator = new JToolBar.Separator();
|
||||
separator.setOrientation(SwingConstants.VERTICAL);
|
||||
return separator;
|
||||
}
|
||||
|
||||
private void showView(MapView view) {
|
||||
if (view == null || view == currentView) {
|
||||
return;
|
||||
}
|
||||
MapView previousView = currentView;
|
||||
currentView = view;
|
||||
viewList.select(view);
|
||||
viewTitle.setText(view.label());
|
||||
viewTitle.setToolTipText(view.description());
|
||||
if (view.isTileMap()) {
|
||||
showMap(previousView, view);
|
||||
} else if (view == MapView.PALETTES) {
|
||||
showPalettes(previousView);
|
||||
} else if (view == MapView.OBJECTS) {
|
||||
showObjects();
|
||||
} else {
|
||||
showNoise();
|
||||
}
|
||||
frame.getRootPane().revalidate();
|
||||
frame.getRootPane().repaint();
|
||||
}
|
||||
|
||||
private void showMap(MapView previousView, MapView view) {
|
||||
if (previousView == MapView.PALETTES && paletteExplorer != null) {
|
||||
canvas.centerOn(paletteExplorer.centerX(), paletteExplorer.centerZ());
|
||||
}
|
||||
canvas.showView(view);
|
||||
canvas.setActive(true);
|
||||
showControls(mapControls);
|
||||
useSidebar(viewListScroll);
|
||||
inspectorSlot.removeAll();
|
||||
inspectorSlot.add(legendPanel, BorderLayout.CENTER);
|
||||
showCard(MAP_CARD);
|
||||
canvas.requestFocusInWindow();
|
||||
}
|
||||
|
||||
private void showPalettes(MapView previousView) {
|
||||
boolean firstOpen = paletteExplorer == null;
|
||||
if (firstOpen) {
|
||||
createPaletteExplorer(previousView == null);
|
||||
} else {
|
||||
followMapIntoPalettes();
|
||||
}
|
||||
canvas.setActive(false);
|
||||
showControls(explorerControls);
|
||||
useSidebar(viewListAbove(paletteExplorer.paletteSidebar()));
|
||||
inspectorSlot.removeAll();
|
||||
inspectorSlot.add(paletteExplorer.paletteInspector(), BorderLayout.CENTER);
|
||||
showCard(PALETTES_CARD);
|
||||
EventQueue.invokeLater(() -> viewList.select(currentView));
|
||||
if (firstOpen) {
|
||||
paletteExplorer.openViewer();
|
||||
}
|
||||
}
|
||||
|
||||
private void showObjects() {
|
||||
boolean firstOpen = objectExplorer == null;
|
||||
if (firstOpen) {
|
||||
createObjectExplorer();
|
||||
}
|
||||
canvas.setActive(false);
|
||||
showControls(explorerControls);
|
||||
useSidebar(viewListAbove(objectExplorer.browser()));
|
||||
inspectorSlot.removeAll();
|
||||
inspectorSlot.add(objectExplorer.overview(), BorderLayout.CENTER);
|
||||
showCard(OBJECTS_CARD);
|
||||
EventQueue.invokeLater(() -> viewList.select(currentView));
|
||||
if (firstOpen) {
|
||||
objectExplorer.open();
|
||||
}
|
||||
}
|
||||
|
||||
private void keepClearOfIsland(IntConsumer setCoveredLeftWidth) {
|
||||
setCoveredLeftWidth.accept(sidebarIsland.getWidth());
|
||||
sidebarIsland.addComponentListener(new ComponentAdapter() {
|
||||
@Override
|
||||
public void componentResized(ComponentEvent event) {
|
||||
setCoveredLeftWidth.accept(sidebarIsland.getWidth());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void createObjectExplorer() {
|
||||
objectExplorer = new ObjectExplorer(start.engine(), start.worldId());
|
||||
keepClearOfIsland(objectExplorer.viewport()::setCoveredLeftWidth);
|
||||
content.add(objectExplorer.view(), OBJECTS_CARD);
|
||||
statusBars.add(objectExplorer.statusBar(), OBJECTS_CARD);
|
||||
}
|
||||
|
||||
private void showNoise() {
|
||||
boolean firstOpen = noiseExplorer == null;
|
||||
if (firstOpen) {
|
||||
createNoiseExplorer();
|
||||
}
|
||||
canvas.setActive(false);
|
||||
showControls(noiseControls, explorerControls);
|
||||
useSidebar(viewListAbove(noiseSidebar));
|
||||
inspectorSlot.removeAll();
|
||||
showCard(NOISE_CARD);
|
||||
EventQueue.invokeLater(() -> viewList.select(currentView));
|
||||
if (firstOpen) {
|
||||
noiseExplorer.openViewer();
|
||||
}
|
||||
}
|
||||
|
||||
// Both explorers share one split, so the divider stays where it was dragged when switching between them.
|
||||
private JSplitPane viewListAbove(JComponent explorerSidebar) {
|
||||
boolean firstUse = explorerSplit.getBottomComponent() == null;
|
||||
explorerSplit.setTopComponent(viewListScroll);
|
||||
explorerSplit.setBottomComponent(explorerSidebar);
|
||||
if (firstUse) {
|
||||
EventQueue.invokeLater(this::fitViewListAboveExplorer);
|
||||
}
|
||||
return explorerSplit;
|
||||
}
|
||||
|
||||
private void styleExplorerSplit() {
|
||||
explorerSplit.setBorder(BorderFactory.createEmptyBorder());
|
||||
explorerSplit.setResizeWeight(0D);
|
||||
explorerSplit.setBackground(StudioTheme.PANEL_BACKGROUND);
|
||||
explorerSplit.setDividerSize(StudioTheme.scale(EXPLORER_DIVIDER_HEIGHT));
|
||||
explorerSplit.putClientProperty("FlatLaf.style", "style: grip");
|
||||
}
|
||||
|
||||
// Every view stays in sight, so the divider starts below the last one and can only be dragged further down. Measured
|
||||
// once the sidebar is laid out, since the list's rows are taller before they know their width.
|
||||
private void fitViewListAboveExplorer() {
|
||||
int fullHeight = viewList.getPreferredSize().height + VIEW_LIST_SCROLL_INSETS;
|
||||
viewListScroll.setMinimumSize(new Dimension(0, fullHeight));
|
||||
explorerSplit.setDividerLocation(fullHeight);
|
||||
}
|
||||
|
||||
private void createNoiseExplorer() {
|
||||
noiseExplorer = NoiseExplorerGUI.createNoiseExplorer(start.engine());
|
||||
noiseSidebar = noiseExplorer.noiseSidebar();
|
||||
noiseControls.addAll(noiseExplorer.noiseControls());
|
||||
noiseControls.forEach(control -> toolbar.add(control, toolbar.getComponentIndex(resetViewButton)));
|
||||
content.add(noiseExplorer, NOISE_CARD);
|
||||
statusBars.add(noiseExplorer.statusBar(), NOISE_CARD);
|
||||
}
|
||||
|
||||
private void createPaletteExplorer(boolean openedOnPalettes) {
|
||||
PaletteExplorerStart explorerStart = openedOnPalettes
|
||||
? start
|
||||
: new PaletteExplorerStart(start.engine(), start.worldId(), start.playerId(), null, canvas.centerX(), canvas.centerZ());
|
||||
paletteExplorer = NoiseExplorerGUI.createPaletteExplorer(explorerStart);
|
||||
content.add(paletteExplorer, PALETTES_CARD);
|
||||
statusBars.add(paletteExplorer.statusBar(), PALETTES_CARD);
|
||||
rememberPaletteCenter(explorerStart.centerX(), explorerStart.centerZ());
|
||||
if (!openedOnPalettes) {
|
||||
showSurfaceBiomePalettes(explorerStart.centerX(), explorerStart.centerZ());
|
||||
}
|
||||
}
|
||||
|
||||
// Coming back from the map only moves the palettes when the map was moved, so a chosen layer survives a quick look.
|
||||
private void followMapIntoPalettes() {
|
||||
double movedX = canvas.centerX() - paletteCenterX;
|
||||
double movedZ = canvas.centerZ() - paletteCenterZ;
|
||||
if (Math.hypot(movedX, movedZ) < PALETTE_RECENTER_DISTANCE_BLOCKS) {
|
||||
return;
|
||||
}
|
||||
paletteExplorer.centerOn(canvas.centerX(), canvas.centerZ());
|
||||
rememberPaletteCenter(canvas.centerX(), canvas.centerZ());
|
||||
showSurfaceBiomePalettes(canvas.centerX(), canvas.centerZ());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onShowPalettesAt(double worldX, double worldZ) {
|
||||
canvas.centerOn(worldX, worldZ);
|
||||
showView(MapView.PALETTES);
|
||||
}
|
||||
|
||||
private void showSurfaceBiomePalettes(double worldX, double worldZ) {
|
||||
canvas.findSurfaceBiome(worldX, worldZ, biomeKey -> {
|
||||
if (paletteExplorer != null) {
|
||||
paletteExplorer.showPalettesAt(worldX, worldZ, biomeKey);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void rememberPaletteCenter(double worldX, double worldZ) {
|
||||
paletteCenterX = worldX;
|
||||
paletteCenterZ = worldZ;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTogglesChanged() {
|
||||
gridToggle.setSelected(canvas.isGridVisible());
|
||||
entitiesToggle.setSelected(canvas.areEntitiesVisible());
|
||||
followToggle.setSelected(canvas.isFollowing());
|
||||
shadingToggle.setSelected(canvas.style().shading());
|
||||
bordersToggle.setSelected(canvas.style().regionBorders());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLegendChanged(String title, List<MapCensus.Share> shares, MapStyle.Highlight highlight) {
|
||||
legendPanel.show(title, shares, highlight);
|
||||
}
|
||||
|
||||
private void resetExplorerView() {
|
||||
if (currentView == MapView.OBJECTS) {
|
||||
objectExplorer.resetCamera();
|
||||
return;
|
||||
}
|
||||
NoiseExplorerGUI explorer = currentView == MapView.NOISE ? noiseExplorer : paletteExplorer;
|
||||
if (explorer != null) {
|
||||
explorer.resetViewport();
|
||||
}
|
||||
}
|
||||
|
||||
private void useSidebar(JComponent component) {
|
||||
sidebar.removeAll();
|
||||
sidebar.add(component, BorderLayout.CENTER);
|
||||
}
|
||||
|
||||
private void showCard(String card) {
|
||||
((CardLayout) content.getLayout()).show(content, card);
|
||||
((CardLayout) statusBars.getLayout()).show(statusBars, card);
|
||||
}
|
||||
|
||||
@SafeVarargs
|
||||
private void showControls(List<JComponent>... visibleControls) {
|
||||
for (List<JComponent> controls : List.of(mapControls, explorerControls, noiseControls)) {
|
||||
controls.forEach(control -> control.setVisible(false));
|
||||
}
|
||||
for (List<JComponent> controls : visibleControls) {
|
||||
controls.forEach(control -> control.setVisible(true));
|
||||
}
|
||||
}
|
||||
|
||||
private void installShortcuts(JRootPane rootPane) {
|
||||
for (MapView view : MapView.values()) {
|
||||
bind(rootPane, KeyStroke.getKeyStroke(view.shortcutKey(), 0), "view-" + view.name(), () -> showView(view));
|
||||
}
|
||||
bind(rootPane, KeyStroke.getKeyStroke(KeyEvent.VK_M, 0), "next-view", this::showNextView);
|
||||
bind(rootPane, KeyStroke.getKeyStroke(KeyEvent.VK_R, 0), "refresh", this::refreshOrReset);
|
||||
bindMap(rootPane, KeyEvent.VK_F, 0, "follow", canvas::toggleFollow);
|
||||
bindMap(rootPane, KeyEvent.VK_G, 0, "grid", canvas::toggleGrid);
|
||||
bindMap(rootPane, KeyEvent.VK_E, 0, "entities", canvas::toggleEntities);
|
||||
bindMap(rootPane, KeyEvent.VK_F3, 0, "debug", canvas::toggleDebug);
|
||||
bindMap(rootPane, KeyEvent.VK_H, 0, "highlight", canvas::highlightUnderCursor);
|
||||
bindMap(rootPane, KeyEvent.VK_ESCAPE, 0, "dismiss", canvas::dismissEmphasis);
|
||||
bindMap(rootPane, KeyEvent.VK_S, 0, "shading", canvas::toggleShading);
|
||||
bindMap(rootPane, KeyEvent.VK_B, 0, "borders", canvas::toggleRegionBorders);
|
||||
bindMap(rootPane, KeyEvent.VK_EQUALS, 0, "zoom-in", canvas::zoomIn);
|
||||
bindMap(rootPane, KeyEvent.VK_EQUALS, KeyEvent.SHIFT_DOWN_MASK, "zoom-in-plus", canvas::zoomIn);
|
||||
bindMap(rootPane, KeyEvent.VK_ADD, 0, "zoom-in-keypad", canvas::zoomIn);
|
||||
bindMap(rootPane, KeyEvent.VK_MINUS, 0, "zoom-out", canvas::zoomOut);
|
||||
bindMap(rootPane, KeyEvent.VK_SUBTRACT, 0, "zoom-out-keypad", canvas::zoomOut);
|
||||
bindMap(rootPane, KeyEvent.VK_BACK_SLASH, 0, "reset-zoom", canvas::resetZoom);
|
||||
bindMap(rootPane, KeyEvent.VK_SLASH, 0, "help", canvas::toggleHelp);
|
||||
bindMap(rootPane, KeyEvent.VK_SLASH, KeyEvent.SHIFT_DOWN_MASK, "help-question", canvas::toggleHelp);
|
||||
bind(rootPane, KeyStroke.getKeyStroke(KeyEvent.VK_SHIFT, KeyEvent.SHIFT_DOWN_MASK, false), "details-on",
|
||||
() -> canvas.setDetailedHover(true));
|
||||
bind(rootPane, KeyStroke.getKeyStroke(KeyEvent.VK_SHIFT, 0, true), "details-off", () -> canvas.setDetailedHover(false));
|
||||
}
|
||||
|
||||
private void bindMap(JRootPane rootPane, int keyCode, int modifiers, String name, Runnable action) {
|
||||
bind(rootPane, KeyStroke.getKeyStroke(keyCode, modifiers), name, () -> {
|
||||
if (currentView != null && currentView.isTileMap()) {
|
||||
action.run();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Keys typed into a search box belong to that box, not to the window's shortcuts.
|
||||
private static void bind(JRootPane rootPane, KeyStroke keyStroke, String name, Runnable action) {
|
||||
rootPane.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(keyStroke, name);
|
||||
rootPane.getActionMap().put(name, new AbstractAction() {
|
||||
@Override
|
||||
public void actionPerformed(ActionEvent event) {
|
||||
if (!(KeyboardFocusManager.getCurrentKeyboardFocusManager().getFocusOwner() instanceof JTextComponent)) {
|
||||
action.run();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void showNextView() {
|
||||
MapView[] views = MapView.values();
|
||||
showView(views[(currentView.ordinal() + 1) % views.length]);
|
||||
}
|
||||
|
||||
private void refreshOrReset() {
|
||||
if (currentView.isTileMap()) {
|
||||
canvas.refreshContent();
|
||||
} else {
|
||||
resetExplorerView();
|
||||
}
|
||||
}
|
||||
|
||||
private void close() {
|
||||
canvas.close();
|
||||
if (paletteExplorer != null) {
|
||||
paletteExplorer.close();
|
||||
}
|
||||
if (objectExplorer != null) {
|
||||
objectExplorer.close();
|
||||
}
|
||||
if (noiseExplorer != null) {
|
||||
noiseExplorer.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.volmit.iris.engine.object.IrisBiomeCustom;
|
||||
import org.bukkit.NamespacedKey;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.IOException;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.zip.ZipFile;
|
||||
|
||||
// Works out biome tint colours the way Minecraft does, from the colour maps and vanilla biome files in the client jar.
|
||||
final class MinecraftBiomeColors {
|
||||
private static final String COLOR_MAP_FOLDER = "assets/minecraft/textures/colormap/";
|
||||
private static final int COLOR_MAP_SIZE = 256;
|
||||
private static final int GRASS_FALLBACK = 0xFF00FF;
|
||||
private static final int FOLIAGE_FALLBACK = 0x48B518;
|
||||
private static final int DRY_FOLIAGE_FALLBACK = 0x5C3C32;
|
||||
private static final float UNKNOWN_BIOME_TEMPERATURE = 0.5F;
|
||||
private static final float UNKNOWN_BIOME_DOWNFALL = 0.5F;
|
||||
private static final int UNKNOWN_BIOME_WATER = 0x3F76E4;
|
||||
private static final int UNPARSEABLE_COLOR = 0x000000;
|
||||
|
||||
private final ZipFile clientJar;
|
||||
private final ColorMap grassMap;
|
||||
private final ColorMap foliageMap;
|
||||
private final ColorMap dryFoliageMap;
|
||||
private final Map<NamespacedKey, BiomeTintColors> vanillaBiomes = new ConcurrentHashMap<>();
|
||||
|
||||
private MinecraftBiomeColors(ZipFile clientJar, ColorMap grassMap, ColorMap foliageMap, ColorMap dryFoliageMap) {
|
||||
this.clientJar = clientJar;
|
||||
this.grassMap = grassMap;
|
||||
this.foliageMap = foliageMap;
|
||||
this.dryFoliageMap = dryFoliageMap;
|
||||
}
|
||||
|
||||
static MinecraftBiomeColors load(ZipFile clientJar) throws IOException {
|
||||
return new MinecraftBiomeColors(
|
||||
clientJar,
|
||||
ColorMap.read(clientJar, "grass.png", GRASS_FALLBACK),
|
||||
ColorMap.read(clientJar, "foliage.png", FOLIAGE_FALLBACK),
|
||||
ColorMap.read(clientJar, "dry_foliage.png", DRY_FOLIAGE_FALLBACK)
|
||||
);
|
||||
}
|
||||
|
||||
BiomeTintColors vanilla(NamespacedKey biome) {
|
||||
return vanillaBiomes.computeIfAbsent(biome, this::readVanillaBiome);
|
||||
}
|
||||
|
||||
// Iris registers custom biomes as data pack biomes built from these fields, see IrisBiomeCustom#generateJson.
|
||||
BiomeTintColors custom(IrisBiomeCustom biome) {
|
||||
float temperature = (float) biome.getTemperature();
|
||||
float downfall = (float) biome.getHumidity();
|
||||
return new BiomeTintColors(
|
||||
biome.getGrassColor().isEmpty() ? grassMap.colorAt(temperature, downfall) : parseIrisColor(biome.getGrassColor()),
|
||||
BiomeTintColors.GrassColorModifier.NONE,
|
||||
biome.getFoliageColor().isEmpty() ? foliageMap.colorAt(temperature, downfall) : parseIrisColor(biome.getFoliageColor()),
|
||||
dryFoliageMap.colorAt(temperature, downfall),
|
||||
parseIrisColor(biome.getWaterColor())
|
||||
);
|
||||
}
|
||||
|
||||
private BiomeTintColors readVanillaBiome(NamespacedKey biome) {
|
||||
JsonObject definition = readBiomeDefinition(biome);
|
||||
if (definition == null) {
|
||||
return fromClimate(UNKNOWN_BIOME_TEMPERATURE, UNKNOWN_BIOME_DOWNFALL, new JsonObject());
|
||||
}
|
||||
JsonObject effects = definition.has("effects") ? definition.getAsJsonObject("effects") : new JsonObject();
|
||||
return fromClimate(definition.get("temperature").getAsFloat(), definition.get("downfall").getAsFloat(), effects);
|
||||
}
|
||||
|
||||
private BiomeTintColors fromClimate(float temperature, float downfall, JsonObject effects) {
|
||||
return new BiomeTintColors(
|
||||
effects.has("grass_color") ? parseVanillaColor(effects.get("grass_color")) : grassMap.colorAt(temperature, downfall),
|
||||
effects.has("grass_color_modifier")
|
||||
? BiomeTintColors.GrassColorModifier.named(effects.get("grass_color_modifier").getAsString())
|
||||
: BiomeTintColors.GrassColorModifier.NONE,
|
||||
effects.has("foliage_color") ? parseVanillaColor(effects.get("foliage_color")) : foliageMap.colorAt(temperature, downfall),
|
||||
effects.has("dry_foliage_color") ? parseVanillaColor(effects.get("dry_foliage_color")) : dryFoliageMap.colorAt(temperature, downfall),
|
||||
effects.has("water_color") ? parseVanillaColor(effects.get("water_color")) : UNKNOWN_BIOME_WATER
|
||||
);
|
||||
}
|
||||
|
||||
private JsonObject readBiomeDefinition(NamespacedKey biome) {
|
||||
try {
|
||||
return ClientJarFiles.readJson(clientJar, "data/" + biome.getNamespace() + "/worldgen/biome/" + biome.getKey() + ".json");
|
||||
} catch (IOException | RuntimeException error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Older versions store colours as plain numbers, newer ones as "#rrggbb" strings.
|
||||
private static int parseVanillaColor(JsonElement color) {
|
||||
if (color.getAsJsonPrimitive().isNumber()) {
|
||||
return color.getAsInt() & 0xFFFFFF;
|
||||
}
|
||||
return Integer.parseInt(color.getAsString().substring(1), 16);
|
||||
}
|
||||
|
||||
private static int parseIrisColor(String color) {
|
||||
try {
|
||||
return Color.decode((color.startsWith("#") ? color : "#" + color).trim()).getRGB() & 0xFFFFFF;
|
||||
} catch (RuntimeException error) {
|
||||
return UNPARSEABLE_COLOR;
|
||||
}
|
||||
}
|
||||
|
||||
// Minecraft's ColorMapColorUtil: temperature picks the column and temperature times downfall picks the row.
|
||||
private record ColorMap(int[] pixels, int fallback) {
|
||||
private static ColorMap read(ZipFile clientJar, String name, int fallback) throws IOException {
|
||||
BufferedImage image = ClientJarFiles.readImage(clientJar, COLOR_MAP_FOLDER + name);
|
||||
if (image == null) {
|
||||
throw new IOException("Minecraft colour map " + name + " is missing");
|
||||
}
|
||||
int[] pixels = new int[COLOR_MAP_SIZE * COLOR_MAP_SIZE];
|
||||
for (int y = 0; y < COLOR_MAP_SIZE; y++) {
|
||||
for (int x = 0; x < COLOR_MAP_SIZE; x++) {
|
||||
pixels[y * COLOR_MAP_SIZE + x] = image.getRGB(x, y) & 0xFFFFFF;
|
||||
}
|
||||
}
|
||||
return new ColorMap(pixels, fallback);
|
||||
}
|
||||
|
||||
private int colorAt(float temperature, float downfall) {
|
||||
double clampedTemperature = Math.clamp(temperature, 0F, 1F);
|
||||
double clampedDownfall = Math.clamp(downfall, 0F, 1F) * clampedTemperature;
|
||||
int x = (int) ((1D - clampedTemperature) * 255D);
|
||||
int y = (int) ((1D - clampedDownfall) * 255D);
|
||||
int index = y << 8 | x;
|
||||
return index >= pixels.length ? fallback : pixels[index];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
// Mirrors Minecraft's BlockColors: which colour each tinted face of a block is multiplied with in the world.
|
||||
final class MinecraftBlockTints {
|
||||
static final int NO_TINT = 0xFFFFFF;
|
||||
private static final Set<String> GRASS_TINTED = Set.of(
|
||||
"grass_block", "short_grass", "fern", "potted_fern", "bush", "tall_grass", "large_fern", "sugar_cane");
|
||||
private static final Set<String> GRASS_TINTED_ON_SECOND_LAYER = Set.of("pink_petals", "wildflowers");
|
||||
private static final Set<String> FOLIAGE_TINTED = Set.of(
|
||||
"oak_leaves", "jungle_leaves", "acacia_leaves", "dark_oak_leaves", "vine", "mangrove_leaves");
|
||||
private static final Set<String> WATER_TINTED_BY_FLUID_RENDERER = Set.of("water", "bubble_column");
|
||||
private static final Set<String> GROWING_STEMS = Set.of("melon_stem", "pumpkin_stem");
|
||||
private static final Map<String, Integer> FIXED_TINTS = Map.of(
|
||||
"spruce_leaves", 0x619961,
|
||||
"birch_leaves", 0x80A755,
|
||||
"attached_melon_stem", 0xE0C71C,
|
||||
"attached_pumpkin_stem", 0xE0C71C,
|
||||
"lily_pad", 0x208030
|
||||
);
|
||||
|
||||
private MinecraftBlockTints() {
|
||||
}
|
||||
|
||||
static Optional<BiomeTint> biomeTint(String blockName, int tintIndex) {
|
||||
if (WATER_TINTED_BY_FLUID_RENDERER.contains(blockName)) {
|
||||
return Optional.of(BiomeTint.WATER);
|
||||
}
|
||||
if (GRASS_TINTED_ON_SECOND_LAYER.contains(blockName)) {
|
||||
return tintIndex == 1 ? Optional.of(BiomeTint.GRASS) : Optional.empty();
|
||||
}
|
||||
if (tintIndex != 0) {
|
||||
return Optional.empty();
|
||||
}
|
||||
if (GRASS_TINTED.contains(blockName)) {
|
||||
return Optional.of(BiomeTint.GRASS);
|
||||
}
|
||||
if (FOLIAGE_TINTED.contains(blockName)) {
|
||||
return Optional.of(BiomeTint.FOLIAGE);
|
||||
}
|
||||
if (blockName.equals("leaf_litter")) {
|
||||
return Optional.of(BiomeTint.DRY_FOLIAGE);
|
||||
}
|
||||
if (blockName.equals("water_cauldron")) {
|
||||
return Optional.of(BiomeTint.WATER);
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
static int fixedTint(String blockName, Map<String, String> properties, int tintIndex) {
|
||||
if (tintIndex != 0) {
|
||||
return NO_TINT;
|
||||
}
|
||||
if (GROWING_STEMS.contains(blockName)) {
|
||||
return stemTint(Integer.parseInt(properties.getOrDefault("age", "0")));
|
||||
}
|
||||
return FIXED_TINTS.getOrDefault(blockName, NO_TINT);
|
||||
}
|
||||
|
||||
private static int stemTint(int age) {
|
||||
return (age * 32) << 16 | (255 - age * 8) << 8 | (age * 4);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import com.volmit.iris.Iris;
|
||||
import org.bukkit.Bukkit;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.security.DigestInputStream;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Duration;
|
||||
import java.util.HexFormat;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.zip.ZipFile;
|
||||
|
||||
final class MinecraftClientJar {
|
||||
private static final URI VERSION_MANIFEST = URI.create("https://piston-meta.mojang.com/mc/game/version_manifest_v2.json");
|
||||
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(15);
|
||||
private static final Duration DOWNLOAD_TIMEOUT = Duration.ofMinutes(10);
|
||||
private static final HttpClient HTTP = HttpClient.newBuilder()
|
||||
.connectTimeout(CONNECT_TIMEOUT)
|
||||
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||
.build();
|
||||
|
||||
private MinecraftClientJar() {
|
||||
}
|
||||
|
||||
static boolean isCached() {
|
||||
return cachedJar().exists();
|
||||
}
|
||||
|
||||
static CompletableFuture<ZipFile> open() {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
File jar = cachedJar();
|
||||
if (!jar.exists()) {
|
||||
download(Bukkit.getMinecraftVersion(), jar.toPath());
|
||||
}
|
||||
return new ZipFile(jar);
|
||||
} catch (IOException | InterruptedException error) {
|
||||
throw new IllegalStateException(readableReason(error), error);
|
||||
}
|
||||
}, MinecraftClientJar::runInBackground);
|
||||
}
|
||||
|
||||
private static void runInBackground(Runnable task) {
|
||||
Thread thread = new Thread(task, "Iris Minecraft Client Download");
|
||||
thread.setDaemon(true);
|
||||
thread.start();
|
||||
}
|
||||
|
||||
private static File cachedJar() {
|
||||
return Iris.instance.getDataFile("cache", "minecraft-client", Bukkit.getMinecraftVersion() + ".jar");
|
||||
}
|
||||
|
||||
private static void download(String version, Path destination) throws IOException, InterruptedException {
|
||||
JsonObject versionInfo = fetchJson(findVersionUrl(version));
|
||||
JsonObject client = versionInfo.getAsJsonObject("downloads").getAsJsonObject("client");
|
||||
URI clientUrl = URI.create(client.get("url").getAsString());
|
||||
String expectedSha1 = client.get("sha1").getAsString();
|
||||
|
||||
Path partial = destination.resolveSibling(destination.getFileName() + ".part");
|
||||
HttpResponse<InputStream> response = HTTP.send(
|
||||
HttpRequest.newBuilder(clientUrl).timeout(DOWNLOAD_TIMEOUT).build(),
|
||||
HttpResponse.BodyHandlers.ofInputStream()
|
||||
);
|
||||
requireSuccess(response.statusCode(), clientUrl);
|
||||
String actualSha1;
|
||||
try (DigestInputStream body = new DigestInputStream(response.body(), sha1())) {
|
||||
Files.copy(body, partial, StandardCopyOption.REPLACE_EXISTING);
|
||||
actualSha1 = HexFormat.of().formatHex(body.getMessageDigest().digest());
|
||||
}
|
||||
if (!actualSha1.equalsIgnoreCase(expectedSha1)) {
|
||||
Files.deleteIfExists(partial);
|
||||
throw new IOException("the downloaded client jar was damaged (checksum mismatch)");
|
||||
}
|
||||
Files.move(partial, destination, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
|
||||
}
|
||||
|
||||
private static URI findVersionUrl(String version) throws IOException, InterruptedException {
|
||||
for (var entry : fetchJson(VERSION_MANIFEST).getAsJsonArray("versions")) {
|
||||
JsonObject candidate = entry.getAsJsonObject();
|
||||
if (candidate.get("id").getAsString().equals(version)) {
|
||||
return URI.create(candidate.get("url").getAsString());
|
||||
}
|
||||
}
|
||||
throw new IOException("Mojang doesn't list Minecraft " + version);
|
||||
}
|
||||
|
||||
private static JsonObject fetchJson(URI url) throws IOException, InterruptedException {
|
||||
HttpResponse<String> response = HTTP.send(
|
||||
HttpRequest.newBuilder(url).timeout(CONNECT_TIMEOUT).build(),
|
||||
HttpResponse.BodyHandlers.ofString()
|
||||
);
|
||||
requireSuccess(response.statusCode(), url);
|
||||
return JsonParser.parseString(response.body()).getAsJsonObject();
|
||||
}
|
||||
|
||||
private static void requireSuccess(int statusCode, URI url) throws IOException {
|
||||
if (statusCode / 100 != 2) {
|
||||
throw new IOException(url.getHost() + " answered HTTP " + statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
private static MessageDigest sha1() {
|
||||
try {
|
||||
return MessageDigest.getInstance("SHA-1");
|
||||
} catch (NoSuchAlgorithmException error) {
|
||||
throw new IllegalStateException(error);
|
||||
}
|
||||
}
|
||||
|
||||
private static String readableReason(Throwable error) {
|
||||
String message = error.getMessage();
|
||||
return message == null || message.isBlank() ? error.getClass().getSimpleName() : message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
import java.awt.Color;
|
||||
|
||||
interface NoiseColorScale {
|
||||
int colorFinite(double value);
|
||||
|
||||
default int colorAt(double value, double worldX, double worldZ) {
|
||||
return colorFinite(value);
|
||||
}
|
||||
|
||||
int colorInvalid();
|
||||
|
||||
default int colorInvalidAt(double worldX, double worldZ) {
|
||||
return colorInvalid();
|
||||
}
|
||||
|
||||
Color displayColorNormalized(double normalized);
|
||||
|
||||
String label();
|
||||
|
||||
double minimum();
|
||||
|
||||
double maximum();
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
import java.awt.Color;
|
||||
|
||||
enum NoisePalette implements NoiseColorScale {
|
||||
TERRAIN("Terrain", 0D, 1D, new int[]{0x071A2F, 0x155E75, 0x2A9D8F, 0xE9C46A, 0xF4F1DE}),
|
||||
SIGNED("Signed", -1D, 1D, new int[]{0x173B66, 0x4F86C6, 0xE7EDF3, 0xE89555, 0x9D3B35}),
|
||||
GRAYSCALE("Grayscale", 0D, 1D, new int[]{0x050505, 0xFFFFFF});
|
||||
|
||||
static final int INVALID_COLOR = 0xFF2DAA;
|
||||
|
||||
private final String label;
|
||||
private final double minimum;
|
||||
private final double maximum;
|
||||
private final int[] lookup;
|
||||
private final Color[] displayColors;
|
||||
|
||||
NoisePalette(String label, double minimum, double maximum, int[] stops) {
|
||||
this.label = label;
|
||||
this.minimum = minimum;
|
||||
this.maximum = maximum;
|
||||
this.lookup = buildLookup(stops);
|
||||
this.displayColors = buildDisplayColors(lookup);
|
||||
}
|
||||
|
||||
int color(double value) {
|
||||
if (!Double.isFinite(value)) {
|
||||
return INVALID_COLOR;
|
||||
}
|
||||
return colorFinite(value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int colorFinite(double value) {
|
||||
double normalized = (value - minimum) / (maximum - minimum);
|
||||
return colorNormalized(normalized);
|
||||
}
|
||||
|
||||
int colorNormalized(double normalized) {
|
||||
double clipped = Math.max(0D, Math.min(1D, normalized));
|
||||
return lookup[(int) Math.round(clipped * (lookup.length - 1))];
|
||||
}
|
||||
|
||||
@Override
|
||||
public int colorInvalid() {
|
||||
return INVALID_COLOR;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Color displayColorNormalized(double normalized) {
|
||||
double clipped = Math.max(0D, Math.min(1D, normalized));
|
||||
return displayColors[(int) Math.round(clipped * (displayColors.length - 1))];
|
||||
}
|
||||
|
||||
@Override
|
||||
public String label() {
|
||||
return label;
|
||||
}
|
||||
|
||||
@Override
|
||||
public double minimum() {
|
||||
return minimum;
|
||||
}
|
||||
|
||||
@Override
|
||||
public double maximum() {
|
||||
return maximum;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return label;
|
||||
}
|
||||
|
||||
private static int[] buildLookup(int[] stops) {
|
||||
int[] values = new int[256];
|
||||
for (int index = 0; index < values.length; index++) {
|
||||
double position = (index / 255D) * (stops.length - 1);
|
||||
int lowerIndex = Math.min(stops.length - 2, (int) position);
|
||||
double fraction = position - lowerIndex;
|
||||
values[index] = interpolate(stops[lowerIndex], stops[lowerIndex + 1], fraction);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
private static Color[] buildDisplayColors(int[] lookup) {
|
||||
Color[] colors = new Color[lookup.length];
|
||||
for (int index = 0; index < lookup.length; index++) {
|
||||
colors[index] = new Color(lookup[index]);
|
||||
}
|
||||
return colors;
|
||||
}
|
||||
|
||||
private static int interpolate(int from, int to, double fraction) {
|
||||
int red = channel(from, 16, to, fraction);
|
||||
int green = channel(from, 8, to, fraction);
|
||||
int blue = channel(from, 0, to, fraction);
|
||||
return (red << 16) | (green << 8) | blue;
|
||||
}
|
||||
|
||||
private static int channel(int from, int shift, int to, double fraction) {
|
||||
int fromChannel = (from >>> shift) & 0xFF;
|
||||
int toChannel = (to >>> shift) & 0xFF;
|
||||
return (int) Math.round(fromChannel + ((toChannel - fromChannel) * fraction));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
import com.volmit.iris.util.function.NoiseProvider;
|
||||
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import java.util.concurrent.ThreadFactory;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
final class NoiseRenderCoordinator implements AutoCloseable {
|
||||
private static final int BANDS_PER_WORKER = 4;
|
||||
|
||||
private final int workerCount;
|
||||
private final Listener listener;
|
||||
private final ThreadPoolExecutor workers;
|
||||
private final AtomicReference<RenderGeneration> latestRequested = new AtomicReference<>();
|
||||
private final AtomicLong latestRevision = new AtomicLong();
|
||||
private final AtomicBoolean closed = new AtomicBoolean();
|
||||
|
||||
NoiseRenderCoordinator(Listener listener) {
|
||||
this(Math.min(4, Math.max(1, Runtime.getRuntime().availableProcessors() / 2)), listener);
|
||||
}
|
||||
|
||||
NoiseRenderCoordinator(int workerCount, Listener listener) {
|
||||
this.workerCount = Math.max(1, workerCount);
|
||||
this.listener = Objects.requireNonNull(listener, "listener");
|
||||
AtomicInteger threadIds = new AtomicInteger();
|
||||
ThreadFactory workerThreadFactory = runnable -> {
|
||||
Thread thread = new Thread(runnable, "Iris Noise Renderer " + threadIds.incrementAndGet());
|
||||
thread.setDaemon(true);
|
||||
thread.setPriority(Thread.MIN_PRIORITY);
|
||||
return thread;
|
||||
};
|
||||
workers = new ThreadPoolExecutor(this.workerCount, this.workerCount, 30L, TimeUnit.SECONDS,
|
||||
new LinkedBlockingQueue<>(), workerThreadFactory);
|
||||
workers.allowCoreThreadTimeOut(true);
|
||||
GuiHost.interruptOnPluginShutdown(workers);
|
||||
}
|
||||
|
||||
void request(Request request) {
|
||||
Objects.requireNonNull(request, "request");
|
||||
if (closed.get()) {
|
||||
return;
|
||||
}
|
||||
latestRevision.accumulateAndGet(request.revision(), Math::max);
|
||||
RenderGeneration generation = new RenderGeneration(request, Math.min(workerCount * BANDS_PER_WORKER, outputHeight(request)));
|
||||
latestRequested.set(generation);
|
||||
listener.onRenderStarted(request);
|
||||
for (int band = 0; band < generation.bandCount(); band++) {
|
||||
int bandIndex = band;
|
||||
try {
|
||||
workers.execute(() -> renderBand(generation, bandIndex));
|
||||
} catch (RejectedExecutionException rejected) {
|
||||
generation.fail(rejected);
|
||||
finishBand(generation);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void cancel(long revision) {
|
||||
latestRevision.accumulateAndGet(revision, Math::max);
|
||||
latestRequested.set(null);
|
||||
}
|
||||
|
||||
boolean isClosed() {
|
||||
return closed.get();
|
||||
}
|
||||
|
||||
static int sampleStepForBudget(int width, int height, long sampleBudget) {
|
||||
if (width < 1 || height < 1 || sampleBudget < 1L) {
|
||||
throw new IllegalArgumentException("Invalid noise sample budget");
|
||||
}
|
||||
long totalSamples = (long) width * height;
|
||||
int sampleStep = Math.max(1, (int) Math.ceil(Math.sqrt(totalSamples / (double) sampleBudget)));
|
||||
while (sampleCount(width, height, sampleStep) > sampleBudget) {
|
||||
sampleStep++;
|
||||
}
|
||||
return sampleStep;
|
||||
}
|
||||
|
||||
static long sampleCount(int width, int height, int sampleStep) {
|
||||
if (width < 1 || height < 1 || sampleStep < 1) {
|
||||
throw new IllegalArgumentException("Invalid noise sample dimensions");
|
||||
}
|
||||
long outputWidth = ((long) width + sampleStep - 1L) / sampleStep;
|
||||
long outputHeight = ((long) height + sampleStep - 1L) / sampleStep;
|
||||
return outputWidth * outputHeight;
|
||||
}
|
||||
|
||||
static int nextRefinementStep(int width, int height, int currentStep, long sampleBudget) {
|
||||
if (currentStep <= 1) {
|
||||
throw new IllegalArgumentException("Noise refinement requires a coarse input");
|
||||
}
|
||||
return Math.min(currentStep, sampleStepForBudget(width, height, sampleBudget));
|
||||
}
|
||||
|
||||
static long timeBoundSampleBudget(long completedSamples, double milliseconds, double targetMilliseconds,
|
||||
long minimumBudget, long maximumBudget) {
|
||||
if (completedSamples < 1L
|
||||
|| !Double.isFinite(milliseconds)
|
||||
|| milliseconds <= 0D
|
||||
|| !Double.isFinite(targetMilliseconds)
|
||||
|| targetMilliseconds <= 0D
|
||||
|| minimumBudget < 1L
|
||||
|| maximumBudget < minimumBudget) {
|
||||
throw new IllegalArgumentException("Invalid timed noise sample budget");
|
||||
}
|
||||
double projectedSamples = Math.ceil((completedSamples / milliseconds) * targetMilliseconds);
|
||||
long timeBoundBudget = (long) Math.min(maximumBudget, projectedSamples);
|
||||
return Math.max(minimumBudget, timeBoundBudget);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (!closed.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
latestRevision.incrementAndGet();
|
||||
latestRequested.set(null);
|
||||
workers.shutdownNow();
|
||||
}
|
||||
|
||||
// Bands of a frame that was replaced are skipped, so a new frame only waits for the rows already being drawn.
|
||||
private void renderBand(RenderGeneration generation, int bandIndex) {
|
||||
try {
|
||||
if (isCurrent(generation)) {
|
||||
renderRows(generation, bandIndex);
|
||||
}
|
||||
} catch (Throwable error) {
|
||||
generation.fail(error);
|
||||
} finally {
|
||||
finishBand(generation);
|
||||
}
|
||||
}
|
||||
|
||||
private void finishBand(RenderGeneration generation) {
|
||||
if (!generation.completeBand() || !isCurrent(generation)) {
|
||||
return;
|
||||
}
|
||||
Throwable failure = generation.failure();
|
||||
if (failure != null) {
|
||||
listener.onRenderFailed(generation.request(), failure);
|
||||
return;
|
||||
}
|
||||
listener.onRenderCompleted(generation.result());
|
||||
}
|
||||
|
||||
private void renderRows(RenderGeneration generation, int bandIndex) {
|
||||
Request request = generation.request();
|
||||
int outputWidth = generation.outputWidth();
|
||||
int outputHeight = generation.outputHeight();
|
||||
int[] pixels = generation.pixels();
|
||||
BandStats stats = generation.bandStats(bandIndex);
|
||||
int fromY = (outputHeight * bandIndex) / generation.bandCount();
|
||||
int toY = (outputHeight * (bandIndex + 1)) / generation.bandCount();
|
||||
double sampleOffset = request.sampleStep() * 0.5D;
|
||||
double startWorldX = request.viewport().worldX(sampleOffset, request.width());
|
||||
double worldStep = request.viewport().blocksPerPixel() * request.sampleStep();
|
||||
NoiseColorScale colorScale = request.colorScale();
|
||||
double paletteMinimum = colorScale.minimum();
|
||||
double paletteMaximum = colorScale.maximum();
|
||||
for (int y = fromY; y < toY; y++) {
|
||||
double screenY = (y * (double) request.sampleStep()) + sampleOffset;
|
||||
double worldZ = request.viewport().worldZ(screenY, request.height());
|
||||
double worldX = startWorldX;
|
||||
int pixelIndex = y * outputWidth;
|
||||
boolean centerRow = y == outputHeight / 2;
|
||||
for (int x = 0; x < outputWidth; x++) {
|
||||
if ((x & 31) == 0 && (!isCurrent(generation) || Thread.currentThread().isInterrupted())) {
|
||||
return;
|
||||
}
|
||||
double value = request.sampler().noise(worldX, worldZ);
|
||||
boolean center = centerRow && x == outputWidth / 2;
|
||||
if (Double.isFinite(value)) {
|
||||
pixels[pixelIndex++] = colorScale.colorAt(value, worldX, worldZ);
|
||||
stats.acceptFinite(value, center, paletteMinimum, paletteMaximum);
|
||||
} else {
|
||||
pixels[pixelIndex++] = colorScale.colorInvalidAt(worldX, worldZ);
|
||||
stats.acceptInvalid(value, center);
|
||||
}
|
||||
worldX += worldStep;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isCurrent(RenderGeneration generation) {
|
||||
return !closed.get()
|
||||
&& latestRequested.get() == generation
|
||||
&& generation.request().revision() >= latestRevision.get();
|
||||
}
|
||||
|
||||
private static int outputWidth(Request request) {
|
||||
return Math.max(1, (request.width() + request.sampleStep() - 1) / request.sampleStep());
|
||||
}
|
||||
|
||||
private static int outputHeight(Request request) {
|
||||
return Math.max(1, (request.height() + request.sampleStep() - 1) / request.sampleStep());
|
||||
}
|
||||
|
||||
private static RenderStats combine(BandStats[] bands) {
|
||||
long samples = 0L;
|
||||
long underflow = 0L;
|
||||
long overflow = 0L;
|
||||
long invalid = 0L;
|
||||
double minimum = Double.POSITIVE_INFINITY;
|
||||
double maximum = Double.NEGATIVE_INFINITY;
|
||||
double centerValue = Double.NaN;
|
||||
for (BandStats band : bands) {
|
||||
samples += band.samples;
|
||||
underflow += band.underflow;
|
||||
overflow += band.overflow;
|
||||
invalid += band.invalid;
|
||||
minimum = Math.min(minimum, band.minimum);
|
||||
maximum = Math.max(maximum, band.maximum);
|
||||
if (!Double.isNaN(band.centerValue)) {
|
||||
centerValue = band.centerValue;
|
||||
}
|
||||
}
|
||||
if (minimum == Double.POSITIVE_INFINITY) {
|
||||
minimum = Double.NaN;
|
||||
maximum = Double.NaN;
|
||||
}
|
||||
return new RenderStats(samples, minimum, maximum, centerValue, underflow, overflow, invalid);
|
||||
}
|
||||
|
||||
interface Listener {
|
||||
void onRenderStarted(Request request);
|
||||
|
||||
void onRenderCompleted(Result result);
|
||||
|
||||
void onRenderFailed(Request request, Throwable error);
|
||||
}
|
||||
|
||||
record Request(long revision, NoiseProvider sampler, NoiseViewport viewport, NoiseColorScale colorScale,
|
||||
int width, int height, int sampleStep) {
|
||||
Request {
|
||||
Objects.requireNonNull(sampler, "sampler");
|
||||
Objects.requireNonNull(viewport, "viewport");
|
||||
Objects.requireNonNull(colorScale, "colorScale");
|
||||
if (revision < 0L || width < 1 || height < 1 || sampleStep < 1) {
|
||||
throw new IllegalArgumentException("Invalid noise render request");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
record Result(Request request, BufferedImage image, double milliseconds, long samples, double minimum,
|
||||
double maximum, double centerValue, long underflow, long overflow, long invalid) {
|
||||
}
|
||||
|
||||
private static final class RenderGeneration {
|
||||
private final Request request;
|
||||
private final long startedNanos = System.nanoTime();
|
||||
private final int outputWidth;
|
||||
private final int outputHeight;
|
||||
private final int[] pixels;
|
||||
private final BandStats[] bandStats;
|
||||
private final AtomicInteger remainingBands;
|
||||
private final AtomicReference<Throwable> failure = new AtomicReference<>();
|
||||
|
||||
private RenderGeneration(Request request, int bandCount) {
|
||||
this.request = request;
|
||||
this.outputWidth = NoiseRenderCoordinator.outputWidth(request);
|
||||
this.outputHeight = NoiseRenderCoordinator.outputHeight(request);
|
||||
this.pixels = new int[outputWidth * outputHeight];
|
||||
this.bandStats = new BandStats[Math.max(1, bandCount)];
|
||||
for (int band = 0; band < bandStats.length; band++) {
|
||||
bandStats[band] = new BandStats();
|
||||
}
|
||||
this.remainingBands = new AtomicInteger(bandStats.length);
|
||||
}
|
||||
|
||||
private Request request() {
|
||||
return request;
|
||||
}
|
||||
|
||||
private int bandCount() {
|
||||
return bandStats.length;
|
||||
}
|
||||
|
||||
private int outputWidth() {
|
||||
return outputWidth;
|
||||
}
|
||||
|
||||
private int outputHeight() {
|
||||
return outputHeight;
|
||||
}
|
||||
|
||||
private int[] pixels() {
|
||||
return pixels;
|
||||
}
|
||||
|
||||
private BandStats bandStats(int band) {
|
||||
return bandStats[band];
|
||||
}
|
||||
|
||||
private void fail(Throwable error) {
|
||||
failure.compareAndSet(null, error);
|
||||
}
|
||||
|
||||
private Throwable failure() {
|
||||
return failure.get();
|
||||
}
|
||||
|
||||
// True for the band that finishes last, which is the one that publishes the frame.
|
||||
private boolean completeBand() {
|
||||
return remainingBands.decrementAndGet() == 0;
|
||||
}
|
||||
|
||||
private Result result() {
|
||||
BufferedImage image = GuiHost.uncachedImage(pixels, outputWidth, outputHeight);
|
||||
RenderStats combined = combine(bandStats);
|
||||
double milliseconds = (System.nanoTime() - startedNanos) / 1_000_000D;
|
||||
return new Result(request, image, milliseconds, combined.samples, combined.minimum, combined.maximum,
|
||||
combined.centerValue, combined.underflow, combined.overflow, combined.invalid);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class BandStats {
|
||||
private long samples;
|
||||
private long underflow;
|
||||
private long overflow;
|
||||
private long invalid;
|
||||
private double minimum = Double.POSITIVE_INFINITY;
|
||||
private double maximum = Double.NEGATIVE_INFINITY;
|
||||
private double centerValue = Double.NaN;
|
||||
|
||||
private void acceptFinite(double value, boolean center, double paletteMinimum, double paletteMaximum) {
|
||||
samples++;
|
||||
if (center) {
|
||||
centerValue = value;
|
||||
}
|
||||
minimum = Math.min(minimum, value);
|
||||
maximum = Math.max(maximum, value);
|
||||
if (value < paletteMinimum) {
|
||||
underflow++;
|
||||
} else if (value > paletteMaximum) {
|
||||
overflow++;
|
||||
}
|
||||
}
|
||||
|
||||
private void acceptInvalid(double value, boolean center) {
|
||||
samples++;
|
||||
invalid++;
|
||||
if (center) {
|
||||
centerValue = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private record RenderStats(long samples, double minimum, double maximum, double centerValue,
|
||||
long underflow, long overflow, long invalid) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
record NoiseViewport(double centerX, double centerZ, double blocksPerPixel) {
|
||||
static final double MIN_BLOCKS_PER_PIXEL = 0.0001D;
|
||||
static final double MAX_BLOCKS_PER_PIXEL = 1_000_000D;
|
||||
|
||||
NoiseViewport {
|
||||
if (!Double.isFinite(centerX) || !Double.isFinite(centerZ)) {
|
||||
throw new IllegalArgumentException("Viewport center must be finite");
|
||||
}
|
||||
if (!Double.isFinite(blocksPerPixel) || blocksPerPixel <= 0D) {
|
||||
throw new IllegalArgumentException("Viewport scale must be finite and positive");
|
||||
}
|
||||
}
|
||||
|
||||
double worldX(double screenX, int width) {
|
||||
return centerX + ((screenX - (width / 2D)) * blocksPerPixel);
|
||||
}
|
||||
|
||||
double worldZ(double screenZ, int height) {
|
||||
return centerZ + ((screenZ - (height / 2D)) * blocksPerPixel);
|
||||
}
|
||||
|
||||
double screenX(double worldX, int width) {
|
||||
return ((worldX - centerX) / blocksPerPixel) + (width / 2D);
|
||||
}
|
||||
|
||||
double screenZ(double worldZ, int height) {
|
||||
return ((worldZ - centerZ) / blocksPerPixel) + (height / 2D);
|
||||
}
|
||||
|
||||
NoiseViewport panPixels(double deltaX, double deltaZ) {
|
||||
return new NoiseViewport(
|
||||
centerX - (deltaX * blocksPerPixel),
|
||||
centerZ - (deltaZ * blocksPerPixel),
|
||||
blocksPerPixel
|
||||
);
|
||||
}
|
||||
|
||||
NoiseViewport zoomAt(double screenX, double screenZ, int width, int height, double factor) {
|
||||
if (!Double.isFinite(factor) || factor <= 0D) {
|
||||
throw new IllegalArgumentException("Zoom factor must be finite and positive");
|
||||
}
|
||||
double anchorX = worldX(screenX, width);
|
||||
double anchorZ = worldZ(screenZ, height);
|
||||
double nextScale = Math.max(MIN_BLOCKS_PER_PIXEL,
|
||||
Math.min(MAX_BLOCKS_PER_PIXEL, blocksPerPixel * factor));
|
||||
double nextCenterX = anchorX - ((screenX - (width / 2D)) * nextScale);
|
||||
double nextCenterZ = anchorZ - ((screenZ - (height / 2D)) * nextScale);
|
||||
return new NoiseViewport(nextCenterX, nextCenterZ, nextScale);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JTextField;
|
||||
import javax.swing.JTree;
|
||||
import javax.swing.ToolTipManager;
|
||||
import javax.swing.event.DocumentEvent;
|
||||
import javax.swing.event.DocumentListener;
|
||||
import javax.swing.tree.DefaultMutableTreeNode;
|
||||
import javax.swing.tree.DefaultTreeCellRenderer;
|
||||
import javax.swing.tree.DefaultTreeModel;
|
||||
import javax.swing.tree.TreePath;
|
||||
import javax.swing.tree.TreeSelectionModel;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Component;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Every object in the pack as a folder tree, with a search that matches every word typed anywhere in an object's path.
|
||||
*/
|
||||
final class ObjectBrowser extends JPanel {
|
||||
private static final long serialVersionUID = 6795434561028735214L;
|
||||
private static final int ROW_HEIGHT = 24;
|
||||
|
||||
private final Consumer<String> onObjectChosen;
|
||||
private final JTextField searchField = new JTextField();
|
||||
private final DefaultTreeModel treeModel = new DefaultTreeModel(new DefaultMutableTreeNode());
|
||||
private final JTree tree = new JTree(treeModel);
|
||||
private final JLabel countLabel = new JLabel("Loading objects...");
|
||||
private List<String> objectKeys = List.of();
|
||||
private String chosenKey;
|
||||
private boolean updatingSelection;
|
||||
|
||||
ObjectBrowser(Consumer<String> onObjectChosen) {
|
||||
super(new BorderLayout());
|
||||
this.onObjectChosen = onObjectChosen;
|
||||
StudioToolbar.makeSearchField(searchField, "Search objects...");
|
||||
searchField.getDocument().addDocumentListener(new DocumentListener() {
|
||||
@Override
|
||||
public void insertUpdate(DocumentEvent event) {
|
||||
rebuildTree();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeUpdate(DocumentEvent event) {
|
||||
rebuildTree();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void changedUpdate(DocumentEvent event) {
|
||||
rebuildTree();
|
||||
}
|
||||
});
|
||||
tree.setRootVisible(false);
|
||||
tree.setShowsRootHandles(true);
|
||||
tree.setRowHeight(StudioTheme.scale(ROW_HEIGHT));
|
||||
tree.getSelectionModel().setSelectionMode(TreeSelectionModel.SINGLE_TREE_SELECTION);
|
||||
tree.setCellRenderer(new EntryRenderer());
|
||||
ToolTipManager.sharedInstance().registerComponent(tree);
|
||||
tree.addTreeSelectionListener(event -> {
|
||||
if (!updatingSelection && nodeAt(event.getPath()) instanceof Entry entry && entry.isObject()) {
|
||||
chosenKey = entry.key();
|
||||
onObjectChosen.accept(entry.key());
|
||||
}
|
||||
});
|
||||
countLabel.setFont(StudioTheme.smallFont());
|
||||
countLabel.setForeground(StudioTheme.SECONDARY_TEXT);
|
||||
add(StudioToolbar.padded(searchField, 8, 8, 8, 8), BorderLayout.NORTH);
|
||||
add(StickyFolderHeaders.around(tree, StudioToolbar.scroll(tree)), BorderLayout.CENTER);
|
||||
add(StudioToolbar.padded(countLabel, 6, 12, 8, 12), BorderLayout.SOUTH);
|
||||
}
|
||||
|
||||
void showObjects(List<String> keys) {
|
||||
objectKeys = keys.stream().sorted(Comparator.comparing(key -> key.toLowerCase(Locale.ROOT))).toList();
|
||||
countLabel.setText(objectKeys.size() == 1 ? "1 object" : objectKeys.size() + " objects");
|
||||
rebuildTree();
|
||||
}
|
||||
|
||||
void showError(String text) {
|
||||
countLabel.setText(text);
|
||||
}
|
||||
|
||||
String firstObject() {
|
||||
return objectKeys.isEmpty() ? null : objectKeys.get(0);
|
||||
}
|
||||
|
||||
private void rebuildTree() {
|
||||
String[] words = searchField.getText().trim().toLowerCase(Locale.ROOT).split("\\s+");
|
||||
boolean searching = !searchField.getText().isBlank();
|
||||
DefaultMutableTreeNode root = searching ? searchResults(words) : folderTree();
|
||||
treeModel.setRoot(root);
|
||||
if (searching) {
|
||||
expandAll();
|
||||
}
|
||||
selectChosen();
|
||||
}
|
||||
|
||||
private DefaultMutableTreeNode searchResults(String[] words) {
|
||||
DefaultMutableTreeNode root = new DefaultMutableTreeNode();
|
||||
for (String key : objectKeys) {
|
||||
if (matchesEveryWord(key, words)) {
|
||||
root.add(new DefaultMutableTreeNode(Entry.object(key, key)));
|
||||
}
|
||||
}
|
||||
if (root.getChildCount() == 0) {
|
||||
root.add(new DefaultMutableTreeNode(Entry.note("No matching objects")));
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
private static boolean matchesEveryWord(String key, String[] words) {
|
||||
String lowerKey = key.toLowerCase(Locale.ROOT);
|
||||
for (String word : words) {
|
||||
if (!lowerKey.contains(word)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Folders come before the objects beside them, like a file browser.
|
||||
private DefaultMutableTreeNode folderTree() {
|
||||
Folder top = new Folder();
|
||||
for (String key : objectKeys) {
|
||||
Folder folder = top;
|
||||
String[] parts = key.split("/");
|
||||
for (int index = 0; index < parts.length - 1; index++) {
|
||||
folder = folder.subfolders.computeIfAbsent(parts[index], name -> new Folder());
|
||||
}
|
||||
folder.objects.add(key);
|
||||
}
|
||||
DefaultMutableTreeNode root = new DefaultMutableTreeNode();
|
||||
addFolderContents(root, top, "");
|
||||
return root;
|
||||
}
|
||||
|
||||
private static void addFolderContents(DefaultMutableTreeNode node, Folder folder, String path) {
|
||||
for (Map.Entry<String, Folder> subfolder : folder.subfolders.entrySet()) {
|
||||
String subfolderPath = path + subfolder.getKey() + "/";
|
||||
DefaultMutableTreeNode child = new DefaultMutableTreeNode(Entry.folder(subfolder.getKey(), subfolderPath));
|
||||
addFolderContents(child, subfolder.getValue(), subfolderPath);
|
||||
node.add(child);
|
||||
}
|
||||
for (String key : folder.objects) {
|
||||
node.add(new DefaultMutableTreeNode(Entry.object(key.substring(key.lastIndexOf('/') + 1), key)));
|
||||
}
|
||||
}
|
||||
|
||||
void select(String key) {
|
||||
chosenKey = key;
|
||||
selectChosen();
|
||||
}
|
||||
|
||||
private void selectChosen() {
|
||||
DefaultMutableTreeNode node = chosenKey == null ? null : findObjectNode((DefaultMutableTreeNode) treeModel.getRoot());
|
||||
updatingSelection = true;
|
||||
try {
|
||||
if (node == null) {
|
||||
tree.clearSelection();
|
||||
return;
|
||||
}
|
||||
TreePath path = new TreePath(node.getPath());
|
||||
tree.setSelectionPath(path);
|
||||
tree.scrollPathToVisible(path);
|
||||
} finally {
|
||||
updatingSelection = false;
|
||||
}
|
||||
}
|
||||
|
||||
private DefaultMutableTreeNode findObjectNode(DefaultMutableTreeNode node) {
|
||||
if (node.getUserObject() instanceof Entry entry && entry.isObject() && entry.key().equals(chosenKey)) {
|
||||
return node;
|
||||
}
|
||||
for (int index = 0; index < node.getChildCount(); index++) {
|
||||
DefaultMutableTreeNode found = findObjectNode((DefaultMutableTreeNode) node.getChildAt(index));
|
||||
if (found != null) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void expandAll() {
|
||||
for (int row = 0; row < tree.getRowCount(); row++) {
|
||||
tree.expandRow(row);
|
||||
}
|
||||
}
|
||||
|
||||
private static Object nodeAt(TreePath path) {
|
||||
return path == null ? null : ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject();
|
||||
}
|
||||
|
||||
private static final class Folder {
|
||||
private final Map<String, Folder> subfolders = new LinkedHashMap<>();
|
||||
private final List<String> objects = new ArrayList<>();
|
||||
}
|
||||
|
||||
/**
|
||||
* A row of the tree: an object with its key, a folder with its path, or a note without either.
|
||||
*/
|
||||
private record Entry(String label, String key, String folderPath) {
|
||||
static Entry object(String label, String key) {
|
||||
return new Entry(label, key, null);
|
||||
}
|
||||
|
||||
static Entry folder(String label, String path) {
|
||||
return new Entry(label, null, path);
|
||||
}
|
||||
|
||||
static Entry note(String text) {
|
||||
return new Entry(text, null, null);
|
||||
}
|
||||
|
||||
boolean isObject() {
|
||||
return key != null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return label;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class EntryRenderer extends DefaultTreeCellRenderer {
|
||||
private static final long serialVersionUID = -4316652201793418740L;
|
||||
|
||||
private EntryRenderer() {
|
||||
setLeafIcon(null);
|
||||
setOpenIcon(null);
|
||||
setClosedIcon(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Component getTreeCellRendererComponent(JTree tree, Object value, boolean selected, boolean expanded,
|
||||
boolean leaf, int row, boolean focus) {
|
||||
Object userObject = ((DefaultMutableTreeNode) value).getUserObject();
|
||||
super.getTreeCellRendererComponent(tree, userObject == null ? "" : userObject.toString(), selected, expanded, leaf, row, false);
|
||||
boolean isNote = userObject instanceof Entry entry && !entry.isObject() && entry.folderPath() == null;
|
||||
setFont(StudioTheme.font());
|
||||
setForeground(isNote ? StudioTheme.MUTED_TEXT : StudioTheme.TEXT);
|
||||
setToolTipText(userObject instanceof Entry entry ? entry.isObject() ? entry.key() : entry.folderPath() : null);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
/**
|
||||
* Where the object viewer looks from: turned by yaw around the vertical axis, tilted down by pitch, both in radians.
|
||||
* Zoom 1 fits the object in the view at the starting angle. Pan moves the picture in fractions of the view's size.
|
||||
*/
|
||||
record ObjectCamera(double yaw, double pitch, double zoom, double panX, double panY) {
|
||||
static final ObjectCamera START = new ObjectCamera(0.76D, 0.38D, 1.72D, 0D, 0.025D);
|
||||
private static final double RADIANS_PER_DRAGGED_PIXEL = 0.008D;
|
||||
private static final double MAXIMUM_PITCH = 1.3D;
|
||||
private static final double MINIMUM_ZOOM = 0.4D;
|
||||
private static final double MAXIMUM_ZOOM = 12D;
|
||||
|
||||
ObjectCamera orbitedBy(double draggedX, double draggedY) {
|
||||
double nextPitch = Math.max(-MAXIMUM_PITCH, Math.min(MAXIMUM_PITCH, pitch + draggedY * RADIANS_PER_DRAGGED_PIXEL));
|
||||
return new ObjectCamera(yaw + draggedX * RADIANS_PER_DRAGGED_PIXEL, nextPitch, zoom, panX, panY);
|
||||
}
|
||||
|
||||
ObjectCamera zoomedBy(double factor) {
|
||||
return new ObjectCamera(yaw, pitch, Math.max(MINIMUM_ZOOM, Math.min(MAXIMUM_ZOOM, zoom * factor)), panX, panY);
|
||||
}
|
||||
|
||||
ObjectCamera pannedBy(double fractionX, double fractionY) {
|
||||
return new ObjectCamera(yaw, pitch, zoom, panX + fractionX, panY + fractionY);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
import com.volmit.iris.core.gui.BlockTextures.BlockTexture;
|
||||
import com.volmit.iris.engine.framework.Engine;
|
||||
import com.volmit.iris.engine.object.IrisObject;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
import org.bukkit.util.BlockVector;
|
||||
|
||||
import javax.swing.JComponent;
|
||||
import java.awt.EventQueue;
|
||||
import java.awt.event.KeyEvent;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* The map window's Objects view: every object in the pack in a folder tree, the chosen one in 3D, and what it is made
|
||||
* of. Inspired by the Procedural Objects workbench.
|
||||
*/
|
||||
final class ObjectExplorer {
|
||||
private static final NamespacedKey TINT_BIOME = NamespacedKey.minecraft("plains");
|
||||
private static final int UNKNOWN_BLOCK_COLOR = 0x8A8A8A;
|
||||
private static final String CAMERA_HINT = "Drag to orbit · Shift-drag to pan · Scroll to zoom · Page Up/Down cuts layers · R resets";
|
||||
|
||||
private final Engine startEngine;
|
||||
private final UUID worldId;
|
||||
private final ObjectBrowser browser = new ObjectBrowser(this::showObject);
|
||||
private final ObjectViewport viewport = new ObjectViewport();
|
||||
private final ObjectLayerCut layerCut = new ObjectLayerCut(shownLayers -> showLayerCut());
|
||||
private final JComponent viewWithLayerCut = layerCut.floatingOver(viewport);
|
||||
private final ObjectOverviewPanel overview = new ObjectOverviewPanel(this::showSeeThrough);
|
||||
private final StudioStatusBar statusBar = new StudioStatusBar();
|
||||
private final ExecutorService loader = Executors.newSingleThreadExecutor(runnable -> {
|
||||
Thread thread = new Thread(runnable, "Iris Object Loader");
|
||||
thread.setDaemon(true);
|
||||
thread.setPriority(Thread.MIN_PRIORITY);
|
||||
return thread;
|
||||
});
|
||||
private final AtomicLong objectRevision = new AtomicLong();
|
||||
private final AtomicLong meshRevision = new AtomicLong();
|
||||
private final Map<String, BlockModel> models = new ConcurrentHashMap<>();
|
||||
private final Runnable hotloadHook = this::reloadPack;
|
||||
private volatile BlockTextures textures;
|
||||
private volatile BiomeTintColors tintColors;
|
||||
private String shownKey;
|
||||
private LoadedObject shown;
|
||||
private ObjectMesh wholeMesh;
|
||||
private Set<String> seeThroughTypes = Set.of();
|
||||
|
||||
ObjectExplorer(Engine engine, UUID worldId) {
|
||||
this.startEngine = engine;
|
||||
this.worldId = worldId;
|
||||
GuiHost.interruptOnPluginShutdown(loader);
|
||||
GuiHost.registerHotloadHook(hotloadHook);
|
||||
statusBar.showPosition(CAMERA_HINT);
|
||||
viewport.bindKey(KeyEvent.VK_PAGE_UP, "show-layer-above", () -> layerCut.stepBy(1));
|
||||
viewport.bindKey(KeyEvent.VK_PAGE_DOWN, "hide-top-layer", () -> layerCut.stepBy(-1));
|
||||
}
|
||||
|
||||
JComponent browser() {
|
||||
return browser;
|
||||
}
|
||||
|
||||
ObjectViewport viewport() {
|
||||
return viewport;
|
||||
}
|
||||
|
||||
/**
|
||||
* The 3D view with the slider that cuts away its top layers.
|
||||
*/
|
||||
JComponent view() {
|
||||
return viewWithLayerCut;
|
||||
}
|
||||
|
||||
JComponent overview() {
|
||||
return overview;
|
||||
}
|
||||
|
||||
StudioStatusBar statusBar() {
|
||||
return statusBar;
|
||||
}
|
||||
|
||||
void resetCamera() {
|
||||
viewport.resetCamera();
|
||||
}
|
||||
|
||||
// Textures load first, since the first object would otherwise be drawn in flat colours.
|
||||
void open() {
|
||||
statusBar.showActivity("Loading Minecraft textures...");
|
||||
loader.execute(() -> {
|
||||
loadTextures();
|
||||
List<String> keys = objectKeys();
|
||||
EventQueue.invokeLater(() -> showCatalog(keys));
|
||||
});
|
||||
}
|
||||
|
||||
void close() {
|
||||
GuiHost.unregisterHotloadHook(hotloadHook);
|
||||
objectRevision.incrementAndGet();
|
||||
loader.shutdownNow();
|
||||
viewport.close();
|
||||
}
|
||||
|
||||
private void loadTextures() {
|
||||
try {
|
||||
BlockTextures loaded = BlockTextures.shared().join();
|
||||
tintColors = loaded.biomeColors().vanilla(TINT_BIOME);
|
||||
textures = loaded;
|
||||
} catch (RuntimeException unavailable) {
|
||||
EventQueue.invokeLater(() -> statusBar.showError("Minecraft textures are unavailable, objects use plain colours"));
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> objectKeys() {
|
||||
return Arrays.asList(engine().getData().getObjectLoader().getPossibleKeys());
|
||||
}
|
||||
|
||||
private void showCatalog(List<String> keys) {
|
||||
statusBar.showActivity("");
|
||||
browser.showObjects(keys);
|
||||
String key = shownKey != null && keys.contains(shownKey) ? shownKey : browser.firstObject();
|
||||
if (key == null) {
|
||||
viewport.showMessage("This pack has no objects");
|
||||
overview.clear();
|
||||
layerCut.showHeight(1);
|
||||
return;
|
||||
}
|
||||
browser.select(key);
|
||||
showObject(key);
|
||||
}
|
||||
|
||||
private void showObject(String key) {
|
||||
shownKey = key;
|
||||
shown = null;
|
||||
wholeMesh = null;
|
||||
meshRevision.incrementAndGet();
|
||||
long revision = objectRevision.incrementAndGet();
|
||||
viewport.showMessage("Loading " + key + "...");
|
||||
statusBar.showActivity("Loading " + key + "...");
|
||||
loader.execute(() -> {
|
||||
if (revision != objectRevision.get()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
LoadedObject loaded = load(key);
|
||||
EventQueue.invokeLater(() -> showLoaded(revision, loaded));
|
||||
} catch (RuntimeException error) {
|
||||
EventQueue.invokeLater(() -> showFailure(revision, key, error));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private LoadedObject load(String key) {
|
||||
IrisObject object = engine().getData().getObjectLoader().load(key);
|
||||
if (object == null) {
|
||||
throw new IllegalStateException("The object file could not be read");
|
||||
}
|
||||
Map<BlockVector, BlockData> blocks = new HashMap<>();
|
||||
object.getBlocks().forEach((position, block) -> {
|
||||
if (!block.getMaterial().isAir()) {
|
||||
blocks.put(position, block);
|
||||
}
|
||||
});
|
||||
ObjectMesh mesh = ObjectMesh.build(blocks, this::modelOf);
|
||||
ObjectSummary summary = ObjectSummary.of(key, mesh.bounds(), blocks.values(), this::iconOf);
|
||||
return new LoadedObject(blocks, mesh, summary);
|
||||
}
|
||||
|
||||
private void showLoaded(long revision, LoadedObject loaded) {
|
||||
if (revision != objectRevision.get()) {
|
||||
return;
|
||||
}
|
||||
shown = loaded;
|
||||
wholeMesh = loaded.mesh();
|
||||
seeThroughTypes = Set.of();
|
||||
statusBar.showActivity("");
|
||||
viewport.show(loaded.mesh());
|
||||
overview.show(loaded.summary());
|
||||
layerCut.showHeight(loaded.mesh().bounds().sizeY());
|
||||
showMeshSummary(loaded.mesh());
|
||||
}
|
||||
|
||||
private void showMeshSummary(ObjectMesh mesh) {
|
||||
ObjectSummary summary = shown.summary();
|
||||
String cut = layerCut.isCutting()
|
||||
? String.format(Locale.ROOT, " · showing layers 1-%d of %d", layerCut.shownLayerCount(), layerCut.layerCount())
|
||||
: "";
|
||||
statusBar.showSummary(String.format(Locale.ROOT, "%s · %,d blocks · %,d faces drawn%s",
|
||||
summary.key(), summary.blockCount(), mesh.faceCount(), cut));
|
||||
}
|
||||
|
||||
private void showSeeThrough(Set<String> blockTypes) {
|
||||
seeThroughTypes = blockTypes;
|
||||
rebuildWholeMesh();
|
||||
}
|
||||
|
||||
// Blocks behind see-through ones get their hidden faces back, so the mesh is built again.
|
||||
private void rebuildWholeMesh() {
|
||||
LoadedObject object = shown;
|
||||
if (object == null) {
|
||||
return;
|
||||
}
|
||||
long revision = meshRevision.incrementAndGet();
|
||||
Set<String> seeThrough = seeThroughTypes;
|
||||
loader.execute(() -> {
|
||||
if (revision != meshRevision.get()) {
|
||||
return;
|
||||
}
|
||||
ObjectMesh mesh = seeThrough.isEmpty()
|
||||
? object.mesh()
|
||||
: ObjectMesh.build(object.blocks(), this::modelOf, object.mesh().bounds(),
|
||||
block -> seeThrough.contains(block.getMaterial().getKey().getKey()));
|
||||
EventQueue.invokeLater(() -> {
|
||||
if (revision == meshRevision.get() && shown == object) {
|
||||
wholeMesh = mesh;
|
||||
showLayerCut();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private void showLayerCut() {
|
||||
if (wholeMesh == null) {
|
||||
return;
|
||||
}
|
||||
int highestShownY = wholeMesh.bounds().minY() + layerCut.shownLayerCount() - 1;
|
||||
ObjectMesh mesh = layerCut.isCutting() ? wholeMesh.cutAbove(highestShownY) : wholeMesh;
|
||||
viewport.replaceMesh(mesh);
|
||||
showMeshSummary(mesh);
|
||||
}
|
||||
|
||||
private void showFailure(long revision, String key, Throwable error) {
|
||||
if (revision != objectRevision.get()) {
|
||||
return;
|
||||
}
|
||||
statusBar.showActivity("");
|
||||
viewport.showMessage("Could not load " + key);
|
||||
overview.clear();
|
||||
layerCut.showHeight(1);
|
||||
statusBar.showError(error.getMessage() == null ? error.getClass().getSimpleName() : error.getMessage());
|
||||
}
|
||||
|
||||
// The pack's files may have changed, so the list and the shown object are read again.
|
||||
private void reloadPack() {
|
||||
loader.execute(() -> {
|
||||
List<String> keys = objectKeys();
|
||||
EventQueue.invokeLater(() -> showCatalog(keys));
|
||||
});
|
||||
}
|
||||
|
||||
private Engine engine() {
|
||||
Engine current = GuiHost.findEngine(worldId);
|
||||
return current != null && !current.isClosed() ? current : startEngine;
|
||||
}
|
||||
|
||||
private BlockModel modelOf(BlockData block) {
|
||||
return models.computeIfAbsent(block.getAsString(false), state -> {
|
||||
BlockTextures available = textures;
|
||||
if (available == null) {
|
||||
return BlockModel.solidColor(UNKNOWN_BLOCK_COLOR);
|
||||
}
|
||||
return available.model(block).map(this::withBiomeTint).orElseGet(() -> BlockModel.solidColor(UNKNOWN_BLOCK_COLOR));
|
||||
});
|
||||
}
|
||||
|
||||
private BufferedImage iconOf(BlockData block) {
|
||||
BlockTextures available = textures;
|
||||
if (available == null) {
|
||||
return null;
|
||||
}
|
||||
return available.iconTexture(block).map(this::withBiomeTint).map(BlockTexture::toImage).orElse(null);
|
||||
}
|
||||
|
||||
// Objects have no biome of their own, so grass and leaves get plains' colours.
|
||||
private BlockModel withBiomeTint(BlockModel model) {
|
||||
return model.withTextures(this::withBiomeTint);
|
||||
}
|
||||
|
||||
private BlockTexture withBiomeTint(BlockTexture texture) {
|
||||
BiomeTintColors colors = tintColors;
|
||||
if (texture.biomeTint() == null || colors == null) {
|
||||
return texture;
|
||||
}
|
||||
return texture.tinted(colors.colorAt(texture.biomeTint(), 0, 0));
|
||||
}
|
||||
|
||||
private record LoadedObject(Map<BlockVector, BlockData> blocks, ObjectMesh mesh, ObjectSummary summary) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JSlider;
|
||||
import javax.swing.SwingConstants;
|
||||
import java.awt.AlphaComposite;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Graphics;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.event.MouseAdapter;
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.util.function.IntConsumer;
|
||||
|
||||
/**
|
||||
* A slim slider floating on the right of the 3D view that hides an object's top layers, to look inside buildings
|
||||
* floor by floor. Most objects never need it, so it stays faint until the mouse comes near or a cut is active.
|
||||
*/
|
||||
final class ObjectLayerCut extends JPanel {
|
||||
private static final long serialVersionUID = 4473820211915526120L;
|
||||
private static final int PANEL_WIDTH = 30;
|
||||
private static final int MAX_HEIGHT = 320;
|
||||
private static final double HEIGHT_SHARE_OF_VIEW = 0.5D;
|
||||
private static final int EDGE_MARGIN = 8;
|
||||
private static final float FAINT_OPACITY = 0.28F;
|
||||
|
||||
private final JSlider slider = new JSlider(SwingConstants.VERTICAL, 1, 1, 1);
|
||||
private final JLabel shownLayers = new JLabel(" ", SwingConstants.CENTER);
|
||||
private final IntConsumer onLayersChanged;
|
||||
private boolean resetting;
|
||||
private boolean hovered;
|
||||
|
||||
/**
|
||||
* @param onLayersChanged told how many layers from the bottom stay visible whenever the user moves the slider
|
||||
*/
|
||||
ObjectLayerCut(IntConsumer onLayersChanged) {
|
||||
super(new BorderLayout(0, StudioTheme.scale(4)));
|
||||
this.onLayersChanged = onLayersChanged;
|
||||
setOpaque(false);
|
||||
setToolTipText("Hide the top layers to look inside · Page Up / Page Down");
|
||||
shownLayers.setFont(StudioTheme.smallFont());
|
||||
shownLayers.setForeground(StudioTheme.SECONDARY_TEXT);
|
||||
slider.setOpaque(false);
|
||||
slider.setFocusable(false);
|
||||
slider.setToolTipText(getToolTipText());
|
||||
slider.addChangeListener(event -> {
|
||||
showLayerLabel();
|
||||
if (!resetting) {
|
||||
onLayersChanged.accept(slider.getValue());
|
||||
}
|
||||
});
|
||||
MouseAdapter hover = new MouseAdapter() {
|
||||
@Override
|
||||
public void mouseEntered(MouseEvent event) {
|
||||
setHovered(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseExited(MouseEvent event) {
|
||||
setHovered(slider.getValueIsAdjusting());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseReleased(MouseEvent event) {
|
||||
setHovered(slider.contains(event.getPoint()));
|
||||
}
|
||||
};
|
||||
slider.addMouseListener(hover);
|
||||
addMouseListener(hover);
|
||||
add(shownLayers, BorderLayout.NORTH);
|
||||
add(slider, BorderLayout.CENTER);
|
||||
showHeight(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* The view with this slider floating over its right edge, halfway down.
|
||||
*/
|
||||
JComponent floatingOver(JComponent view) {
|
||||
ObjectLayerCut cut = this;
|
||||
JPanel stack = new JPanel(null) {
|
||||
private static final long serialVersionUID = -1950381405658102347L;
|
||||
|
||||
// The slider overlaps the view, so the view may not repaint without the slider on top of it.
|
||||
@Override
|
||||
public boolean isOptimizedDrawingEnabled() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void doLayout() {
|
||||
view.setBounds(0, 0, getWidth(), getHeight());
|
||||
int width = StudioTheme.scale(PANEL_WIDTH);
|
||||
int height = Math.min(StudioTheme.scale(MAX_HEIGHT), (int) (getHeight() * HEIGHT_SHARE_OF_VIEW));
|
||||
cut.setBounds(getWidth() - width - StudioTheme.scale(EDGE_MARGIN), (getHeight() - height) / 2, width, height);
|
||||
}
|
||||
};
|
||||
stack.add(cut);
|
||||
stack.add(view);
|
||||
return stack;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows every layer of a newly chosen object without telling anyone, since nothing is hidden yet.
|
||||
*/
|
||||
void showHeight(int layers) {
|
||||
resetting = true;
|
||||
try {
|
||||
slider.setMaximum(Math.max(1, layers));
|
||||
slider.setValue(slider.getMaximum());
|
||||
} finally {
|
||||
resetting = false;
|
||||
}
|
||||
showLayerLabel();
|
||||
setVisible(layers > 1);
|
||||
}
|
||||
|
||||
void stepBy(int layers) {
|
||||
if (isVisible()) {
|
||||
slider.setValue(slider.getValue() + layers);
|
||||
}
|
||||
}
|
||||
|
||||
boolean isCutting() {
|
||||
return slider.getValue() < slider.getMaximum();
|
||||
}
|
||||
|
||||
int shownLayerCount() {
|
||||
return slider.getValue();
|
||||
}
|
||||
|
||||
int layerCount() {
|
||||
return slider.getMaximum();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void paintChildren(Graphics graphics) {
|
||||
Graphics2D canvas = (Graphics2D) graphics.create();
|
||||
try {
|
||||
if (!hovered && !isCutting()) {
|
||||
canvas.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, FAINT_OPACITY));
|
||||
}
|
||||
super.paintChildren(canvas);
|
||||
} finally {
|
||||
canvas.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private void setHovered(boolean nowHovered) {
|
||||
if (hovered != nowHovered) {
|
||||
hovered = nowHovered;
|
||||
repaint();
|
||||
}
|
||||
}
|
||||
|
||||
private void showLayerLabel() {
|
||||
shownLayers.setText(isCutting() ? Integer.toString(slider.getValue()) : " ");
|
||||
repaint();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
import com.volmit.iris.core.gui.BlockModel.Face;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
import org.bukkit.util.BlockVector;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
/**
|
||||
* The faces of an object's blocks that can be seen from outside. A face is left out when the neighbour on its side
|
||||
* covers that side, which is most of them in a large object. See-through faces, like water and stained glass, are
|
||||
* kept apart because they are drawn last, blended over everything behind them. Top faces covered only by the block
|
||||
* above are kept aside too, so the top layers can be cut away without building the mesh again; what is cut away stays
|
||||
* as a faint ghost of the object's shape.
|
||||
*/
|
||||
record ObjectMesh(List<Quad> solidQuads, List<Quad> translucentQuads, List<Quad> cutAwayQuads, List<Quad> topsUnderBlockAbove,
|
||||
Bounds bounds) {
|
||||
private static final double SEE_THROUGH_OPACITY = 0.22D;
|
||||
private static final double FULL_OPACITY = 1D;
|
||||
private static final double CUT_AWAY_OPACITY = 0.1D;
|
||||
|
||||
static ObjectMesh build(Map<BlockVector, BlockData> blocks, Function<BlockData, BlockModel> modelOf) {
|
||||
return build(blocks, modelOf, Bounds.of(blocks.keySet()), block -> false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param bounds the whole object's extent, kept when only part of it is drawn so the view does not jump
|
||||
* @param isSeeThrough blocks drawn faintly, which hide nothing behind them, to look past them into the object
|
||||
*/
|
||||
static ObjectMesh build(Map<BlockVector, BlockData> blocks, Function<BlockData, BlockModel> modelOf, Bounds bounds,
|
||||
Predicate<BlockData> isSeeThrough) {
|
||||
List<Quad> solid = new ArrayList<>();
|
||||
List<Quad> translucent = new ArrayList<>();
|
||||
List<Quad> topsUnderBlockAbove = new ArrayList<>();
|
||||
BlockGrid grid = new BlockGrid(blocks, modelOf, bounds, isSeeThrough);
|
||||
for (BlockVector position : blocks.keySet()) {
|
||||
int x = position.getBlockX();
|
||||
int y = position.getBlockY();
|
||||
int z = position.getBlockZ();
|
||||
int cell = grid.cellAt(x, y, z);
|
||||
for (Face face : grid.modelIn(cell).faces()) {
|
||||
Quad quad = new Quad(face, x, y, z, grid.isSeeThroughIn(cell) ? SEE_THROUGH_OPACITY : FULL_OPACITY);
|
||||
if (!grid.isHiddenByNeighbour(x, y, z, face)) {
|
||||
(quad.isBlended() ? translucent : solid).add(quad);
|
||||
} else if (face.cullFace() == CubeFace.UP) {
|
||||
topsUnderBlockAbove.add(quad);
|
||||
}
|
||||
}
|
||||
}
|
||||
return new ObjectMesh(List.copyOf(solid), List.copyOf(translucent), List.of(), List.copyOf(topsUnderBlockAbove), bounds);
|
||||
}
|
||||
|
||||
/**
|
||||
* This mesh with the blocks above the given layer faded to a ghost of the object's shape, and the tops of that
|
||||
* layer's blocks showing again. Quick enough to follow a slider as it moves.
|
||||
*/
|
||||
ObjectMesh cutAbove(int highestShownY) {
|
||||
List<Quad> solid = new ArrayList<>();
|
||||
List<Quad> translucent = new ArrayList<>();
|
||||
List<Quad> cutAway = new ArrayList<>();
|
||||
for (Quad quad : solidQuads) {
|
||||
keepOrCutAway(quad, highestShownY, solid, cutAway);
|
||||
}
|
||||
for (Quad quad : translucentQuads) {
|
||||
keepOrCutAway(quad, highestShownY, translucent, cutAway);
|
||||
}
|
||||
for (Quad quad : topsUnderBlockAbove) {
|
||||
if (quad.y() == highestShownY) {
|
||||
(quad.isBlended() ? translucent : solid).add(quad);
|
||||
}
|
||||
}
|
||||
return new ObjectMesh(solid, translucent, cutAway, List.of(), bounds);
|
||||
}
|
||||
|
||||
private static void keepOrCutAway(Quad quad, int highestShownY, List<Quad> kept, List<Quad> cutAway) {
|
||||
if (quad.y() <= highestShownY) {
|
||||
kept.add(quad);
|
||||
} else {
|
||||
cutAway.add(quad.faded(CUT_AWAY_OPACITY));
|
||||
}
|
||||
}
|
||||
|
||||
boolean isEmpty() {
|
||||
return solidQuads.isEmpty() && translucentQuads.isEmpty() && cutAwayQuads.isEmpty();
|
||||
}
|
||||
|
||||
int faceCount() {
|
||||
return solidQuads.size() + translucentQuads.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* The object's blocks laid out in an array over its bounds, so finding a block's neighbours is quick. Looking them
|
||||
* up by {@link BlockVector} in a map is slow, since its hash codes collide a lot.
|
||||
*/
|
||||
private static final class BlockGrid {
|
||||
private final Bounds bounds;
|
||||
private final BlockData[] blocks;
|
||||
private final BlockModel[] models;
|
||||
private final boolean[] seeThrough;
|
||||
|
||||
BlockGrid(Map<BlockVector, BlockData> placed, Function<BlockData, BlockModel> modelOf, Bounds bounds,
|
||||
Predicate<BlockData> isSeeThrough) {
|
||||
this.bounds = bounds;
|
||||
int cells = bounds.sizeX() * bounds.sizeY() * bounds.sizeZ();
|
||||
this.blocks = new BlockData[cells];
|
||||
this.models = new BlockModel[cells];
|
||||
this.seeThrough = new boolean[cells];
|
||||
placed.forEach((position, block) -> {
|
||||
int cell = cellAt(position.getBlockX(), position.getBlockY(), position.getBlockZ());
|
||||
blocks[cell] = block;
|
||||
models[cell] = modelOf.apply(block);
|
||||
seeThrough[cell] = isSeeThrough.test(block);
|
||||
});
|
||||
}
|
||||
|
||||
int cellAt(int x, int y, int z) {
|
||||
return ((y - bounds.minY()) * bounds.sizeZ() + (z - bounds.minZ())) * bounds.sizeX() + (x - bounds.minX());
|
||||
}
|
||||
|
||||
BlockModel modelIn(int cell) {
|
||||
return models[cell];
|
||||
}
|
||||
|
||||
boolean isSeeThroughIn(int cell) {
|
||||
return seeThrough[cell];
|
||||
}
|
||||
|
||||
// Glass next to glass and water next to water hide the side between them too, as they do in the game. A
|
||||
// see-through block only hides sides of other see-through blocks, so whatever it covers shows through it.
|
||||
boolean isHiddenByNeighbour(int x, int y, int z, Face face) {
|
||||
CubeFace side = face.cullFace();
|
||||
if (side == null || !contains(x + side.stepX(), y + side.stepY(), z + side.stepZ())) {
|
||||
return false;
|
||||
}
|
||||
int cell = cellAt(x, y, z);
|
||||
int neighbourCell = cellAt(x + side.stepX(), y + side.stepY(), z + side.stepZ());
|
||||
BlockData neighbour = blocks[neighbourCell];
|
||||
if (neighbour == null || (seeThrough[neighbourCell] && !seeThrough[cell])) {
|
||||
return false;
|
||||
}
|
||||
BlockModel neighbourModel = models[neighbourCell];
|
||||
if (neighbourModel.coveredSides().contains(side.opposite())) {
|
||||
return true;
|
||||
}
|
||||
return neighbour.getMaterial() == blocks[cell].getMaterial() && models[cell].hidesSameBlockSides()
|
||||
&& neighbourModel.hidesSameBlockSides();
|
||||
}
|
||||
|
||||
private boolean contains(int x, int y, int z) {
|
||||
return x >= bounds.minX() && x < bounds.maxX() && y >= bounds.minY() && y < bounds.maxY()
|
||||
&& z >= bounds.minZ() && z < bounds.maxZ();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A model face placed at a block in the object, drawn at the given opacity on top of its texture's own.
|
||||
*/
|
||||
record Quad(Face face, int x, int y, int z, double opacity) {
|
||||
boolean isBlended() {
|
||||
return opacity < FULL_OPACITY || face.texture().isTranslucent();
|
||||
}
|
||||
|
||||
Quad faded(double factor) {
|
||||
return new Quad(face, x, y, z, opacity * factor);
|
||||
}
|
||||
|
||||
double originX() {
|
||||
return x + face.origin().x();
|
||||
}
|
||||
|
||||
double originY() {
|
||||
return y + face.origin().y();
|
||||
}
|
||||
|
||||
double originZ() {
|
||||
return z + face.origin().z();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The blocks' extent, with the maximum one past the last block so a single block spans one unit.
|
||||
*/
|
||||
record Bounds(int minX, int minY, int minZ, int maxX, int maxY, int maxZ) {
|
||||
private static final Bounds EMPTY = new Bounds(0, 0, 0, 1, 1, 1);
|
||||
|
||||
static Bounds of(Iterable<BlockVector> positions) {
|
||||
int minX = Integer.MAX_VALUE;
|
||||
int minY = Integer.MAX_VALUE;
|
||||
int minZ = Integer.MAX_VALUE;
|
||||
int maxX = Integer.MIN_VALUE;
|
||||
int maxY = Integer.MIN_VALUE;
|
||||
int maxZ = Integer.MIN_VALUE;
|
||||
for (BlockVector position : positions) {
|
||||
minX = Math.min(minX, position.getBlockX());
|
||||
minY = Math.min(minY, position.getBlockY());
|
||||
minZ = Math.min(minZ, position.getBlockZ());
|
||||
maxX = Math.max(maxX, position.getBlockX() + 1);
|
||||
maxY = Math.max(maxY, position.getBlockY() + 1);
|
||||
maxZ = Math.max(maxZ, position.getBlockZ() + 1);
|
||||
}
|
||||
return minX == Integer.MAX_VALUE ? EMPTY : new Bounds(minX, minY, minZ, maxX, maxY, maxZ);
|
||||
}
|
||||
|
||||
int sizeX() {
|
||||
return maxX - minX;
|
||||
}
|
||||
|
||||
int sizeY() {
|
||||
return maxY - minY;
|
||||
}
|
||||
|
||||
int sizeZ() {
|
||||
return maxZ - minZ;
|
||||
}
|
||||
|
||||
int largestSize() {
|
||||
return Math.max(sizeX(), Math.max(sizeY(), sizeZ()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.BoxLayout;
|
||||
import javax.swing.JButton;
|
||||
import javax.swing.SwingUtilities;
|
||||
import javax.swing.DefaultListCellRenderer;
|
||||
import javax.swing.DefaultListModel;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JList;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JTextField;
|
||||
import javax.swing.event.DocumentEvent;
|
||||
import javax.swing.event.DocumentListener;
|
||||
import java.awt.AlphaComposite;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Component;
|
||||
import java.awt.Composite;
|
||||
import java.awt.Cursor;
|
||||
import java.awt.Insets;
|
||||
import java.awt.event.MouseAdapter;
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.awt.Graphics;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.GridLayout;
|
||||
import java.awt.RenderingHints;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* The chosen object's size and the blocks it is made of, most used first, like the overview in Procedural Objects.
|
||||
* Clicking a block makes it see-through in the 3D view, to look past walls into the object.
|
||||
*/
|
||||
final class ObjectOverviewPanel extends JPanel {
|
||||
private static final long serialVersionUID = -8811407613645718290L;
|
||||
private static final int PANEL_WIDTH = 280;
|
||||
private static final int ROW_HEIGHT = 26;
|
||||
private static final int ICON_SIZE = 16;
|
||||
private static final int ICON_GAP = 8;
|
||||
private static final int ROW_PADDING = 10;
|
||||
private static final int SHARE_WIDTH = 92;
|
||||
private static final int SHARE_BAR_HEIGHT = 4;
|
||||
private static final float SEE_THROUGH_ICON_OPACITY = 0.3F;
|
||||
private static final String SEE_THROUGH_HINT = "Click a block to see through it";
|
||||
|
||||
private final JLabel title = new JLabel(" ");
|
||||
private final JLabel path = new JLabel(" ");
|
||||
private final JLabel size = statValue();
|
||||
private final JLabel blockCount = statValue();
|
||||
private final JLabel blockTypes = statValue();
|
||||
private final JLabel filled = statValue();
|
||||
private final JTextField filterField = new JTextField();
|
||||
private final DefaultListModel<ObjectSummary.BlockUse> rows = new DefaultListModel<>();
|
||||
private final JList<ObjectSummary.BlockUse> list = new JList<>(rows) {
|
||||
@Override
|
||||
public boolean getScrollableTracksViewportWidth() {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
private final JLabel seeThroughHint = new JLabel(SEE_THROUGH_HINT);
|
||||
private final JButton showAllSolid = new JButton("Show all solid");
|
||||
private final Set<String> seeThroughTypes = new LinkedHashSet<>();
|
||||
private final Consumer<Set<String>> onSeeThroughChanged;
|
||||
private ObjectSummary summary;
|
||||
|
||||
/**
|
||||
* @param onSeeThroughChanged told which block types to draw see-through whenever the user changes them
|
||||
*/
|
||||
ObjectOverviewPanel(Consumer<Set<String>> onSeeThroughChanged) {
|
||||
super(new BorderLayout());
|
||||
this.onSeeThroughChanged = onSeeThroughChanged;
|
||||
setPreferredSize(StudioTheme.scaledSize(PANEL_WIDTH, 0));
|
||||
setBorder(BorderFactory.createMatteBorder(0, 1, 0, 0, StudioTheme.SEPARATOR));
|
||||
setBackground(StudioTheme.PANEL_BACKGROUND);
|
||||
add(buildHeader(), BorderLayout.NORTH);
|
||||
add(buildBlockList(), BorderLayout.CENTER);
|
||||
}
|
||||
|
||||
void show(ObjectSummary nextSummary) {
|
||||
summary = nextSummary;
|
||||
title.setText(nextSummary.name());
|
||||
path.setText(nextSummary.key());
|
||||
path.setToolTipText(nextSummary.key());
|
||||
size.setText(nextSummary.sizeX() + " × " + nextSummary.sizeY() + " × " + nextSummary.sizeZ());
|
||||
blockCount.setText(formatCount(nextSummary.blockCount()));
|
||||
blockTypes.setText(formatCount(nextSummary.blockUses().size()));
|
||||
filled.setText(String.format(Locale.ROOT, "%.1f%%", nextSummary.filledFraction() * 100D));
|
||||
seeThroughTypes.clear();
|
||||
showSeeThroughState();
|
||||
refilter();
|
||||
}
|
||||
|
||||
void clear() {
|
||||
summary = null;
|
||||
title.setText(" ");
|
||||
path.setText(" ");
|
||||
for (JLabel value : List.of(size, blockCount, blockTypes, filled)) {
|
||||
value.setText("—");
|
||||
}
|
||||
rows.clear();
|
||||
seeThroughTypes.clear();
|
||||
showSeeThroughState();
|
||||
}
|
||||
|
||||
private JPanel buildHeader() {
|
||||
JPanel header = new JPanel();
|
||||
header.setLayout(new BoxLayout(header, BoxLayout.Y_AXIS));
|
||||
header.setOpaque(false);
|
||||
header.setBorder(StudioTheme.emptyBorder(12, 16, 8, 16));
|
||||
title.setFont(StudioTheme.titleFont());
|
||||
path.setFont(StudioTheme.smallFont());
|
||||
path.setForeground(StudioTheme.SECONDARY_TEXT);
|
||||
header.add(leftAligned(title));
|
||||
header.add(leftAligned(path));
|
||||
|
||||
JPanel stats = new JPanel(new GridLayout(2, 2, StudioTheme.scale(12), StudioTheme.scale(8)));
|
||||
stats.setOpaque(false);
|
||||
stats.setBorder(StudioTheme.emptyBorder(12, 0, 4, 0));
|
||||
stats.add(stat("Size · X × Y × Z", size));
|
||||
stats.add(stat("Blocks", blockCount));
|
||||
stats.add(stat("Block types", blockTypes));
|
||||
stats.add(stat("Filled volume", filled));
|
||||
filled.setToolTipText("How much of the object's bounding box its blocks fill");
|
||||
header.add(leftAligned(stats));
|
||||
clear();
|
||||
return header;
|
||||
}
|
||||
|
||||
private JPanel buildBlockList() {
|
||||
JPanel blocks = new JPanel(new BorderLayout());
|
||||
blocks.setOpaque(false);
|
||||
StudioToolbar.makeSearchField(filterField, "Find a block...");
|
||||
filterField.getDocument().addDocumentListener(new DocumentListener() {
|
||||
@Override
|
||||
public void insertUpdate(DocumentEvent event) {
|
||||
refilter();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeUpdate(DocumentEvent event) {
|
||||
refilter();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void changedUpdate(DocumentEvent event) {
|
||||
refilter();
|
||||
}
|
||||
});
|
||||
list.setFocusable(false);
|
||||
list.setFixedCellHeight(StudioTheme.scale(ROW_HEIGHT));
|
||||
list.setCellRenderer(new BlockUseRenderer());
|
||||
list.setBackground(StudioTheme.PANEL_BACKGROUND);
|
||||
list.setBorder(StudioTheme.emptyBorder(0, 6, 4, 6));
|
||||
list.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
|
||||
list.addMouseListener(new MouseAdapter() {
|
||||
@Override
|
||||
public void mouseClicked(MouseEvent event) {
|
||||
int row = list.locationToIndex(event.getPoint());
|
||||
if (SwingUtilities.isLeftMouseButton(event) && row >= 0 && list.getCellBounds(row, row).contains(event.getPoint())) {
|
||||
toggleSeeThrough(rows.get(row).blockType());
|
||||
}
|
||||
}
|
||||
});
|
||||
JPanel top = new JPanel();
|
||||
top.setLayout(new BoxLayout(top, BoxLayout.Y_AXIS));
|
||||
top.setOpaque(false);
|
||||
top.add(leftAligned(StudioToolbar.padded(filterField, 4, 12, 4, 12)));
|
||||
top.add(leftAligned(StudioToolbar.padded(seeThroughRow(), 0, 14, 6, 12)));
|
||||
blocks.add(top, BorderLayout.NORTH);
|
||||
blocks.add(StudioToolbar.scroll(list), BorderLayout.CENTER);
|
||||
return blocks;
|
||||
}
|
||||
|
||||
private JPanel seeThroughRow() {
|
||||
seeThroughHint.setFont(StudioTheme.smallFont());
|
||||
seeThroughHint.setForeground(StudioTheme.MUTED_TEXT);
|
||||
showAllSolid.setFont(StudioTheme.smallFont());
|
||||
showAllSolid.setForeground(StudioTheme.LINK);
|
||||
showAllSolid.setBorderPainted(false);
|
||||
showAllSolid.setContentAreaFilled(false);
|
||||
showAllSolid.setFocusable(false);
|
||||
showAllSolid.setMargin(new Insets(0, 0, 0, 0));
|
||||
showAllSolid.setBorder(BorderFactory.createEmptyBorder());
|
||||
showAllSolid.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
|
||||
showAllSolid.addActionListener(event -> {
|
||||
seeThroughTypes.clear();
|
||||
seeThroughChanged();
|
||||
});
|
||||
JPanel row = new JPanel(new BorderLayout());
|
||||
row.setOpaque(false);
|
||||
row.add(seeThroughHint, BorderLayout.CENTER);
|
||||
row.add(showAllSolid, BorderLayout.EAST);
|
||||
showSeeThroughState();
|
||||
return row;
|
||||
}
|
||||
|
||||
private void toggleSeeThrough(String blockType) {
|
||||
if (!seeThroughTypes.remove(blockType)) {
|
||||
seeThroughTypes.add(blockType);
|
||||
}
|
||||
seeThroughChanged();
|
||||
}
|
||||
|
||||
private void seeThroughChanged() {
|
||||
showSeeThroughState();
|
||||
list.repaint();
|
||||
onSeeThroughChanged.accept(Set.copyOf(seeThroughTypes));
|
||||
}
|
||||
|
||||
private void showSeeThroughState() {
|
||||
int count = seeThroughTypes.size();
|
||||
seeThroughHint.setText(count == 0 ? SEE_THROUGH_HINT : count == 1 ? "1 block see-through" : count + " blocks see-through");
|
||||
showAllSolid.setVisible(count > 0);
|
||||
}
|
||||
|
||||
private void refilter() {
|
||||
rows.clear();
|
||||
if (summary == null) {
|
||||
return;
|
||||
}
|
||||
String filter = filterField.getText().trim().toLowerCase(Locale.ROOT).replace(' ', '_');
|
||||
for (ObjectSummary.BlockUse use : summary.blockUses()) {
|
||||
if (use.blockType().contains(filter)) {
|
||||
rows.addElement(use);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static JPanel stat(String label, JLabel value) {
|
||||
JPanel stat = new JPanel(new BorderLayout());
|
||||
stat.setOpaque(false);
|
||||
JLabel name = new JLabel(label);
|
||||
name.setFont(StudioTheme.smallFont());
|
||||
name.setForeground(StudioTheme.MUTED_TEXT);
|
||||
stat.add(name, BorderLayout.NORTH);
|
||||
stat.add(value, BorderLayout.CENTER);
|
||||
return stat;
|
||||
}
|
||||
|
||||
private static JLabel statValue() {
|
||||
JLabel value = new JLabel("—");
|
||||
value.setFont(StudioTheme.boldFont());
|
||||
return value;
|
||||
}
|
||||
|
||||
private static Component leftAligned(JPanel panel) {
|
||||
panel.setAlignmentX(LEFT_ALIGNMENT);
|
||||
return panel;
|
||||
}
|
||||
|
||||
private static Component leftAligned(JLabel label) {
|
||||
label.setAlignmentX(LEFT_ALIGNMENT);
|
||||
return label;
|
||||
}
|
||||
|
||||
private static String formatCount(int count) {
|
||||
return String.format(Locale.ROOT, "%,d", count);
|
||||
}
|
||||
|
||||
private final class BlockUseRenderer extends DefaultListCellRenderer {
|
||||
private static final long serialVersionUID = 2785337045812613941L;
|
||||
private transient BufferedImage icon;
|
||||
private boolean seeThrough;
|
||||
private double share;
|
||||
private String percent = "";
|
||||
|
||||
@Override
|
||||
public Component getListCellRendererComponent(JList<?> owner, Object value, int index, boolean selected, boolean focused) {
|
||||
ObjectSummary.BlockUse use = (ObjectSummary.BlockUse) value;
|
||||
super.getListCellRendererComponent(owner, use.blockType(), index, false, false);
|
||||
seeThrough = seeThroughTypes.contains(use.blockType());
|
||||
setFont(StudioTheme.font());
|
||||
setForeground(seeThrough ? StudioTheme.MUTED_TEXT : StudioTheme.TEXT);
|
||||
setBorder(StudioTheme.emptyBorder(0, ROW_PADDING + ICON_SIZE + ICON_GAP, 0, ROW_PADDING + SHARE_WIDTH));
|
||||
setToolTipText(use.blockType() + " · " + formatCount(use.count()) + " blocks · click to "
|
||||
+ (seeThrough ? "make solid again" : "see through it"));
|
||||
icon = use.icon();
|
||||
share = summary == null || summary.blockCount() == 0 ? 0D : use.count() / (double) summary.blockCount();
|
||||
percent = share > 0D && share < 0.001D ? "<0.1%" : String.format(Locale.ROOT, "%.1f%%", share * 100D);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void paintComponent(Graphics graphics) {
|
||||
super.paintComponent(graphics);
|
||||
Graphics2D canvas = (Graphics2D) graphics.create();
|
||||
try {
|
||||
int iconSize = StudioTheme.scale(ICON_SIZE);
|
||||
int iconX = StudioTheme.scale(ROW_PADDING);
|
||||
int iconY = (getHeight() - iconSize) / 2;
|
||||
if (icon != null) {
|
||||
canvas.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR);
|
||||
Composite solid = canvas.getComposite();
|
||||
if (seeThrough) {
|
||||
canvas.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, SEE_THROUGH_ICON_OPACITY));
|
||||
}
|
||||
canvas.drawImage(icon, iconX, iconY, iconSize, iconSize, null);
|
||||
canvas.setComposite(solid);
|
||||
} else {
|
||||
canvas.setColor(StudioTheme.CONTROL_BORDER);
|
||||
canvas.drawRect(iconX, iconY, iconSize - 1, iconSize - 1);
|
||||
}
|
||||
drawShare(canvas);
|
||||
} finally {
|
||||
canvas.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private void drawShare(Graphics2D canvas) {
|
||||
canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
|
||||
canvas.setFont(StudioTheme.smallFont());
|
||||
int padding = StudioTheme.scale(ROW_PADDING);
|
||||
int percentWidth = canvas.getFontMetrics().stringWidth("100.0%");
|
||||
int barWidth = StudioTheme.scale(SHARE_WIDTH) - percentWidth - StudioTheme.scale(6);
|
||||
int barX = getWidth() - padding - StudioTheme.scale(SHARE_WIDTH);
|
||||
int barHeight = StudioTheme.scale(SHARE_BAR_HEIGHT);
|
||||
int barY = (getHeight() - barHeight) / 2;
|
||||
canvas.setColor(StudioTheme.HOVER_BACKGROUND);
|
||||
canvas.fillRoundRect(barX, barY, barWidth, barHeight, barHeight, barHeight);
|
||||
canvas.setColor(seeThrough ? StudioTheme.CONTROL_BORDER : StudioTheme.ACCENT);
|
||||
canvas.fillRoundRect(barX, barY, Math.max(barHeight, (int) Math.round(barWidth * share)), barHeight, barHeight, barHeight);
|
||||
canvas.setColor(StudioTheme.SECONDARY_TEXT);
|
||||
int textWidth = canvas.getFontMetrics().stringWidth(percent);
|
||||
int baseline = (getHeight() + canvas.getFontMetrics().getAscent() - canvas.getFontMetrics().getDescent()) / 2;
|
||||
canvas.drawString(percent, getWidth() - padding - textWidth, baseline);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
import com.volmit.iris.core.gui.BlockModel.Face;
|
||||
import com.volmit.iris.core.gui.BlockModel.TextureMapping;
|
||||
import com.volmit.iris.core.gui.BlockTextures.BlockTexture;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.awt.image.DataBufferInt;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Draws an object mesh with an orthographic camera into a picture, without a graphics card: each quad is filled pixel
|
||||
* by pixel, and a depth buffer keeps the nearest one. A cut-away part's faint ghost only fills pixels the object
|
||||
* leaves empty, so it never hazes over what is shown. See-through quads come last, farthest first, each blended over
|
||||
* what is already drawn.
|
||||
*/
|
||||
final class ObjectRenderer {
|
||||
private static final Color GRID = new ThemeColor(0xE3E4E8, 0x34373C);
|
||||
private static final int GRID_LINES_PER_SIDE = 25;
|
||||
private static final double GRID_REACH = 0.7D;
|
||||
private static final double GRID_BELOW_OBJECT = 0.035D;
|
||||
// Shared quad edges land exactly on pixel centres now and then; a little overlap keeps them from leaving cracks.
|
||||
private static final double EDGE_TOLERANCE = 1e-6D;
|
||||
private static final double EDGE_ON_DETERMINANT = 1e-9D;
|
||||
// A face's own texels end a hair inside its UV rectangle, so rounding never picks the texel next to it.
|
||||
private static final double TEXEL_EDGE_MARGIN = 1e-6D;
|
||||
private static final double FULL_ALPHA = 255D;
|
||||
|
||||
private final int width;
|
||||
private final int height;
|
||||
private final int[] pixels;
|
||||
private final double[] depths;
|
||||
private final boolean[] showsObject;
|
||||
private final Projection projection;
|
||||
private final int gridColor = GRID.getRGB() & 0xFFFFFF;
|
||||
|
||||
private ObjectRenderer(ObjectMesh mesh, ObjectCamera camera, BufferedImage image, int coveredLeftWidth) {
|
||||
this.width = image.getWidth();
|
||||
this.height = image.getHeight();
|
||||
this.pixels = ((DataBufferInt) image.getRaster().getDataBuffer()).getData();
|
||||
this.depths = new double[pixels.length];
|
||||
this.showsObject = new boolean[pixels.length];
|
||||
this.projection = new Projection(mesh.bounds(), camera, width, height, coveredLeftWidth);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param coveredLeftWidth how much of the picture's left side something floats over, so the object is centred and
|
||||
* fitted in the rest
|
||||
*/
|
||||
static BufferedImage render(ObjectMesh mesh, ObjectCamera camera, int width, int height, int coveredLeftWidth) {
|
||||
BufferedImage image = new BufferedImage(Math.max(1, width), Math.max(1, height), BufferedImage.TYPE_INT_RGB);
|
||||
ObjectRenderer renderer = new ObjectRenderer(mesh, camera, image, Math.max(0, Math.min(width - 1, coveredLeftWidth)));
|
||||
Arrays.fill(renderer.pixels, StudioTheme.EDITOR_BACKGROUND.getRGB() & 0xFFFFFF);
|
||||
Arrays.fill(renderer.depths, Double.NEGATIVE_INFINITY);
|
||||
renderer.drawGrid(mesh.bounds());
|
||||
for (ObjectMesh.Quad quad : mesh.solidQuads()) {
|
||||
renderer.drawQuad(quad, Pass.SOLID);
|
||||
}
|
||||
for (ObjectMesh.Quad quad : mesh.cutAwayQuads()) {
|
||||
renderer.drawQuad(quad, Pass.CUT_AWAY_GHOST);
|
||||
}
|
||||
for (ObjectMesh.Quad quad : renderer.farthestFirst(mesh.translucentQuads())) {
|
||||
renderer.drawQuad(quad, Pass.BLENDED);
|
||||
}
|
||||
return image;
|
||||
}
|
||||
|
||||
private List<ObjectMesh.Quad> farthestFirst(List<ObjectMesh.Quad> quads) {
|
||||
List<ObjectMesh.Quad> sorted = new ArrayList<>(quads);
|
||||
sorted.sort(Comparator.comparingDouble(this::centerDepth));
|
||||
return sorted;
|
||||
}
|
||||
|
||||
private double centerDepth(ObjectMesh.Quad quad) {
|
||||
Face face = quad.face();
|
||||
return projection.depth(
|
||||
quad.originX() + (face.edgeU().x() + face.edgeV().x()) / 2D,
|
||||
quad.originY() + (face.edgeU().y() + face.edgeV().y()) / 2D,
|
||||
quad.originZ() + (face.edgeU().z() + face.edgeV().z()) / 2D);
|
||||
}
|
||||
|
||||
private void drawGrid(ObjectMesh.Bounds bounds) {
|
||||
int reach = (int) Math.ceil(bounds.largestSize() * GRID_REACH);
|
||||
int step = Math.max(1, (int) Math.ceil(reach / (double) GRID_LINES_PER_SIDE));
|
||||
double floor = bounds.minY() - GRID_BELOW_OBJECT;
|
||||
int centerX = Math.floorDiv(bounds.minX() + bounds.maxX(), 2);
|
||||
int centerZ = Math.floorDiv(bounds.minZ() + bounds.maxZ(), 2);
|
||||
for (int offset = -reach; offset <= reach; offset += step) {
|
||||
drawLine(centerX + offset, floor, centerZ - reach, centerX + offset, floor, centerZ + reach);
|
||||
drawLine(centerX - reach, floor, centerZ + offset, centerX + reach, floor, centerZ + offset);
|
||||
}
|
||||
}
|
||||
|
||||
private void drawLine(double fromX, double fromY, double fromZ, double toX, double toY, double toZ) {
|
||||
double startX = projection.screenX(fromX, fromY, fromZ);
|
||||
double startY = projection.screenY(fromX, fromY, fromZ);
|
||||
double startDepth = projection.depth(fromX, fromY, fromZ);
|
||||
double endX = projection.screenX(toX, toY, toZ);
|
||||
double endY = projection.screenY(toX, toY, toZ);
|
||||
double endDepth = projection.depth(toX, toY, toZ);
|
||||
int steps = (int) Math.ceil(Math.max(Math.abs(endX - startX), Math.abs(endY - startY)));
|
||||
for (int step = 0; step <= steps; step++) {
|
||||
double progress = steps == 0 ? 0D : step / (double) steps;
|
||||
int x = (int) (startX + (endX - startX) * progress);
|
||||
int y = (int) (startY + (endY - startY) * progress);
|
||||
if (x < 0 || y < 0 || x >= width || y >= height) {
|
||||
continue;
|
||||
}
|
||||
int index = y * width + x;
|
||||
double depth = startDepth + (endDepth - startDepth) * progress;
|
||||
if (depth > depths[index]) {
|
||||
depths[index] = depth;
|
||||
pixels[index] = gridColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Later faces win ties, so an overlay drawn on the same box, like grass on a grass block's side, shows on top.
|
||||
private void drawQuad(ObjectMesh.Quad quad, Pass pass) {
|
||||
Face face = quad.face();
|
||||
if (!projection.facesCamera(face.normal().x(), face.normal().y(), face.normal().z())) {
|
||||
return;
|
||||
}
|
||||
Vec3 u = face.edgeU();
|
||||
Vec3 v = face.edgeV();
|
||||
double originX = projection.screenX(quad.originX(), quad.originY(), quad.originZ());
|
||||
double originY = projection.screenY(quad.originX(), quad.originY(), quad.originZ());
|
||||
double originDepth = projection.depth(quad.originX(), quad.originY(), quad.originZ());
|
||||
double edgeUX = projection.screenStepX(u.x(), u.z());
|
||||
double edgeUY = projection.screenStepY(u.x(), u.y(), u.z());
|
||||
double edgeUDepth = projection.depthStep(u.x(), u.y(), u.z());
|
||||
double edgeVX = projection.screenStepX(v.x(), v.z());
|
||||
double edgeVY = projection.screenStepY(v.x(), v.y(), v.z());
|
||||
double edgeVDepth = projection.depthStep(v.x(), v.y(), v.z());
|
||||
double determinant = edgeUX * edgeVY - edgeUY * edgeVX;
|
||||
if (Math.abs(determinant) < EDGE_ON_DETERMINANT) {
|
||||
return;
|
||||
}
|
||||
|
||||
int left = Math.max(0, (int) Math.floor(min4(originX, originX + edgeUX, originX + edgeVX, originX + edgeUX + edgeVX)));
|
||||
int right = Math.min(width - 1, (int) Math.ceil(max4(originX, originX + edgeUX, originX + edgeVX, originX + edgeUX + edgeVX)));
|
||||
int top = Math.max(0, (int) Math.floor(min4(originY, originY + edgeUY, originY + edgeVY, originY + edgeUY + edgeVY)));
|
||||
int bottom = Math.min(height - 1, (int) Math.ceil(max4(originY, originY + edgeUY, originY + edgeVY, originY + edgeUY + edgeVY)));
|
||||
if (left > right || top > bottom) {
|
||||
return;
|
||||
}
|
||||
|
||||
BlockTexture texture = face.texture();
|
||||
TextureMapping mapping = face.mapping();
|
||||
TexelRange columns = TexelRange.of(mapping.u(), mapping.uAlongU(), mapping.uAlongV(), texture.width());
|
||||
TexelRange rows = TexelRange.of(mapping.v(), mapping.vAlongU(), mapping.vAlongV(), texture.height());
|
||||
int[] texels = texture.pixels();
|
||||
boolean[] opaque = texture.opaque();
|
||||
int[] alphas = texture.alphas();
|
||||
double shade = face.shade();
|
||||
double alongUPerPixelX = edgeVY / determinant;
|
||||
double alongVPerPixelX = -edgeUY / determinant;
|
||||
for (int y = top; y <= bottom; y++) {
|
||||
double offsetX = left + 0.5D - originX;
|
||||
double offsetY = y + 0.5D - originY;
|
||||
double alongU = (offsetX * edgeVY - offsetY * edgeVX) / determinant;
|
||||
double alongV = (offsetY * edgeUX - offsetX * edgeUY) / determinant;
|
||||
int rowStart = y * width;
|
||||
for (int x = left; x <= right; x++, alongU += alongUPerPixelX, alongV += alongVPerPixelX) {
|
||||
if (alongU < -EDGE_TOLERANCE || alongU > 1D + EDGE_TOLERANCE || alongV < -EDGE_TOLERANCE || alongV > 1D + EDGE_TOLERANCE) {
|
||||
continue;
|
||||
}
|
||||
int index = rowStart + x;
|
||||
double depth = originDepth + alongU * edgeUDepth + alongV * edgeVDepth;
|
||||
if (depth < depths[index] || (pass == Pass.CUT_AWAY_GHOST && showsObject[index])) {
|
||||
continue;
|
||||
}
|
||||
int texelX = columns.texelAt(mapping.u() + alongU * mapping.uAlongU() + alongV * mapping.uAlongV());
|
||||
int texelY = rows.texelAt(mapping.v() + alongU * mapping.vAlongU() + alongV * mapping.vAlongV());
|
||||
int texel = texelY * texture.width() + texelX;
|
||||
if (!opaque[texel]) {
|
||||
continue;
|
||||
}
|
||||
int color = BlockPreviewColors.shade(texels[texel], shade);
|
||||
if (pass != Pass.SOLID) {
|
||||
pixels[index] = blend(color, pixels[index], (alphas == null ? 1D : alphas[texel] / FULL_ALPHA) * quad.opacity());
|
||||
continue;
|
||||
}
|
||||
depths[index] = depth;
|
||||
pixels[index] = color;
|
||||
showsObject[index] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static int blend(int over, int under, double coverage) {
|
||||
return blendChannel(over, under, coverage, 16) << 16 | blendChannel(over, under, coverage, 8) << 8 | blendChannel(over, under, coverage, 0);
|
||||
}
|
||||
|
||||
private static int blendChannel(int over, int under, double coverage, int shift) {
|
||||
return (int) Math.round(((over >>> shift) & 0xFF) * coverage + ((under >>> shift) & 0xFF) * (1D - coverage));
|
||||
}
|
||||
|
||||
private static double min4(double a, double b, double c, double d) {
|
||||
return Math.min(Math.min(a, b), Math.min(c, d));
|
||||
}
|
||||
|
||||
private static double max4(double a, double b, double c, double d) {
|
||||
return Math.max(Math.max(a, b), Math.max(c, d));
|
||||
}
|
||||
|
||||
/**
|
||||
* The texels a face covers along one texture axis, so a pixel on the face's edge never takes its neighbour's.
|
||||
*/
|
||||
private record TexelRange(int first, int last, int size) {
|
||||
static TexelRange of(double start, double alongU, double alongV, int size) {
|
||||
double low = start + Math.min(0D, alongU) + Math.min(0D, alongV);
|
||||
double high = start + Math.max(0D, alongU) + Math.max(0D, alongV);
|
||||
int first = clamp((int) Math.floor(low * size + TEXEL_EDGE_MARGIN), size);
|
||||
int last = Math.max(first, clamp((int) Math.ceil(high * size - TEXEL_EDGE_MARGIN) - 1, size));
|
||||
return new TexelRange(first, last, size);
|
||||
}
|
||||
|
||||
int texelAt(double fraction) {
|
||||
int texel = (int) Math.floor(fraction * size);
|
||||
return texel < first ? first : Math.min(texel, last);
|
||||
}
|
||||
|
||||
private static int clamp(int texel, int size) {
|
||||
return Math.max(0, Math.min(size - 1, texel));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns block positions into pixels. Larger depth is nearer the camera.
|
||||
*/
|
||||
private static final class Projection {
|
||||
private final double centerX;
|
||||
private final double centerY;
|
||||
private final double centerZ;
|
||||
private final double cosYaw;
|
||||
private final double sinYaw;
|
||||
private final double cosPitch;
|
||||
private final double sinPitch;
|
||||
private final double pixelsPerBlock;
|
||||
private final double screenCenterX;
|
||||
private final double screenCenterY;
|
||||
|
||||
private Projection(ObjectMesh.Bounds bounds, ObjectCamera camera, int width, int height, int coveredLeftWidth) {
|
||||
this.centerX = (bounds.minX() + bounds.maxX()) / 2D;
|
||||
this.centerY = (bounds.minY() + bounds.maxY()) / 2D;
|
||||
this.centerZ = (bounds.minZ() + bounds.maxZ()) / 2D;
|
||||
this.cosYaw = Math.cos(camera.yaw());
|
||||
this.sinYaw = Math.sin(camera.yaw());
|
||||
this.cosPitch = Math.cos(camera.pitch());
|
||||
this.sinPitch = Math.sin(camera.pitch());
|
||||
int visibleWidth = width - coveredLeftWidth;
|
||||
double fit = fitAtStartingAngle(bounds, visibleWidth / (double) height);
|
||||
this.pixelsPerBlock = camera.zoom() * fit * height / 2D / bounds.largestSize();
|
||||
this.screenCenterX = coveredLeftWidth + visibleWidth / 2D + camera.panX() * visibleWidth / 2D;
|
||||
this.screenCenterY = height / 2D - camera.panY() * height / 2D;
|
||||
}
|
||||
|
||||
// How far to scale the object down so it fits the view at the starting angle; orbiting keeps the scale.
|
||||
private static double fitAtStartingAngle(ObjectMesh.Bounds bounds, double aspect) {
|
||||
double size = bounds.largestSize();
|
||||
double width = bounds.sizeX() / size;
|
||||
double height = bounds.sizeY() / size;
|
||||
double depth = bounds.sizeZ() / size;
|
||||
double yaw = ObjectCamera.START.yaw();
|
||||
double pitch = ObjectCamera.START.pitch();
|
||||
double projectedWidth = Math.abs(Math.cos(yaw)) * width + Math.abs(Math.sin(yaw)) * depth;
|
||||
double projectedDepth = Math.abs(Math.sin(yaw)) * width + Math.abs(Math.cos(yaw)) * depth;
|
||||
double projectedHeight = Math.abs(Math.cos(pitch)) * height + Math.abs(Math.sin(pitch)) * projectedDepth;
|
||||
return Math.min(1D, Math.min(aspect / projectedWidth, 1D / projectedHeight));
|
||||
}
|
||||
|
||||
double screenX(double x, double y, double z) {
|
||||
return screenCenterX + screenStepX(x - centerX, z - centerZ);
|
||||
}
|
||||
|
||||
double screenY(double x, double y, double z) {
|
||||
return screenCenterY + screenStepY(x - centerX, y - centerY, z - centerZ);
|
||||
}
|
||||
|
||||
double depth(double x, double y, double z) {
|
||||
return depthStep(x - centerX, y - centerY, z - centerZ);
|
||||
}
|
||||
|
||||
double screenStepX(double x, double z) {
|
||||
return (cosYaw * x - sinYaw * z) * pixelsPerBlock;
|
||||
}
|
||||
|
||||
double screenStepY(double x, double y, double z) {
|
||||
double towardCamera = sinYaw * x + cosYaw * z;
|
||||
return -(cosPitch * y - sinPitch * towardCamera) * pixelsPerBlock;
|
||||
}
|
||||
|
||||
double depthStep(double x, double y, double z) {
|
||||
double towardCamera = sinYaw * x + cosYaw * z;
|
||||
return sinPitch * y + cosPitch * towardCamera;
|
||||
}
|
||||
|
||||
boolean facesCamera(double normalX, double normalY, double normalZ) {
|
||||
return depthStep(normalX, normalY, normalZ) > 0D;
|
||||
}
|
||||
}
|
||||
|
||||
private enum Pass {
|
||||
SOLID,
|
||||
CUT_AWAY_GHOST,
|
||||
BLENDED
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
import org.bukkit.block.data.BlockData;
|
||||
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* What an object is made of: its size and how many of each block type it uses, most used first.
|
||||
*/
|
||||
record ObjectSummary(String key, int sizeX, int sizeY, int sizeZ, int blockCount, List<BlockUse> blockUses) {
|
||||
static ObjectSummary of(String key, ObjectMesh.Bounds bounds, Iterable<BlockData> blocks, Function<BlockData, BufferedImage> iconOf) {
|
||||
Map<String, Integer> counts = new HashMap<>();
|
||||
Map<String, BlockData> examples = new HashMap<>();
|
||||
int blockCount = 0;
|
||||
for (BlockData block : blocks) {
|
||||
String blockType = block.getMaterial().getKey().getKey();
|
||||
counts.merge(blockType, 1, Integer::sum);
|
||||
examples.putIfAbsent(blockType, block);
|
||||
blockCount++;
|
||||
}
|
||||
List<BlockUse> uses = counts.entrySet().stream()
|
||||
.map(count -> new BlockUse(count.getKey(), count.getValue(), iconOf.apply(examples.get(count.getKey()))))
|
||||
.sorted(Comparator.comparingInt(BlockUse::count).reversed().thenComparing(BlockUse::blockType))
|
||||
.toList();
|
||||
return new ObjectSummary(key, bounds.sizeX(), bounds.sizeY(), bounds.sizeZ(), blockCount, uses);
|
||||
}
|
||||
|
||||
String name() {
|
||||
return key.substring(key.lastIndexOf('/') + 1);
|
||||
}
|
||||
|
||||
double filledFraction() {
|
||||
long volume = (long) sizeX * sizeY * sizeZ;
|
||||
return volume == 0 ? 0D : blockCount / (double) volume;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param icon the block's texture, or null when Minecraft's textures are not available
|
||||
*/
|
||||
record BlockUse(String blockType, int count, BufferedImage icon) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
import javax.swing.AbstractAction;
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.KeyStroke;
|
||||
import javax.swing.SwingUtilities;
|
||||
import java.awt.EventQueue;
|
||||
import java.awt.Graphics;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.Point;
|
||||
import java.awt.RenderingHints;
|
||||
import java.awt.event.ActionEvent;
|
||||
import java.awt.event.ComponentAdapter;
|
||||
import java.awt.event.ComponentEvent;
|
||||
import java.awt.event.KeyEvent;
|
||||
import java.awt.event.MouseAdapter;
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.awt.event.MouseWheelEvent;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.function.UnaryOperator;
|
||||
|
||||
/**
|
||||
* The 3D view of one object. Drag to orbit, shift-drag or middle-drag to pan, scroll to zoom; arrow keys orbit and
|
||||
* plus and minus zoom too. Frames are drawn on a background thread, one at a time, always for the latest camera.
|
||||
*/
|
||||
final class ObjectViewport extends JComponent {
|
||||
private static final long serialVersionUID = -3175226517409913524L;
|
||||
private static final double WHEEL_ZOOM_EXPONENT = 0.12D;
|
||||
private static final double KEY_ZOOM_FACTOR = 1.1D;
|
||||
private static final int KEY_ORBIT_PIXELS = 12;
|
||||
|
||||
private final ExecutorService renderThread = Executors.newSingleThreadExecutor(runnable -> {
|
||||
Thread thread = new Thread(runnable, "Iris Object Viewer");
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
});
|
||||
private ObjectMesh mesh;
|
||||
private ObjectCamera camera = ObjectCamera.START;
|
||||
private BufferedImage frame;
|
||||
private String message = "Choose an object";
|
||||
private boolean rendering;
|
||||
private boolean renderAgain;
|
||||
private Point dragPoint;
|
||||
private int coveredLeftWidth;
|
||||
|
||||
ObjectViewport() {
|
||||
setOpaque(true);
|
||||
setBackground(StudioTheme.EDITOR_BACKGROUND);
|
||||
setFocusable(true);
|
||||
installMouseControls();
|
||||
installKeyControls();
|
||||
addComponentListener(new ComponentAdapter() {
|
||||
@Override
|
||||
public void componentResized(ComponentEvent event) {
|
||||
requestFrame();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// The theme changed, and the last frame was drawn on the old theme's background.
|
||||
@Override
|
||||
public void updateUI() {
|
||||
super.updateUI();
|
||||
requestFrame();
|
||||
}
|
||||
|
||||
void show(ObjectMesh nextMesh) {
|
||||
mesh = nextMesh;
|
||||
camera = ObjectCamera.START;
|
||||
message = nextMesh.isEmpty() ? "No visible blocks" : null;
|
||||
frame = null;
|
||||
requestFrame();
|
||||
repaint();
|
||||
}
|
||||
|
||||
/**
|
||||
* Swaps in another mesh of the same object, like one with its top layers cut away, keeping the camera.
|
||||
*/
|
||||
void replaceMesh(ObjectMesh nextMesh) {
|
||||
mesh = nextMesh;
|
||||
message = nextMesh.isEmpty() ? "No visible blocks" : null;
|
||||
requestFrame();
|
||||
repaint();
|
||||
}
|
||||
|
||||
void bindKey(int keyCode, String name, Runnable action) {
|
||||
getInputMap(WHEN_FOCUSED).put(KeyStroke.getKeyStroke(keyCode, 0), name);
|
||||
getActionMap().put(name, new AbstractAction() {
|
||||
@Override
|
||||
public void actionPerformed(ActionEvent event) {
|
||||
action.run();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void showMessage(String text) {
|
||||
mesh = null;
|
||||
message = text;
|
||||
frame = null;
|
||||
repaint();
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the object clear of a panel floating over the viewport's left side.
|
||||
*/
|
||||
void setCoveredLeftWidth(int width) {
|
||||
if (width == coveredLeftWidth) {
|
||||
return;
|
||||
}
|
||||
coveredLeftWidth = width;
|
||||
requestFrame();
|
||||
}
|
||||
|
||||
void resetCamera() {
|
||||
moveCamera(current -> ObjectCamera.START);
|
||||
}
|
||||
|
||||
void close() {
|
||||
renderThread.shutdownNow();
|
||||
}
|
||||
|
||||
private void moveCamera(UnaryOperator<ObjectCamera> move) {
|
||||
camera = move.apply(camera);
|
||||
requestFrame();
|
||||
}
|
||||
|
||||
private void requestFrame() {
|
||||
if (mesh == null || mesh.isEmpty() || getWidth() < 1 || getHeight() < 1) {
|
||||
return;
|
||||
}
|
||||
if (rendering) {
|
||||
renderAgain = true;
|
||||
return;
|
||||
}
|
||||
rendering = true;
|
||||
ObjectMesh frameMesh = mesh;
|
||||
ObjectCamera frameCamera = camera;
|
||||
int width = getWidth();
|
||||
int height = getHeight();
|
||||
int covered = coveredLeftWidth;
|
||||
renderThread.execute(() -> {
|
||||
BufferedImage image = ObjectRenderer.render(frameMesh, frameCamera, width, height, covered);
|
||||
EventQueue.invokeLater(() -> finishFrame(frameMesh, image));
|
||||
});
|
||||
}
|
||||
|
||||
private void finishFrame(ObjectMesh frameMesh, BufferedImage image) {
|
||||
rendering = false;
|
||||
if (frameMesh == mesh) {
|
||||
frame = image;
|
||||
repaint();
|
||||
}
|
||||
if (renderAgain) {
|
||||
renderAgain = false;
|
||||
requestFrame();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void paintComponent(Graphics graphics) {
|
||||
graphics.setColor(getBackground());
|
||||
graphics.fillRect(0, 0, getWidth(), getHeight());
|
||||
if (frame != null) {
|
||||
graphics.drawImage(frame, 0, 0, null);
|
||||
}
|
||||
if (message != null) {
|
||||
drawMessage((Graphics2D) graphics);
|
||||
}
|
||||
}
|
||||
|
||||
private void drawMessage(Graphics2D canvas) {
|
||||
canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
|
||||
canvas.setFont(StudioTheme.font());
|
||||
canvas.setColor(StudioTheme.MUTED_TEXT);
|
||||
int textWidth = canvas.getFontMetrics().stringWidth(message);
|
||||
canvas.drawString(message, (getWidth() - textWidth) / 2, getHeight() / 2);
|
||||
}
|
||||
|
||||
private void installMouseControls() {
|
||||
MouseAdapter mouse = new MouseAdapter() {
|
||||
@Override
|
||||
public void mousePressed(MouseEvent event) {
|
||||
dragPoint = event.getPoint();
|
||||
requestFocusInWindow();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseReleased(MouseEvent event) {
|
||||
dragPoint = null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseDragged(MouseEvent event) {
|
||||
if (dragPoint == null) {
|
||||
return;
|
||||
}
|
||||
int draggedX = event.getX() - dragPoint.x;
|
||||
int draggedY = event.getY() - dragPoint.y;
|
||||
dragPoint = event.getPoint();
|
||||
boolean panning = event.isShiftDown() || SwingUtilities.isMiddleMouseButton(event);
|
||||
moveCamera(current -> panning
|
||||
? current.pannedBy(2D * draggedX / Math.max(1, getWidth() - coveredLeftWidth), -2D * draggedY / getHeight())
|
||||
: current.orbitedBy(draggedX, draggedY));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseWheelMoved(MouseWheelEvent event) {
|
||||
moveCamera(current -> current.zoomedBy(Math.exp(-event.getPreciseWheelRotation() * WHEEL_ZOOM_EXPONENT)));
|
||||
}
|
||||
};
|
||||
addMouseListener(mouse);
|
||||
addMouseMotionListener(mouse);
|
||||
addMouseWheelListener(mouse);
|
||||
}
|
||||
|
||||
private void installKeyControls() {
|
||||
bindKey(KeyEvent.VK_LEFT, "orbit-left", current -> current.orbitedBy(-KEY_ORBIT_PIXELS, 0));
|
||||
bindKey(KeyEvent.VK_RIGHT, "orbit-right", current -> current.orbitedBy(KEY_ORBIT_PIXELS, 0));
|
||||
bindKey(KeyEvent.VK_UP, "orbit-up", current -> current.orbitedBy(0, KEY_ORBIT_PIXELS));
|
||||
bindKey(KeyEvent.VK_DOWN, "orbit-down", current -> current.orbitedBy(0, -KEY_ORBIT_PIXELS));
|
||||
bindKey(KeyEvent.VK_EQUALS, "zoom-in", current -> current.zoomedBy(KEY_ZOOM_FACTOR));
|
||||
bindKey(KeyEvent.VK_ADD, "zoom-in-keypad", current -> current.zoomedBy(KEY_ZOOM_FACTOR));
|
||||
bindKey(KeyEvent.VK_MINUS, "zoom-out", current -> current.zoomedBy(1D / KEY_ZOOM_FACTOR));
|
||||
bindKey(KeyEvent.VK_SUBTRACT, "zoom-out-keypad", current -> current.zoomedBy(1D / KEY_ZOOM_FACTOR));
|
||||
}
|
||||
|
||||
private void bindKey(int keyCode, String name, UnaryOperator<ObjectCamera> move) {
|
||||
bindKey(keyCode, name, () -> moveCamera(move));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import com.google.gson.stream.JsonReader;
|
||||
import com.volmit.iris.core.loader.IrisData;
|
||||
import com.volmit.iris.engine.framework.Engine;
|
||||
import com.volmit.iris.engine.object.IrisBiome;
|
||||
import com.volmit.iris.engine.object.IrisBlockData;
|
||||
import com.volmit.iris.engine.object.IrisRegion;
|
||||
import com.volmit.iris.util.collection.KList;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileReader;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
record PaletteCatalog(List<RegionEntry> regions, List<String> biomesOutsideRegions, Map<String, BiomeEntry> biomes,
|
||||
List<SnippetEntry> snippets) {
|
||||
private static final int SUMMARIZED_BLOCK_COUNT = 3;
|
||||
|
||||
static PaletteCatalog load(Engine engine) {
|
||||
IrisData data = engine.getData();
|
||||
Map<String, BiomeEntry> biomes = loadBiomes(data);
|
||||
List<RegionEntry> regions = new ArrayList<>();
|
||||
Set<String> biomesInRegions = new HashSet<>();
|
||||
for (String regionKey : regionKeysInUse(engine)) {
|
||||
IrisRegion region = data.getRegionLoader().load(regionKey);
|
||||
if (region == null) {
|
||||
continue;
|
||||
}
|
||||
RegionEntry entry = describeRegion(regionKey, region, biomes);
|
||||
regions.add(entry);
|
||||
entry.groups().forEach(group -> group.biomeKeys().forEach(key -> collectWithChildren(key, biomes, biomesInRegions)));
|
||||
}
|
||||
List<String> outside = biomes.keySet().stream().filter(key -> !biomesInRegions.contains(key)).toList();
|
||||
return new PaletteCatalog(List.copyOf(regions), outside, Map.copyOf(biomes), loadSnippets(data, biomes));
|
||||
}
|
||||
|
||||
// Snippet files no biome refers to are listed too, so a new snippet shows up before it is put to use.
|
||||
private static List<SnippetEntry> loadSnippets(IrisData data, Map<String, BiomeEntry> biomes) {
|
||||
Map<String, SnippetEntry> snippets = new LinkedHashMap<>();
|
||||
for (BiomeEntry biome : biomes.values()) {
|
||||
for (PaletteSummary summary : biome.palettes()) {
|
||||
if (summary.snippet() == null) {
|
||||
continue;
|
||||
}
|
||||
BiomePalette.SnippetFolder folder = summary.palette().kind().snippetFolder();
|
||||
snippets.computeIfAbsent(folder.folder() + "/" + summary.snippet(),
|
||||
key -> new SnippetEntry(folder, summary.snippet(), summary.blocks(), new ArrayList<>()))
|
||||
.uses().add(summary.palette());
|
||||
}
|
||||
}
|
||||
for (BiomePalette.SnippetFolder folder : BiomePalette.SnippetFolder.values()) {
|
||||
for (String reference : data.getPossibleSnippets(folder.folder())) {
|
||||
String path = folder.pathOf(reference);
|
||||
snippets.putIfAbsent(folder.folder() + "/" + path, new SnippetEntry(folder, path, null, List.of()));
|
||||
}
|
||||
}
|
||||
return snippets.values().stream()
|
||||
.map(snippet -> new SnippetEntry(snippet.folder(), snippet.path(), snippet.blocks(), List.copyOf(snippet.uses())))
|
||||
.sorted(Comparator.comparing(SnippetEntry::folder).thenComparing(SnippetEntry::path))
|
||||
.toList();
|
||||
}
|
||||
|
||||
// A studio focus region is generated even when the dimension's region list doesn't include it.
|
||||
private static Set<String> regionKeysInUse(Engine engine) {
|
||||
Set<String> keys = new LinkedHashSet<>();
|
||||
if (engine.getDimension().hasFocusRegion()) {
|
||||
keys.add(engine.getDimension().getFocusRegion());
|
||||
}
|
||||
keys.addAll(engine.getDimension().getRegions());
|
||||
return keys;
|
||||
}
|
||||
|
||||
BiomeEntry biome(String key) {
|
||||
return key == null ? null : biomes.get(key);
|
||||
}
|
||||
|
||||
RegionEntry region(String key) {
|
||||
return regions.stream().filter(region -> region.key().equals(key)).findFirst().orElse(null);
|
||||
}
|
||||
|
||||
static String summarizeBlocks(KList<IrisBlockData> blocks) {
|
||||
List<String> names = blocks.stream()
|
||||
.map(block -> block.getBlock().replace("minecraft:", "").toLowerCase(Locale.ROOT))
|
||||
.distinct()
|
||||
.toList();
|
||||
if (names.size() <= SUMMARIZED_BLOCK_COUNT) {
|
||||
return String.join(", ", names);
|
||||
}
|
||||
return String.join(", ", names.subList(0, SUMMARIZED_BLOCK_COUNT))
|
||||
+ " +" + (names.size() - SUMMARIZED_BLOCK_COUNT) + " more";
|
||||
}
|
||||
|
||||
private static Map<String, BiomeEntry> loadBiomes(IrisData data) {
|
||||
String[] keys = data.getBiomeLoader().getPossibleKeys();
|
||||
Map<String, IrisBiome> loaded = new LinkedHashMap<>();
|
||||
if (keys == null) {
|
||||
return Map.of();
|
||||
}
|
||||
Arrays.sort(keys);
|
||||
for (String key : keys) {
|
||||
IrisBiome biome = data.getBiomeLoader().load(key);
|
||||
if (biome != null) {
|
||||
loaded.put(key, biome);
|
||||
}
|
||||
}
|
||||
Map<String, List<String>> parentsByChild = parentsByChild(loaded);
|
||||
Map<String, BiomeEntry> biomes = new LinkedHashMap<>();
|
||||
loaded.forEach((key, biome) -> biomes.put(key, new BiomeEntry(
|
||||
key,
|
||||
biome.getName(),
|
||||
summarizePalettes(key, biome),
|
||||
biome.getChildren().stream().filter(loaded::containsKey).distinct().toList(),
|
||||
parentsByChild.getOrDefault(key, List.of())
|
||||
)));
|
||||
return biomes;
|
||||
}
|
||||
|
||||
private static Map<String, List<String>> parentsByChild(Map<String, IrisBiome> biomes) {
|
||||
Map<String, List<String>> parents = new LinkedHashMap<>();
|
||||
biomes.forEach((parentKey, parent) -> parent.getChildren().stream().distinct().forEach(childKey ->
|
||||
parents.computeIfAbsent(childKey, key -> new ArrayList<>()).add(parentKey)));
|
||||
return parents;
|
||||
}
|
||||
|
||||
static List<PaletteSummary> summarizePalettes(String key, IrisBiome biome) {
|
||||
JsonObject biomeSource = readSource(biome.getLoadFile());
|
||||
return BiomePalette.allOf(key, biome).stream()
|
||||
.map(palette -> new PaletteSummary(palette, snippetName(biomeSource, palette),
|
||||
summarizeBlocks(palette.blocksIn(biome))))
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Snippets are copied into the biome when it loads, so only its file still tells which palettes came from one.
|
||||
private static String snippetName(JsonObject biomeSource, BiomePalette palette) {
|
||||
JsonElement element = biomeSource == null ? null : biomeSource.get(palette.kind().jsonKey());
|
||||
if (element instanceof JsonArray list) {
|
||||
element = palette.index() < list.size() ? list.get(palette.index()) : null;
|
||||
}
|
||||
if (element == null || !element.isJsonPrimitive()) {
|
||||
return null;
|
||||
}
|
||||
return palette.kind().snippetFolder().pathOf(element.getAsString());
|
||||
}
|
||||
|
||||
private static JsonObject readSource(File file) {
|
||||
if (file == null || !file.isFile()) {
|
||||
return null;
|
||||
}
|
||||
try (JsonReader reader = new JsonReader(new FileReader(file))) {
|
||||
reader.setLenient(true);
|
||||
JsonElement source = JsonParser.parseReader(reader);
|
||||
return source.isJsonObject() ? source.getAsJsonObject() : null;
|
||||
} catch (IOException | RuntimeException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static RegionEntry describeRegion(String key, IrisRegion region, Map<String, BiomeEntry> biomes) {
|
||||
List<BiomeGroup> groups = new ArrayList<>();
|
||||
addGroup(groups, "Land", region.getLandBiomes(), biomes);
|
||||
addGroup(groups, "Shore", region.getShoreBiomes(), biomes);
|
||||
addGroup(groups, "Sea", region.getSeaBiomes(), biomes);
|
||||
addGroup(groups, "Cave", region.getCaveBiomes(), biomes);
|
||||
return new RegionEntry(key, region.getName(), List.copyOf(groups));
|
||||
}
|
||||
|
||||
private static void addGroup(List<BiomeGroup> groups, String label, List<String> keys, Map<String, BiomeEntry> biomes) {
|
||||
List<String> known = keys.stream().filter(biomes::containsKey).distinct().toList();
|
||||
if (!known.isEmpty()) {
|
||||
groups.add(new BiomeGroup(label, known));
|
||||
}
|
||||
}
|
||||
|
||||
private static void collectWithChildren(String key, Map<String, BiomeEntry> biomes, Set<String> collected) {
|
||||
BiomeEntry biome = biomes.get(key);
|
||||
if (biome == null || !collected.add(key)) {
|
||||
return;
|
||||
}
|
||||
biome.childKeys().forEach(child -> collectWithChildren(child, biomes, collected));
|
||||
}
|
||||
|
||||
record RegionEntry(String key, String name, List<BiomeGroup> groups) {
|
||||
List<String> allBiomeKeys() {
|
||||
return groups.stream().flatMap(group -> group.biomeKeys().stream()).distinct().toList();
|
||||
}
|
||||
}
|
||||
|
||||
record BiomeGroup(String label, List<String> biomeKeys) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param parentKeys the biomes that list this one as a child, which it can appear inside of
|
||||
*/
|
||||
record BiomeEntry(String key, String name, List<PaletteSummary> palettes, List<String> childKeys,
|
||||
List<String> parentKeys) {
|
||||
boolean matches(String filter) {
|
||||
if (key.toLowerCase().contains(filter) || name.toLowerCase().contains(filter)) {
|
||||
return true;
|
||||
}
|
||||
return palettes.stream().anyMatch(summary -> summary.text().toLowerCase().contains(filter));
|
||||
}
|
||||
|
||||
BiomePalette firstPalette() {
|
||||
return palettes.isEmpty() ? null : palettes.get(0).palette();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A snippet file palettes can come from.
|
||||
*
|
||||
* @param blocks a summary of its blocks, or null when no biome uses it
|
||||
* @param uses the palettes made from it, in biome order
|
||||
*/
|
||||
record SnippetEntry(BiomePalette.SnippetFolder folder, String path, String blocks, List<BiomePalette> uses) {
|
||||
boolean matches(String filter) {
|
||||
return path.toLowerCase(Locale.ROOT).contains(filter)
|
||||
|| (blocks != null && blocks.toLowerCase(Locale.ROOT).contains(filter));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param snippet the path of the snippet file the palette comes from, or null when the biome defines it itself
|
||||
*/
|
||||
record PaletteSummary(BiomePalette palette, String snippet, String blocks) {
|
||||
String text() {
|
||||
return snippet == null
|
||||
? palette.label() + " · " + blocks
|
||||
: palette.label() + " · " + snippet + " snippet · " + blocks;
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-7
@@ -16,13 +16,11 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.volmit.iris.engine.mantle;
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
import com.volmit.iris.util.mantle.MantleFlag;
|
||||
import lombok.Data;
|
||||
import com.volmit.iris.engine.framework.Engine;
|
||||
|
||||
@Data
|
||||
public abstract class IrisMantleComponent implements MantleComponent {
|
||||
private final EngineMantle engineMantle;
|
||||
private final MantleFlag flag;
|
||||
import java.util.UUID;
|
||||
|
||||
public record PaletteExplorerStart(Engine engine, UUID worldId, UUID playerId, String biomeKey, double centerX, double centerZ) {
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.ButtonGroup;
|
||||
import javax.swing.JButton;
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JToggleButton;
|
||||
import javax.swing.SwingConstants;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Color;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.FontMetrics;
|
||||
import java.awt.Graphics;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.GridLayout;
|
||||
import java.awt.RenderingHints;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
|
||||
final class PaletteInspectorPanel extends JPanel {
|
||||
private static final long serialVersionUID = 4473102938518712001L;
|
||||
private static final int PANEL_WIDTH = 310;
|
||||
private static final Color HISTOGRAM_BAR = new Color(0x9D, 0xA0, 0xA8, 150);
|
||||
private static final Color BAND_DIVIDER = new Color(255, 255, 255, 70);
|
||||
|
||||
private final Listener listener;
|
||||
private final JLabel titleLabel = new JLabel();
|
||||
private final JLabel detailLabel = new JLabel();
|
||||
private final JLabel depthLabel = new JLabel("", SwingConstants.CENTER);
|
||||
private final JButton shallowerButton = new JButton("‹");
|
||||
private final JButton deeperButton = new JButton("›");
|
||||
private final JPanel depthRow = new JPanel(new BorderLayout(StudioTheme.scale(6), 0));
|
||||
private final StatisticsView statisticsView = new StatisticsView();
|
||||
private PaletteLayerScale.View selectedView = PaletteLayerScale.View.BLOCKS;
|
||||
private int depth;
|
||||
private int deepestDepth;
|
||||
|
||||
PaletteInspectorPanel(Listener listener) {
|
||||
super(new BorderLayout());
|
||||
this.listener = listener;
|
||||
setPreferredSize(StudioTheme.scaledSize(PANEL_WIDTH, 0));
|
||||
setBorder(BorderFactory.createMatteBorder(0, 1, 0, 0, StudioTheme.SEPARATOR));
|
||||
add(buildControls(), BorderLayout.NORTH);
|
||||
add(StudioToolbar.scroll(statisticsView), BorderLayout.CENTER);
|
||||
setVisible(false);
|
||||
}
|
||||
|
||||
void showPreview(PaletteLayerPreview preview) {
|
||||
titleLabel.setText(preview.biomeKey() + " · " + preview.palette().lowercaseLabel());
|
||||
detailLabel.setText(placementText(preview));
|
||||
depth = preview.depth();
|
||||
deepestDepth = Math.max(0, preview.maxThickness() - 1);
|
||||
refreshDepthControls();
|
||||
statisticsView.show(preview);
|
||||
setVisible(true);
|
||||
}
|
||||
|
||||
void showTextureStatus(String status) {
|
||||
statisticsView.showTextureStatus(status);
|
||||
}
|
||||
|
||||
void hideInspector() {
|
||||
setVisible(false);
|
||||
}
|
||||
|
||||
PaletteLayerScale.View selectedView() {
|
||||
return selectedView;
|
||||
}
|
||||
|
||||
private JPanel buildControls() {
|
||||
JPanel controls = new JPanel(new GridLayout(0, 1, 0, StudioTheme.scale(6)));
|
||||
controls.setBorder(BorderFactory.createCompoundBorder(
|
||||
BorderFactory.createMatteBorder(0, 0, 1, 0, StudioTheme.SEPARATOR),
|
||||
StudioTheme.emptyBorder(12, 14, 12, 14)
|
||||
));
|
||||
titleLabel.setFont(StudioTheme.titleFont());
|
||||
detailLabel.setForeground(StudioTheme.SECONDARY_TEXT);
|
||||
detailLabel.setFont(StudioTheme.smallFont());
|
||||
controls.add(titleLabel);
|
||||
controls.add(detailLabel);
|
||||
|
||||
controls.add(sectionLabel("Show"));
|
||||
controls.add(buildViewButtons());
|
||||
controls.add(sectionLabel("Depth"));
|
||||
controls.add(buildDepthRow());
|
||||
return controls;
|
||||
}
|
||||
|
||||
private JPanel buildViewButtons() {
|
||||
JPanel row = new JPanel(new GridLayout(1, 0, StudioTheme.scale(4), 0));
|
||||
row.setOpaque(false);
|
||||
ButtonGroup group = new ButtonGroup();
|
||||
for (PaletteLayerScale.View view : PaletteLayerScale.View.values()) {
|
||||
JToggleButton button = new JToggleButton(view.toString());
|
||||
button.setFocusable(false);
|
||||
button.setSelected(view == selectedView);
|
||||
button.addActionListener(event -> {
|
||||
selectedView = view;
|
||||
listener.onViewChanged(view);
|
||||
});
|
||||
group.add(button);
|
||||
row.add(button);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
private JPanel buildDepthRow() {
|
||||
depthRow.setOpaque(false);
|
||||
shallowerButton.setToolTipText("Show the block one higher up");
|
||||
deeperButton.setToolTipText("Show the block one further down");
|
||||
shallowerButton.addActionListener(event -> changeDepth(-1));
|
||||
deeperButton.addActionListener(event -> changeDepth(1));
|
||||
depthRow.add(shallowerButton, BorderLayout.WEST);
|
||||
depthRow.add(depthLabel, BorderLayout.CENTER);
|
||||
depthRow.add(deeperButton, BorderLayout.EAST);
|
||||
return depthRow;
|
||||
}
|
||||
|
||||
private void changeDepth(int step) {
|
||||
int nextDepth = Math.clamp(depth + step, 0, deepestDepth);
|
||||
if (nextDepth == depth) {
|
||||
return;
|
||||
}
|
||||
depth = nextDepth;
|
||||
refreshDepthControls();
|
||||
listener.onDepthChanged(depth);
|
||||
}
|
||||
|
||||
private void refreshDepthControls() {
|
||||
depthLabel.setText(depthText(depth, deepestDepth));
|
||||
shallowerButton.setEnabled(depth > 0);
|
||||
deeperButton.setEnabled(depth < deepestDepth);
|
||||
}
|
||||
|
||||
private static JLabel sectionLabel(String text) {
|
||||
JLabel label = new JLabel(text.toUpperCase(Locale.ROOT));
|
||||
label.setForeground(StudioTheme.MUTED_TEXT);
|
||||
label.setFont(StudioTheme.sectionFont());
|
||||
return label;
|
||||
}
|
||||
|
||||
// Stacked layers divide their coordinates by the zoom twice before sampling; wall and slab blocks only once.
|
||||
private static String placementText(PaletteLayerPreview preview) {
|
||||
if (!preview.palette().kind().isStacked()) {
|
||||
return "Single blocks · zoom " + formatZoom(preview.zoom());
|
||||
}
|
||||
return thicknessText(preview) + " · zoom " + formatZoom(preview.zoom())
|
||||
+ " (×" + formatZoom(preview.zoom() * preview.zoom()) + " effective)";
|
||||
}
|
||||
|
||||
private static String thicknessText(PaletteLayerPreview preview) {
|
||||
if (preview.minThickness() == preview.maxThickness()) {
|
||||
return preview.maxThickness() + (preview.maxThickness() == 1 ? " block thick" : " blocks thick");
|
||||
}
|
||||
return preview.minThickness() + "–" + preview.maxThickness() + " blocks thick";
|
||||
}
|
||||
|
||||
private static String depthText(int depth, int deepestDepth) {
|
||||
if (deepestDepth == 0) {
|
||||
return "Top block only";
|
||||
}
|
||||
return depth == 0 ? "Top block" : depth + (depth == 1 ? " block down" : " blocks down");
|
||||
}
|
||||
|
||||
private static String formatZoom(double zoom) {
|
||||
return zoom == Math.rint(zoom) ? Long.toString((long) zoom) : String.format(Locale.ROOT, "%.2f", zoom);
|
||||
}
|
||||
|
||||
private static String percent(double share) {
|
||||
if (share > 0D && share < 0.001D) {
|
||||
return "<0.1%";
|
||||
}
|
||||
return String.format(Locale.ROOT, "%.1f%%", share * 100D);
|
||||
}
|
||||
|
||||
interface Listener {
|
||||
void onViewChanged(PaletteLayerScale.View view);
|
||||
|
||||
void onDepthChanged(int depth);
|
||||
}
|
||||
|
||||
private static final class StatisticsView extends JComponent {
|
||||
private static final long serialVersionUID = -2318840125766150871L;
|
||||
private static final int MARGIN = 14;
|
||||
private static final int HISTOGRAM_HEIGHT = 84;
|
||||
private static final int BAND_HEIGHT = 12;
|
||||
private static final int SECTION_GAP = 18;
|
||||
private static final int TITLE_GAP = 6;
|
||||
private static final int NOTE_GAP = 4;
|
||||
private static final String WIDEST_WEIGHT = "100.0%";
|
||||
private static final String WIDEST_ACTUAL = "▲ 100.0%";
|
||||
|
||||
private PaletteLayerPreview preview;
|
||||
private String textureStatus;
|
||||
|
||||
private StatisticsView() {
|
||||
setOpaque(true);
|
||||
setBackground(StudioTheme.PANEL_BACKGROUND);
|
||||
}
|
||||
|
||||
private void show(PaletteLayerPreview nextPreview) {
|
||||
preview = nextPreview;
|
||||
revalidate();
|
||||
repaint();
|
||||
}
|
||||
|
||||
private void showTextureStatus(String status) {
|
||||
textureStatus = status;
|
||||
revalidate();
|
||||
repaint();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Dimension getPreferredSize() {
|
||||
int preferredWidth = StudioTheme.scale(PANEL_WIDTH - 20);
|
||||
if (preview == null) {
|
||||
return new Dimension(preferredWidth, 0);
|
||||
}
|
||||
int margin = StudioTheme.scale(MARGIN);
|
||||
int width = Math.max(getWidth(), preferredWidth) - margin * 2;
|
||||
int height = margin + valueSpreadHeight() + StudioTheme.scale(SECTION_GAP) + blockTableHeight()
|
||||
+ StudioTheme.scale(SECTION_GAP) + notesHeight(width) + margin;
|
||||
return new Dimension(preferredWidth, height);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void paintComponent(Graphics graphics) {
|
||||
Graphics2D canvas = (Graphics2D) graphics.create();
|
||||
try {
|
||||
canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
|
||||
canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
canvas.setColor(StudioTheme.PANEL_BACKGROUND);
|
||||
canvas.fillRect(0, 0, getWidth(), getHeight());
|
||||
if (preview == null) {
|
||||
return;
|
||||
}
|
||||
int margin = StudioTheme.scale(MARGIN);
|
||||
int width = getWidth() - margin * 2;
|
||||
int top = margin;
|
||||
drawValueSpread(canvas, margin, top, width);
|
||||
top += valueSpreadHeight() + StudioTheme.scale(SECTION_GAP);
|
||||
drawBlockTable(canvas, margin, top, width);
|
||||
top += blockTableHeight() + StudioTheme.scale(SECTION_GAP);
|
||||
drawNotes(canvas, margin, top, width);
|
||||
} finally {
|
||||
canvas.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private int valueSpreadHeight() {
|
||||
return sectionMetrics().getHeight() + StudioTheme.scale(TITLE_GAP) + StudioTheme.scale(HISTOGRAM_HEIGHT)
|
||||
+ StudioTheme.scale(2) + StudioTheme.scale(BAND_HEIGHT) + StudioTheme.scale(3) + smallMetrics().getHeight();
|
||||
}
|
||||
|
||||
private int blockTableHeight() {
|
||||
return sectionMetrics().getHeight() + StudioTheme.scale(TITLE_GAP) + rowHeight() * preview.entries().size();
|
||||
}
|
||||
|
||||
private int notesHeight(int width) {
|
||||
FontMetrics metrics = smallMetrics();
|
||||
int height = 0;
|
||||
for (String note : notes()) {
|
||||
height += wrap(note, metrics, width).size() * metrics.getHeight() + StudioTheme.scale(NOTE_GAP);
|
||||
}
|
||||
return height;
|
||||
}
|
||||
|
||||
private int rowHeight() {
|
||||
return textMetrics().getHeight() + StudioTheme.scale(4);
|
||||
}
|
||||
|
||||
private FontMetrics textMetrics() {
|
||||
return getFontMetrics(StudioTheme.font());
|
||||
}
|
||||
|
||||
private FontMetrics smallMetrics() {
|
||||
return getFontMetrics(StudioTheme.smallFont());
|
||||
}
|
||||
|
||||
private FontMetrics sectionMetrics() {
|
||||
return getFontMetrics(StudioTheme.sectionFont());
|
||||
}
|
||||
|
||||
private void drawValueSpread(Graphics2D canvas, int x, int top, int width) {
|
||||
drawSectionTitle(canvas, "Noise values this layer produces", x, top + sectionMetrics().getAscent());
|
||||
int histogramTop = top + sectionMetrics().getHeight() + StudioTheme.scale(TITLE_GAP);
|
||||
int histogramHeight = StudioTheme.scale(HISTOGRAM_HEIGHT);
|
||||
int bandHeight = StudioTheme.scale(BAND_HEIGHT);
|
||||
long[] histogram = preview.histogram();
|
||||
long peak = 1L;
|
||||
for (long count : histogram) {
|
||||
peak = Math.max(peak, count);
|
||||
}
|
||||
canvas.setColor(HISTOGRAM_BAR);
|
||||
for (int bin = 0; bin < histogram.length; bin++) {
|
||||
int barLeft = x + (bin * width) / histogram.length;
|
||||
int barRight = x + ((bin + 1) * width) / histogram.length;
|
||||
int barHeight = (int) Math.round(histogramHeight * (histogram[bin] / (double) peak));
|
||||
canvas.fillRect(barLeft, histogramTop + histogramHeight - barHeight, Math.max(1, barRight - barLeft - 1), barHeight);
|
||||
}
|
||||
|
||||
int bandTop = histogramTop + histogramHeight + StudioTheme.scale(2);
|
||||
for (PaletteLayerPreview.Entry entry : preview.entries()) {
|
||||
if (Double.isNaN(entry.lowestNoise())) {
|
||||
continue;
|
||||
}
|
||||
int bandLeft = x + (int) Math.round(entry.lowestNoise() * width);
|
||||
int bandRight = x + (int) Math.round(entry.highestNoise() * width);
|
||||
canvas.setColor(new Color(entry.color()));
|
||||
canvas.fillRect(bandLeft, bandTop, Math.max(1, bandRight - bandLeft), bandHeight);
|
||||
if (entry.highestNoise() < 1D) {
|
||||
canvas.setColor(BAND_DIVIDER);
|
||||
canvas.drawLine(bandRight, histogramTop, bandRight, bandTop + bandHeight - 1);
|
||||
}
|
||||
}
|
||||
|
||||
canvas.setFont(StudioTheme.smallFont());
|
||||
canvas.setColor(StudioTheme.SECONDARY_TEXT);
|
||||
FontMetrics metrics = canvas.getFontMetrics();
|
||||
int labelY = bandTop + bandHeight + StudioTheme.scale(3) + metrics.getAscent();
|
||||
canvas.drawString("0", x, labelY);
|
||||
canvas.drawString("0.5", x + (width - metrics.stringWidth("0.5")) / 2, labelY);
|
||||
canvas.drawString("1", x + width - metrics.stringWidth("1"), labelY);
|
||||
}
|
||||
|
||||
private void drawBlockTable(Graphics2D canvas, int x, int top, int width) {
|
||||
int columnGap = StudioTheme.scale(10);
|
||||
int actualRight = x + width;
|
||||
int weightRight = actualRight - textMetrics().stringWidth(WIDEST_ACTUAL) - columnGap;
|
||||
int nameRight = weightRight - textMetrics().stringWidth(WIDEST_WEIGHT) - columnGap;
|
||||
int titleBaseline = top + sectionMetrics().getAscent();
|
||||
drawSectionTitle(canvas, "Blocks", x, titleBaseline);
|
||||
drawRightAligned(canvas, "weight", weightRight, titleBaseline);
|
||||
drawRightAligned(canvas, "actual", actualRight, titleBaseline);
|
||||
|
||||
int rowHeight = rowHeight();
|
||||
FontMetrics text = textMetrics();
|
||||
int swatchSize = text.getAscent() - text.getDescent();
|
||||
int rowTop = top + sectionMetrics().getHeight() + StudioTheme.scale(TITLE_GAP);
|
||||
for (PaletteLayerPreview.Entry entry : preview.entries()) {
|
||||
int baseline = rowTop + (rowHeight + text.getAscent() - text.getDescent()) / 2;
|
||||
drawSwatch(canvas, entry, x, rowTop + (rowHeight - swatchSize) / 2, swatchSize);
|
||||
canvas.setFont(StudioTheme.font());
|
||||
canvas.setColor(entry.found() ? StudioTheme.TEXT : StudioTheme.ERROR);
|
||||
String name = entry.found() ? shortBlockName(entry.block()) : shortBlockName(entry.block()) + " (not found)";
|
||||
int nameLeft = x + swatchSize + StudioTheme.scale(7);
|
||||
canvas.drawString(fit(canvas, name, nameRight - nameLeft), nameLeft, baseline);
|
||||
canvas.setColor(StudioTheme.SECONDARY_TEXT);
|
||||
drawRightAligned(canvas, percent(entry.weightShare()), weightRight, baseline);
|
||||
canvas.setColor(actualColor(entry));
|
||||
drawRightAligned(canvas, actualText(entry), actualRight, baseline);
|
||||
rowTop += rowHeight;
|
||||
}
|
||||
}
|
||||
|
||||
private static void drawSwatch(Graphics2D canvas, PaletteLayerPreview.Entry entry, int x, int y, int size) {
|
||||
if (entry.texture() != null) {
|
||||
canvas.drawImage(entry.texture().toImage(), x, y, size, size, null);
|
||||
return;
|
||||
}
|
||||
canvas.setColor(new Color(entry.color()));
|
||||
int arc = StudioTheme.scale(3);
|
||||
canvas.fillRoundRect(x, y, size, size, arc, arc);
|
||||
}
|
||||
|
||||
private void drawNotes(Graphics2D canvas, int x, int top, int width) {
|
||||
canvas.setFont(StudioTheme.smallFont());
|
||||
canvas.setColor(StudioTheme.SECONDARY_TEXT);
|
||||
FontMetrics metrics = canvas.getFontMetrics();
|
||||
int lineY = top + metrics.getAscent();
|
||||
for (String note : notes()) {
|
||||
for (String line : wrap(note, metrics, width)) {
|
||||
canvas.drawString(line, x, lineY);
|
||||
lineY += metrics.getHeight();
|
||||
}
|
||||
lineY += StudioTheme.scale(NOTE_GAP);
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> notes() {
|
||||
List<String> notes = new ArrayList<>();
|
||||
if (textureStatus != null) {
|
||||
notes.add(textureStatus);
|
||||
}
|
||||
notes.add("▼ gets under half its weight, ▲ gets over double.");
|
||||
notes.add(String.format(Locale.ROOT, "Measured on a %d × %d grid spread over %,.0f blocks around 0, 0.",
|
||||
PaletteLayerPreview.STATISTICS_GRID_SIZE, PaletteLayerPreview.STATISTICS_GRID_SIZE,
|
||||
PaletteLayerPreview.statisticsAreaBlocks()));
|
||||
if (preview.absentShare() > 0D) {
|
||||
notes.add(absentText(preview) + " That ground is dark on the map and left out of the shares.");
|
||||
}
|
||||
if (preview.outOfRangeShare() > 0D) {
|
||||
notes.add(percent(preview.outOfRangeShare()) + " of the noise values fall outside 0 to 1."
|
||||
+ " Those spots get the first block.");
|
||||
}
|
||||
if (preview.simplification() != null) {
|
||||
notes.add(preview.simplification());
|
||||
}
|
||||
if (preview.hasSlopeCondition()) {
|
||||
notes.add("This " + paletteNoun(preview) + " has a slope condition, which the preview ignores.");
|
||||
}
|
||||
return notes;
|
||||
}
|
||||
|
||||
private static String absentText(PaletteLayerPreview preview) {
|
||||
if (isDecorator(preview)) {
|
||||
return "The decorator leaves " + percent(preview.absentShare()) + " of the ground bare.";
|
||||
}
|
||||
return "The layer doesn't reach this depth on " + percent(preview.absentShare()) + " of the ground.";
|
||||
}
|
||||
|
||||
private static String paletteNoun(PaletteLayerPreview preview) {
|
||||
return isDecorator(preview) ? "decorator" : "layer";
|
||||
}
|
||||
|
||||
private static boolean isDecorator(PaletteLayerPreview preview) {
|
||||
return preview.palette().kind() == BiomePalette.Kind.DECORATOR;
|
||||
}
|
||||
|
||||
private static String actualText(PaletteLayerPreview.Entry entry) {
|
||||
if (entry.isFarBelowWeight()) {
|
||||
return "▼ " + percent(entry.actualShare());
|
||||
}
|
||||
if (entry.isFarAboveWeight()) {
|
||||
return "▲ " + percent(entry.actualShare());
|
||||
}
|
||||
return percent(entry.actualShare());
|
||||
}
|
||||
|
||||
private static Color actualColor(PaletteLayerPreview.Entry entry) {
|
||||
return entry.isFarBelowWeight() || entry.isFarAboveWeight() ? StudioTheme.WARNING : StudioTheme.TEXT;
|
||||
}
|
||||
|
||||
private static void drawSectionTitle(Graphics2D canvas, String title, int x, int y) {
|
||||
canvas.setFont(StudioTheme.sectionFont());
|
||||
canvas.setColor(StudioTheme.MUTED_TEXT);
|
||||
canvas.drawString(title.toUpperCase(Locale.ROOT), x, y);
|
||||
}
|
||||
|
||||
private static void drawRightAligned(Graphics2D canvas, String text, int right, int y) {
|
||||
canvas.drawString(text, right - canvas.getFontMetrics().stringWidth(text), y);
|
||||
}
|
||||
|
||||
private static String shortBlockName(String block) {
|
||||
return block.startsWith("minecraft:") ? block.substring("minecraft:".length()) : block;
|
||||
}
|
||||
|
||||
private static String fit(Graphics2D canvas, String text, int maxWidth) {
|
||||
FontMetrics metrics = canvas.getFontMetrics();
|
||||
if (metrics.stringWidth(text) <= maxWidth) {
|
||||
return text;
|
||||
}
|
||||
String shortened = text;
|
||||
while (shortened.length() > 1 && metrics.stringWidth(shortened + "…") > maxWidth) {
|
||||
shortened = shortened.substring(0, shortened.length() - 1);
|
||||
}
|
||||
return shortened + "…";
|
||||
}
|
||||
|
||||
private static List<String> wrap(String text, FontMetrics metrics, int maxWidth) {
|
||||
List<String> lines = new ArrayList<>();
|
||||
StringBuilder line = new StringBuilder();
|
||||
for (String word : text.split(" ")) {
|
||||
String candidate = line.isEmpty() ? word : line + " " + word;
|
||||
if (metrics.stringWidth(candidate) > maxWidth && !line.isEmpty()) {
|
||||
lines.add(line.toString());
|
||||
line = new StringBuilder(word);
|
||||
} else {
|
||||
line = new StringBuilder(candidate);
|
||||
}
|
||||
}
|
||||
if (!line.isEmpty()) {
|
||||
lines.add(line.toString());
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
import com.volmit.iris.core.gui.BlockTextures.BlockTexture;
|
||||
import com.volmit.iris.core.loader.IrisData;
|
||||
import com.volmit.iris.engine.framework.Engine;
|
||||
import com.volmit.iris.engine.object.IrisBiome;
|
||||
import com.volmit.iris.engine.object.IrisBiomePaletteLayer;
|
||||
import com.volmit.iris.engine.object.IrisBlockData;
|
||||
import com.volmit.iris.util.collection.KList;
|
||||
import com.volmit.iris.util.math.RNG;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
final class PaletteLayerPreview {
|
||||
static final int HISTOGRAM_BINS = 50;
|
||||
static final int STATISTICS_GRID_SIZE = 64;
|
||||
static final double STATISTICS_SPACING_BLOCKS = 37D;
|
||||
private static final int MISSING_BLOCK_COLOR = 0x5A5F6B;
|
||||
private static final double FAR_FROM_WEIGHT_FACTOR = 2D;
|
||||
private static final int BLOCK_CACHE_SIZE = 1 << 16;
|
||||
private static final int NO_OVERHANG = -1;
|
||||
|
||||
private final BiomePalette palette;
|
||||
private final KList<IrisBlockData> blocks;
|
||||
private final BiomePalettePlacement placement;
|
||||
private final PaletteLayerPreview ground;
|
||||
private final boolean[] showsGroundByEntry;
|
||||
private final int depth;
|
||||
private final IrisData data;
|
||||
private final int[] entryBySlot;
|
||||
private final BiomeTintField biomeTints;
|
||||
private final BlockTexture[] textureByEntry;
|
||||
private final BlockTexture[] upperHalfByEntry;
|
||||
private final boolean hasTallPlants;
|
||||
private final BlockTexture[] legendTextureByEntry;
|
||||
private final int[] legendColorByEntry;
|
||||
private final List<Entry> entries;
|
||||
private final SampledBlock[] blockCache = new SampledBlock[BLOCK_CACHE_SIZE];
|
||||
private final long[] histogram;
|
||||
private final double absentShare;
|
||||
private final double outOfRangeShare;
|
||||
|
||||
private PaletteLayerPreview(BiomePalette palette, BiomePalettePlacement placement, PaletteLayerPreview ground, int depth,
|
||||
IrisData data, BlockTextures textures, BiomeTintField biomeTints) {
|
||||
this.palette = palette;
|
||||
this.blocks = placement.blocks();
|
||||
this.placement = placement;
|
||||
this.ground = ground;
|
||||
this.depth = depth;
|
||||
this.data = data;
|
||||
this.entryBySlot = IrisBiomePaletteLayer.paletteEntryPerSlot(blocks, data).stream().mapToInt(Integer::intValue).toArray();
|
||||
if (entryBySlot.length == 0) {
|
||||
throw new IllegalStateException("No block of " + palette.biomeKey() + " " + palette.lowercaseLabel() + " could be found");
|
||||
}
|
||||
this.biomeTints = biomeTints;
|
||||
this.showsGroundByEntry = resolveGroundShowingEntries();
|
||||
this.textureByEntry = resolveEntryTextures(textures);
|
||||
this.upperHalfByEntry = resolveUpperHalves(textures);
|
||||
this.hasTallPlants = Arrays.stream(upperHalfByEntry).anyMatch(Objects::nonNull);
|
||||
this.legendTextureByEntry = tintForLegend(legendTextures());
|
||||
this.legendColorByEntry = resolveLegendColors();
|
||||
|
||||
Statistics statistics = measure();
|
||||
this.histogram = statistics.histogram();
|
||||
this.absentShare = statistics.absentShare();
|
||||
this.outOfRangeShare = statistics.outOfRangeShare();
|
||||
this.entries = describeEntries(statistics.sharesByEntry());
|
||||
}
|
||||
|
||||
static PaletteLayerPreview load(Engine engine, BiomePalette palette, int depth, BlockTextures textures) {
|
||||
if (engine == null || engine.isClosed()) {
|
||||
throw new IllegalStateException("Open a studio world to preview pack palettes");
|
||||
}
|
||||
IrisData data = engine.getData();
|
||||
IrisBiome biome = data.getBiomeLoader().load(palette.biomeKey());
|
||||
if (biome == null || palette.blocksIn(biome) == null) {
|
||||
throw new IllegalStateException("Palette removed during hotload: " + palette.biomeKey() + " " + palette.lowercaseLabel());
|
||||
}
|
||||
BiomePalettePlacement placement = BiomePalettePlacement.of(engine, biome, palette);
|
||||
BiomeTintField biomeTints = textures == null
|
||||
? null
|
||||
: new BiomeTintField(biome, new RNG(engine.getSeedManager().getBiome()), textures.biomeColors());
|
||||
PaletteLayerPreview ground = palette.kind() == BiomePalette.Kind.DECORATOR
|
||||
? groundUnder(engine, palette.biomeKey(), biome, data, textures, biomeTints)
|
||||
: null;
|
||||
return new PaletteLayerPreview(palette, placement, ground, depth, data, textures, biomeTints);
|
||||
}
|
||||
|
||||
// Decorations stand on the biome's top surface layer, so that is what shows around and between them.
|
||||
private static PaletteLayerPreview groundUnder(Engine engine, String biomeKey, IrisBiome biome, IrisData data,
|
||||
BlockTextures textures, BiomeTintField biomeTints) {
|
||||
BiomePalette topLayer = new BiomePalette(biomeKey, BiomePalette.Kind.LAYER, 0);
|
||||
KList<IrisBlockData> blocks = topLayer.blocksIn(biome);
|
||||
if (blocks == null || blocks.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return new PaletteLayerPreview(topLayer, BiomePalettePlacement.of(engine, biome, topLayer), null, 0, data, textures,
|
||||
biomeTints);
|
||||
}
|
||||
|
||||
// The world picks one block per whole coordinate, and zoomed in, many pixels fall on the same block.
|
||||
double sample(double x, double z) {
|
||||
int blockX = (int) Math.floor(x);
|
||||
int blockZ = (int) Math.floor(z);
|
||||
int cacheSlot = blockCacheSlot(blockX, blockZ);
|
||||
SampledBlock cached = blockCache[cacheSlot];
|
||||
if (cached != null && cached.x() == blockX && cached.z() == blockZ) {
|
||||
return cached.noise();
|
||||
}
|
||||
double noise = sampleBlock(blockX, blockZ);
|
||||
blockCache[cacheSlot] = new SampledBlock(blockX, blockZ, noise);
|
||||
return noise;
|
||||
}
|
||||
|
||||
private double sampleBlock(int blockX, int blockZ) {
|
||||
if (placement.thickness(blockX, blockZ) <= depth) {
|
||||
return Double.NaN;
|
||||
}
|
||||
return placement.noise(blockX, blockZ, depth);
|
||||
}
|
||||
|
||||
private static int blockCacheSlot(int blockX, int blockZ) {
|
||||
long hash = blockX * 0x9E3779B97F4A7C15L ^ blockZ * 0xC2B2AE3D27D4EB4FL;
|
||||
return (int) ((hash ^ (hash >>> 29)) & (BLOCK_CACHE_SIZE - 1));
|
||||
}
|
||||
|
||||
int colorForNoise(double noise) {
|
||||
return legendColorByEntry[entryIndexForNoise(noise)];
|
||||
}
|
||||
|
||||
int blockColorAt(double noise, double worldX, double worldZ, boolean showTexturePixels) {
|
||||
int overhang = showTexturePixels ? overhangColorAt(worldX, worldZ) : NO_OVERHANG;
|
||||
if (overhang != NO_OVERHANG) {
|
||||
return overhang;
|
||||
}
|
||||
int entry = entryIndexForNoise(noise);
|
||||
if (showsGroundByEntry[entry]) {
|
||||
return bareColorAt(worldX, worldZ, showTexturePixels);
|
||||
}
|
||||
BlockTexture texture = textureByEntry[entry];
|
||||
if (texture == null) {
|
||||
return legendColorByEntry[entry];
|
||||
}
|
||||
if (ground != null && showTexturePixels && !texture.isOpaqueAt(worldX, worldZ)) {
|
||||
return groundColorAt(worldX, worldZ, true);
|
||||
}
|
||||
int untintedColor = showTexturePixels ? texture.texelAt(worldX, worldZ) : texture.averageColor();
|
||||
return tinted(texture, untintedColor, worldX, worldZ);
|
||||
}
|
||||
|
||||
/**
|
||||
* The colour where the palette places nothing: the ground under a decorator, or plain dark otherwise.
|
||||
*/
|
||||
int bareColorAt(double worldX, double worldZ, boolean showTexturePixels) {
|
||||
if (ground == null) {
|
||||
return PaletteLayerScale.ABSENT_COLOR;
|
||||
}
|
||||
int overhang = showTexturePixels ? overhangColorAt(worldX, worldZ) : NO_OVERHANG;
|
||||
return overhang != NO_OVERHANG ? overhang : groundColorAt(worldX, worldZ, showTexturePixels);
|
||||
}
|
||||
|
||||
// A two block tall plant stands up on the map: its top half covers the block above it, which is one block north.
|
||||
private int overhangColorAt(double worldX, double worldZ) {
|
||||
if (!hasTallPlants) {
|
||||
return NO_OVERHANG;
|
||||
}
|
||||
double plantZ = worldZ + 1D;
|
||||
double noise = sample(worldX, plantZ);
|
||||
if (Double.isNaN(noise)) {
|
||||
return NO_OVERHANG;
|
||||
}
|
||||
BlockTexture upperHalf = upperHalfByEntry[entryIndexForNoise(noise)];
|
||||
if (upperHalf == null || !upperHalf.isOpaqueAt(worldX, worldZ)) {
|
||||
return NO_OVERHANG;
|
||||
}
|
||||
return tinted(upperHalf, upperHalf.texelAt(worldX, worldZ), worldX, plantZ);
|
||||
}
|
||||
|
||||
private int tinted(BlockTexture texture, int untintedColor, double worldX, double worldZ) {
|
||||
if (texture.biomeTint() == null) {
|
||||
return untintedColor;
|
||||
}
|
||||
int tint = biomeTints.colorAt(texture.biomeTint(), (int) Math.floor(worldX), (int) Math.floor(worldZ));
|
||||
return BlockTexture.multiply(untintedColor, tint);
|
||||
}
|
||||
|
||||
private int groundColorAt(double worldX, double worldZ, boolean showTexturePixels) {
|
||||
double noise = ground.sample(worldX, worldZ);
|
||||
return Double.isNaN(noise) ? PaletteLayerScale.ABSENT_COLOR : ground.blockColorAt(noise, worldX, worldZ, showTexturePixels);
|
||||
}
|
||||
|
||||
// A decorator places air to leave gaps, which looks the same as placing nothing.
|
||||
private boolean[] resolveGroundShowingEntries() {
|
||||
boolean[] showsGround = new boolean[blocks.size()];
|
||||
for (int entry = 0; entry < blocks.size(); entry++) {
|
||||
BlockData block = blocks.get(entry).getBlockData(data);
|
||||
showsGround[entry] = ground != null && block != null && block.getMaterial().isAir();
|
||||
}
|
||||
return showsGround;
|
||||
}
|
||||
|
||||
Entry entryForNoise(double noise) {
|
||||
return entries.get(entryIndexForNoise(noise));
|
||||
}
|
||||
|
||||
String biomeKey() {
|
||||
return palette.biomeKey();
|
||||
}
|
||||
|
||||
BiomePalette palette() {
|
||||
return palette;
|
||||
}
|
||||
|
||||
String simplification() {
|
||||
return placement.simplification();
|
||||
}
|
||||
|
||||
int depth() {
|
||||
return depth;
|
||||
}
|
||||
|
||||
int minThickness() {
|
||||
return placement.minThickness();
|
||||
}
|
||||
|
||||
int maxThickness() {
|
||||
return placement.maxThickness();
|
||||
}
|
||||
|
||||
double zoom() {
|
||||
return placement.zoom();
|
||||
}
|
||||
|
||||
boolean hasSlopeCondition() {
|
||||
return placement.hasSlopeCondition();
|
||||
}
|
||||
|
||||
List<Entry> entries() {
|
||||
return entries;
|
||||
}
|
||||
|
||||
long[] histogram() {
|
||||
return histogram.clone();
|
||||
}
|
||||
|
||||
double absentShare() {
|
||||
return absentShare;
|
||||
}
|
||||
|
||||
double outOfRangeShare() {
|
||||
return outOfRangeShare;
|
||||
}
|
||||
|
||||
static double statisticsAreaBlocks() {
|
||||
return STATISTICS_GRID_SIZE * STATISTICS_SPACING_BLOCKS;
|
||||
}
|
||||
|
||||
private int entryIndexForNoise(double noise) {
|
||||
return entryBySlot[IrisBiomePaletteLayer.slotForNoise(noise, entryBySlot.length)];
|
||||
}
|
||||
|
||||
private BlockTexture[] resolveEntryTextures(BlockTextures textures) {
|
||||
BlockTexture[] resolved = new BlockTexture[blocks.size()];
|
||||
if (textures == null) {
|
||||
return resolved;
|
||||
}
|
||||
for (int entry = 0; entry < blocks.size(); entry++) {
|
||||
BlockData block = blocks.get(entry).getBlockData(data);
|
||||
resolved[entry] = block == null ? null : textureOf(block, textures).orElse(null);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private BlockTexture[] resolveUpperHalves(BlockTextures textures) {
|
||||
BlockTexture[] resolved = new BlockTexture[blocks.size()];
|
||||
if (textures == null || palette.kind() != BiomePalette.Kind.DECORATOR) {
|
||||
return resolved;
|
||||
}
|
||||
for (int entry = 0; entry < blocks.size(); entry++) {
|
||||
BlockData block = blocks.get(entry).getBlockData(data);
|
||||
resolved[entry] = block == null ? null : textures.upperHalfIcon(block).orElse(null);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// Tall plants are known by their top half, the way Minecraft draws them in the inventory.
|
||||
private BlockTexture[] legendTextures() {
|
||||
BlockTexture[] legend = textureByEntry.clone();
|
||||
for (int entry = 0; entry < legend.length; entry++) {
|
||||
if (upperHalfByEntry[entry] != null) {
|
||||
legend[entry] = upperHalfByEntry[entry];
|
||||
}
|
||||
}
|
||||
return legend;
|
||||
}
|
||||
|
||||
private Optional<BlockTexture> textureOf(BlockData block, BlockTextures textures) {
|
||||
return palette.kind() == BiomePalette.Kind.DECORATOR ? textures.iconTexture(block) : textures.topTexture(block);
|
||||
}
|
||||
|
||||
// The legend shows tinted blocks as they look at the world origin, where the statistics are sampled too.
|
||||
private BlockTexture[] tintForLegend(BlockTexture[] textures) {
|
||||
BlockTexture[] tinted = new BlockTexture[textures.length];
|
||||
for (int entry = 0; entry < textures.length; entry++) {
|
||||
BlockTexture texture = textures[entry];
|
||||
tinted[entry] = texture == null || texture.biomeTint() == null
|
||||
? texture
|
||||
: texture.tinted(biomeTints.colorAt(texture.biomeTint(), 0, 0));
|
||||
}
|
||||
return tinted;
|
||||
}
|
||||
|
||||
// Blocks without a texture, like custom blocks or everything before the textures load, fall back to map colours.
|
||||
private int[] resolveLegendColors() {
|
||||
List<BlockData> untexturedBlocks = new ArrayList<>();
|
||||
for (int entry = 0; entry < blocks.size(); entry++) {
|
||||
BlockData block = blocks.get(entry).getBlockData(data);
|
||||
if (block != null && textureByEntry[entry] == null) {
|
||||
untexturedBlocks.add(block);
|
||||
}
|
||||
}
|
||||
Map<String, Integer> mapColorsByBlock = BlockPreviewColors.colorsByBlock(untexturedBlocks);
|
||||
int[] colors = new int[blocks.size()];
|
||||
for (int entry = 0; entry < blocks.size(); entry++) {
|
||||
BlockData block = blocks.get(entry).getBlockData(data);
|
||||
if (block == null) {
|
||||
colors[entry] = MISSING_BLOCK_COLOR;
|
||||
} else if (legendTextureByEntry[entry] != null) {
|
||||
colors[entry] = legendTextureByEntry[entry].averageColor();
|
||||
} else {
|
||||
colors[entry] = mapColorsByBlock.get(block.getAsString(true));
|
||||
}
|
||||
}
|
||||
return colors;
|
||||
}
|
||||
|
||||
private Statistics measure() {
|
||||
long[] values = new long[HISTOGRAM_BINS];
|
||||
long[] samplesByEntry = new long[blocks.size()];
|
||||
long present = 0;
|
||||
long absent = 0;
|
||||
long outOfRange = 0;
|
||||
double origin = -statisticsAreaBlocks() / 2D;
|
||||
for (int gridX = 0; gridX < STATISTICS_GRID_SIZE; gridX++) {
|
||||
for (int gridZ = 0; gridZ < STATISTICS_GRID_SIZE; gridZ++) {
|
||||
double noise = sample(origin + gridX * STATISTICS_SPACING_BLOCKS, origin + gridZ * STATISTICS_SPACING_BLOCKS);
|
||||
if (Double.isNaN(noise)) {
|
||||
absent++;
|
||||
continue;
|
||||
}
|
||||
present++;
|
||||
if (noise < 0D || noise > 1D) {
|
||||
outOfRange++;
|
||||
}
|
||||
values[Math.clamp((int) (noise * HISTOGRAM_BINS), 0, HISTOGRAM_BINS - 1)]++;
|
||||
samplesByEntry[entryIndexForNoise(noise)]++;
|
||||
}
|
||||
}
|
||||
double[] sharesByEntry = new double[samplesByEntry.length];
|
||||
for (int entry = 0; entry < samplesByEntry.length; entry++) {
|
||||
sharesByEntry[entry] = present == 0 ? 0D : samplesByEntry[entry] / (double) present;
|
||||
}
|
||||
long total = present + absent;
|
||||
return new Statistics(values, sharesByEntry, absent / (double) total, present == 0 ? 0D : outOfRange / (double) present);
|
||||
}
|
||||
|
||||
private List<Entry> describeEntries(double[] sharesByEntry) {
|
||||
int slotCount = entryBySlot.length;
|
||||
List<Entry> described = new ArrayList<>(blocks.size());
|
||||
for (int entry = 0; entry < blocks.size(); entry++) {
|
||||
IrisBlockData block = blocks.get(entry);
|
||||
int firstSlot = firstSlotOf(entry);
|
||||
int lastSlot = lastSlotOf(entry);
|
||||
boolean placed = firstSlot >= 0;
|
||||
described.add(new Entry(
|
||||
block.getBlock(),
|
||||
block.getWeight(),
|
||||
block.getBlockData(data) != null,
|
||||
legendColorByEntry[entry],
|
||||
legendTextureByEntry[entry],
|
||||
placed ? (lastSlot - firstSlot + 1) / (double) slotCount : 0D,
|
||||
sharesByEntry[entry],
|
||||
placed ? slotLowerBound(firstSlot, slotCount) : Double.NaN,
|
||||
placed ? slotUpperBound(lastSlot, slotCount) : Double.NaN
|
||||
));
|
||||
}
|
||||
return List.copyOf(described);
|
||||
}
|
||||
|
||||
private int firstSlotOf(int entry) {
|
||||
for (int slot = 0; slot < entryBySlot.length; slot++) {
|
||||
if (entryBySlot[slot] == entry) {
|
||||
return slot;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private int lastSlotOf(int entry) {
|
||||
for (int slot = entryBySlot.length - 1; slot >= 0; slot--) {
|
||||
if (entryBySlot[slot] == entry) {
|
||||
return slot;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Slots are picked by rounding, so the first and last slot only own half a step of the value range.
|
||||
private static double slotLowerBound(int slot, int slotCount) {
|
||||
return slot == 0 ? 0D : (slot - 0.5D) / (slotCount - 1);
|
||||
}
|
||||
|
||||
private static double slotUpperBound(int slot, int slotCount) {
|
||||
return slot == slotCount - 1 ? 1D : (slot + 0.5D) / (slotCount - 1);
|
||||
}
|
||||
|
||||
record Entry(String block, int weight, boolean found, int color, BlockTexture texture, double weightShare,
|
||||
double actualShare, double lowestNoise, double highestNoise) {
|
||||
boolean isFarBelowWeight() {
|
||||
return found && actualShare < weightShare / FAR_FROM_WEIGHT_FACTOR;
|
||||
}
|
||||
|
||||
boolean isFarAboveWeight() {
|
||||
return found && actualShare > weightShare * FAR_FROM_WEIGHT_FACTOR;
|
||||
}
|
||||
}
|
||||
|
||||
private record SampledBlock(int x, int z, double noise) {
|
||||
}
|
||||
|
||||
private record Statistics(long[] histogram, double[] sharesByEntry, double absentShare, double outOfRangeShare) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
import java.awt.Color;
|
||||
|
||||
final class PaletteLayerScale implements NoiseColorScale {
|
||||
static final int ABSENT_COLOR = 0x161A24;
|
||||
private static final double OVERLAY_DARKEST_SHADE = 0.3D;
|
||||
|
||||
private final View view;
|
||||
private final PaletteLayerPreview preview;
|
||||
private final NoisePalette noisePalette;
|
||||
private final boolean showTexturePixels;
|
||||
|
||||
PaletteLayerScale(View view, PaletteLayerPreview preview, NoisePalette noisePalette, boolean showTexturePixels) {
|
||||
this.view = view;
|
||||
this.preview = preview;
|
||||
this.noisePalette = noisePalette;
|
||||
this.showTexturePixels = showTexturePixels;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int colorAt(double value, double worldX, double worldZ) {
|
||||
if (view == View.NOISE) {
|
||||
return noisePalette.colorFinite(value);
|
||||
}
|
||||
int blockColor = preview.blockColorAt(value, worldX, worldZ, showTexturePixels);
|
||||
return view == View.BLOCKS ? blockColor : BlockPreviewColors.shade(blockColor, overlayShade(value));
|
||||
}
|
||||
|
||||
@Override
|
||||
public int colorFinite(double value) {
|
||||
return switch (view) {
|
||||
case BLOCKS -> preview.colorForNoise(value);
|
||||
case NOISE -> noisePalette.colorFinite(value);
|
||||
case BLOCKS_SHADED_BY_NOISE -> BlockPreviewColors.shade(preview.colorForNoise(value), overlayShade(value));
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public int colorInvalid() {
|
||||
return ABSENT_COLOR;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int colorInvalidAt(double worldX, double worldZ) {
|
||||
return view == View.NOISE ? ABSENT_COLOR : preview.bareColorAt(worldX, worldZ, showTexturePixels);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Color displayColorNormalized(double normalized) {
|
||||
return new Color(colorFinite(minimum() + normalized * (maximum() - minimum())));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String label() {
|
||||
return view == View.NOISE ? noisePalette.label() : view.toString();
|
||||
}
|
||||
|
||||
@Override
|
||||
public double minimum() {
|
||||
return view == View.NOISE ? noisePalette.minimum() : 0D;
|
||||
}
|
||||
|
||||
@Override
|
||||
public double maximum() {
|
||||
return view == View.NOISE ? noisePalette.maximum() : 1D;
|
||||
}
|
||||
|
||||
private static double overlayShade(double value) {
|
||||
return OVERLAY_DARKEST_SHADE + (1D - OVERLAY_DARKEST_SHADE) * Math.clamp(value, 0D, 1D);
|
||||
}
|
||||
|
||||
enum View {
|
||||
BLOCKS("Blocks"),
|
||||
NOISE("Noise"),
|
||||
BLOCKS_SHADED_BY_NOISE("Both");
|
||||
|
||||
private final String label;
|
||||
|
||||
View(String label) {
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return label;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
import com.volmit.iris.core.gui.PaletteCatalog.BiomeEntry;
|
||||
import com.volmit.iris.core.gui.PaletteCatalog.BiomeGroup;
|
||||
import com.volmit.iris.core.gui.PaletteCatalog.RegionEntry;
|
||||
import com.volmit.iris.core.gui.PlayerLocationTracker.PlayerLocation;
|
||||
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.ButtonGroup;
|
||||
import javax.swing.DefaultListCellRenderer;
|
||||
import javax.swing.DefaultListModel;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JList;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JTextField;
|
||||
import javax.swing.JToggleButton;
|
||||
import javax.swing.JTree;
|
||||
import javax.swing.ListSelectionModel;
|
||||
import javax.swing.event.DocumentEvent;
|
||||
import javax.swing.event.DocumentListener;
|
||||
import javax.swing.tree.DefaultMutableTreeNode;
|
||||
import javax.swing.tree.DefaultTreeCellRenderer;
|
||||
import javax.swing.tree.DefaultTreeModel;
|
||||
import javax.swing.tree.TreePath;
|
||||
import javax.swing.tree.TreeSelectionModel;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.CardLayout;
|
||||
import java.awt.Component;
|
||||
import java.awt.GridLayout;
|
||||
import java.awt.Rectangle;
|
||||
import java.util.Enumeration;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
|
||||
final class PaletteNavigator extends JPanel {
|
||||
private static final long serialVersionUID = -6402313718430917284L;
|
||||
private static final String HERE_CARD = "here";
|
||||
private static final String OVERVIEW_CARD = "overview";
|
||||
private static final int MESSAGE_WRAP_WIDTH_PIXELS = 190;
|
||||
|
||||
private final Listener listener;
|
||||
private final CardLayout cards = new CardLayout();
|
||||
private final JPanel cardPanel = new JPanel(cards);
|
||||
private final JLabel regionLabel = new JLabel();
|
||||
private final DefaultListModel<HereRow> hereRows = new DefaultListModel<>();
|
||||
private final JList<HereRow> hereList = new JList<>(hereRows);
|
||||
private final JTextField searchField = new JTextField();
|
||||
private final DefaultTreeModel treeModel = new DefaultTreeModel(new DefaultMutableTreeNode());
|
||||
private final JTree overviewTree = new JTree(treeModel);
|
||||
|
||||
private PaletteCatalog catalog;
|
||||
private PlayerLocation location;
|
||||
private BiomePalette selectedPalette;
|
||||
private boolean updatingSelection;
|
||||
private boolean untracked;
|
||||
|
||||
PaletteNavigator(Listener listener) {
|
||||
super(new BorderLayout());
|
||||
this.listener = listener;
|
||||
cardPanel.add(buildHereCard(), HERE_CARD);
|
||||
cardPanel.add(buildOverviewCard(), OVERVIEW_CARD);
|
||||
add(buildTabBar(), BorderLayout.NORTH);
|
||||
add(cardPanel, BorderLayout.CENTER);
|
||||
refreshHere();
|
||||
}
|
||||
|
||||
void showCatalog(PaletteCatalog nextCatalog) {
|
||||
catalog = nextCatalog;
|
||||
rebuildOverview();
|
||||
refreshHere();
|
||||
}
|
||||
|
||||
void showLocation(PlayerLocation nextLocation) {
|
||||
location = nextLocation;
|
||||
refreshHere();
|
||||
}
|
||||
|
||||
// Without a player there is nowhere to call "here", so the Here tab explains how to get one instead.
|
||||
void showUntracked() {
|
||||
untracked = true;
|
||||
refreshHere();
|
||||
}
|
||||
|
||||
void markSelected(BiomePalette palette) {
|
||||
selectedPalette = palette;
|
||||
updatingSelection = true;
|
||||
try {
|
||||
selectInHereList();
|
||||
selectInTree();
|
||||
} finally {
|
||||
updatingSelection = false;
|
||||
}
|
||||
}
|
||||
|
||||
private JPanel buildTabBar() {
|
||||
JPanel bar = new JPanel(new GridLayout(1, 2, 0, 0));
|
||||
bar.setBorder(BorderFactory.createMatteBorder(0, 0, 1, 0, StudioTheme.SEPARATOR));
|
||||
ButtonGroup group = new ButtonGroup();
|
||||
JToggleButton here = createTab("Here", HERE_CARD, group);
|
||||
JToggleButton overview = createTab("Overview", OVERVIEW_CARD, group);
|
||||
bar.add(here);
|
||||
bar.add(overview);
|
||||
here.doClick();
|
||||
return bar;
|
||||
}
|
||||
|
||||
private JToggleButton createTab(String text, String card, ButtonGroup group) {
|
||||
JToggleButton tab = StudioToolbar.tab(text);
|
||||
tab.addActionListener(event -> cards.show(cardPanel, card));
|
||||
group.add(tab);
|
||||
return tab;
|
||||
}
|
||||
|
||||
private JPanel buildHereCard() {
|
||||
JPanel card = new JPanel(new BorderLayout());
|
||||
JPanel location = new JPanel(new GridLayout(0, 1, 0, StudioTheme.scale(3)));
|
||||
location.setBorder(StudioTheme.emptyBorder(9, 12, 5, 12));
|
||||
location.add(regionLabel);
|
||||
|
||||
hereList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
|
||||
hereList.setCellRenderer(new HereRowRenderer());
|
||||
hereList.addListSelectionListener(event -> {
|
||||
HereRow row = hereList.getSelectedValue();
|
||||
if (!event.getValueIsAdjusting() && !updatingSelection && row != null && row.biomeKey() != null) {
|
||||
choose(row.biomeKey(), row.palette());
|
||||
}
|
||||
});
|
||||
card.add(location, BorderLayout.NORTH);
|
||||
card.add(StudioToolbar.scroll(hereList), BorderLayout.CENTER);
|
||||
return card;
|
||||
}
|
||||
|
||||
private JPanel buildOverviewCard() {
|
||||
JPanel card = new JPanel(new BorderLayout());
|
||||
StudioToolbar.makeSearchField(searchField, "Search biomes, snippets or blocks...");
|
||||
searchField.getDocument().addDocumentListener(new DocumentListener() {
|
||||
@Override
|
||||
public void insertUpdate(DocumentEvent event) {
|
||||
rebuildOverview();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeUpdate(DocumentEvent event) {
|
||||
rebuildOverview();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void changedUpdate(DocumentEvent event) {
|
||||
rebuildOverview();
|
||||
}
|
||||
});
|
||||
|
||||
overviewTree.setRootVisible(false);
|
||||
overviewTree.setShowsRootHandles(true);
|
||||
overviewTree.setRowHeight(StudioTheme.scale(24));
|
||||
overviewTree.getSelectionModel().setSelectionMode(TreeSelectionModel.SINGLE_TREE_SELECTION);
|
||||
overviewTree.setCellRenderer(new OverviewRenderer());
|
||||
overviewTree.addTreeSelectionListener(event -> {
|
||||
if (!updatingSelection && nodeAt(event.getPath()) instanceof OverviewNode node && node.biomeKey() != null) {
|
||||
choose(node.biomeKey(), node.palette());
|
||||
}
|
||||
});
|
||||
card.add(StudioToolbar.padded(searchField, 8, 8, 8, 8), BorderLayout.NORTH);
|
||||
card.add(StudioToolbar.scroll(overviewTree), BorderLayout.CENTER);
|
||||
return card;
|
||||
}
|
||||
|
||||
// Choosing a biome rather than one of its palettes opens its first palette.
|
||||
private void choose(String biomeKey, BiomePalette palette) {
|
||||
BiomeEntry biome = catalog == null ? null : catalog.biome(biomeKey);
|
||||
BiomePalette chosen = palette != null ? palette : biome == null ? null : biome.firstPalette();
|
||||
if (chosen != null) {
|
||||
listener.onPaletteChosen(chosen);
|
||||
}
|
||||
}
|
||||
|
||||
private void refreshHere() {
|
||||
updatingSelection = true;
|
||||
try {
|
||||
hereRows.clear();
|
||||
if (untracked) {
|
||||
showHereMessage("Run /iris std pal in-game to see the palettes where you stand.");
|
||||
return;
|
||||
}
|
||||
if (location == null || catalog == null) {
|
||||
showHereMessage("Finding where you are...");
|
||||
return;
|
||||
}
|
||||
switch (location.status()) {
|
||||
case OFFLINE -> showHereMessage("You are offline.");
|
||||
case IN_OTHER_WORLD -> showHereMessage("You left the world this window shows.");
|
||||
case IN_REGION -> showHereRows(location);
|
||||
}
|
||||
selectInHereList();
|
||||
} finally {
|
||||
updatingSelection = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void showHereMessage(String message) {
|
||||
regionLabel.setText("<html><body style='width:" + StudioTheme.scale(MESSAGE_WRAP_WIDTH_PIXELS) + "px'>" + message + "</body></html>");
|
||||
}
|
||||
|
||||
private void showHereRows(PlayerLocation here) {
|
||||
RegionEntry region = catalog.region(here.regionKey());
|
||||
regionLabel.setText(labeled("Region", region == null ? here.regionKey() : region.name(), here.regionKey()));
|
||||
addBiomePalettes("Surface biome here", here.surfaceBiomeKey());
|
||||
if (here.isBelowSurfaceBiome()) {
|
||||
addBiomePalettes("Underground biome at your height", here.biomeAtPositionKey());
|
||||
}
|
||||
if (region == null) {
|
||||
return;
|
||||
}
|
||||
hereRows.addElement(HereRow.header("Biomes in this region"));
|
||||
for (String biomeKey : region.allBiomeKeys()) {
|
||||
addRegionBiome(biomeKey, 0, new HashSet<>(), here);
|
||||
}
|
||||
}
|
||||
|
||||
private void addBiomePalettes(String title, String biomeKey) {
|
||||
BiomeEntry biome = catalog.biome(biomeKey);
|
||||
if (biome == null) {
|
||||
return;
|
||||
}
|
||||
hereRows.addElement(HereRow.header(title));
|
||||
hereRows.addElement(HereRow.biome(biomeText(biome, childOfText(biome)), biomeKey, 0));
|
||||
addPaletteRows(biome, 0);
|
||||
}
|
||||
|
||||
private void addPaletteRows(BiomeEntry biome, int nesting) {
|
||||
for (PaletteCatalog.PaletteSummary summary : biome.palettes()) {
|
||||
hereRows.addElement(HereRow.palette(summary.text(), summary.palette(), nesting));
|
||||
}
|
||||
}
|
||||
|
||||
// Children sit under the biome they grow inside of, so a child biome is found where it actually appears.
|
||||
private void addRegionBiome(String biomeKey, int nesting, Set<String> ancestors, PlayerLocation here) {
|
||||
BiomeEntry biome = catalog.biome(biomeKey);
|
||||
if (biome == null || !ancestors.add(biomeKey)) {
|
||||
return;
|
||||
}
|
||||
boolean standingHere = biomeKey.equals(here.surfaceBiomeKey()) || biomeKey.equals(here.biomeAtPositionKey());
|
||||
hereRows.addElement(HereRow.biome(biomeText(biome, standingHere ? "you are here" : null), biomeKey, nesting));
|
||||
addPaletteRows(biome, nesting);
|
||||
for (String childKey : biome.childKeys()) {
|
||||
addRegionBiome(childKey, nesting + 1, new HashSet<>(ancestors), here);
|
||||
}
|
||||
}
|
||||
|
||||
private String childOfText(BiomeEntry biome) {
|
||||
if (biome.parentKeys().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
List<String> parentNames = biome.parentKeys().stream()
|
||||
.map(key -> catalog.biome(key) == null ? key : catalog.biome(key).name())
|
||||
.toList();
|
||||
return "child of " + String.join(", ", parentNames);
|
||||
}
|
||||
|
||||
private void rebuildOverview() {
|
||||
if (catalog == null) {
|
||||
return;
|
||||
}
|
||||
String filter = searchField.getText().trim().toLowerCase(Locale.ROOT);
|
||||
DefaultMutableTreeNode root = new DefaultMutableTreeNode();
|
||||
for (RegionEntry region : catalog.regions()) {
|
||||
boolean regionMatches = !filter.isEmpty() && region.name().toLowerCase(Locale.ROOT).contains(filter);
|
||||
DefaultMutableTreeNode regionNode = new DefaultMutableTreeNode(new OverviewNode(region.name(), region.key(), null, null));
|
||||
for (BiomeGroup group : region.groups()) {
|
||||
DefaultMutableTreeNode groupNode = new DefaultMutableTreeNode();
|
||||
for (String biomeKey : group.biomeKeys()) {
|
||||
addBiomeNode(groupNode, biomeKey, regionMatches ? "" : filter, new HashSet<>());
|
||||
}
|
||||
if (groupNode.getChildCount() > 0) {
|
||||
groupNode.setUserObject(new OverviewNode(group.label(), groupNode.getChildCount() + " biomes", null, null));
|
||||
regionNode.add(groupNode);
|
||||
}
|
||||
}
|
||||
if (regionNode.getChildCount() > 0) {
|
||||
root.add(regionNode);
|
||||
}
|
||||
}
|
||||
DefaultMutableTreeNode outside = new DefaultMutableTreeNode();
|
||||
for (String biomeKey : catalog.biomesOutsideRegions()) {
|
||||
addBiomeNode(outside, biomeKey, filter, new HashSet<>());
|
||||
}
|
||||
if (outside.getChildCount() > 0) {
|
||||
outside.setUserObject(new OverviewNode("Not in any region", outside.getChildCount() + " biomes", null, null));
|
||||
root.add(outside);
|
||||
}
|
||||
addSnippetNodes(root, filter);
|
||||
updatingSelection = true;
|
||||
try {
|
||||
treeModel.setRoot(root);
|
||||
if (!filter.isEmpty()) {
|
||||
expandDownToBiomes(root);
|
||||
}
|
||||
selectInTree();
|
||||
} finally {
|
||||
updatingSelection = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void addBiomeNode(DefaultMutableTreeNode parent, String biomeKey, String filter, Set<String> ancestors) {
|
||||
BiomeEntry biome = catalog.biome(biomeKey);
|
||||
if (biome == null || !ancestors.add(biomeKey)) {
|
||||
return;
|
||||
}
|
||||
DefaultMutableTreeNode biomeNode = new DefaultMutableTreeNode(new OverviewNode(biome.name(), biomeKey, biomeKey, null));
|
||||
for (PaletteCatalog.PaletteSummary summary : biome.palettes()) {
|
||||
biomeNode.add(new DefaultMutableTreeNode(new OverviewNode(summary.text(), null, biomeKey, summary.palette())));
|
||||
}
|
||||
for (String childKey : biome.childKeys()) {
|
||||
addBiomeNode(biomeNode, childKey, filter, new HashSet<>(ancestors));
|
||||
}
|
||||
boolean hasMatchingChild = biomeNode.getChildCount() > biome.palettes().size();
|
||||
if (filter.isEmpty() || biome.matches(filter) || hasMatchingChild) {
|
||||
parent.add(biomeNode);
|
||||
}
|
||||
}
|
||||
|
||||
// Snippets sit below the regions, so a palette picked elsewhere is still found in its biome first.
|
||||
private void addSnippetNodes(DefaultMutableTreeNode root, String filter) {
|
||||
for (BiomePalette.SnippetFolder folder : BiomePalette.SnippetFolder.values()) {
|
||||
DefaultMutableTreeNode folderNode = new DefaultMutableTreeNode();
|
||||
catalog.snippets().stream()
|
||||
.filter(snippet -> snippet.folder() == folder && (filter.isEmpty() || snippet.matches(filter)))
|
||||
.forEach(snippet -> folderNode.add(snippetNode(snippet)));
|
||||
if (folderNode.getChildCount() > 0) {
|
||||
folderNode.setUserObject(new OverviewNode(folder.label() + " snippets", folderNode.getChildCount() + " files", null, null));
|
||||
root.add(folderNode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Picking a snippet opens it where the first biome using it places it; its children open the other uses.
|
||||
private DefaultMutableTreeNode snippetNode(PaletteCatalog.SnippetEntry snippet) {
|
||||
if (snippet.uses().isEmpty()) {
|
||||
return new DefaultMutableTreeNode(new OverviewNode(snippet.path(), "not used by any biome", null, null));
|
||||
}
|
||||
BiomePalette firstUse = snippet.uses().get(0);
|
||||
String usedBy = snippet.uses().size() == 1 ? "used once" : "used " + snippet.uses().size() + " times";
|
||||
DefaultMutableTreeNode node = new DefaultMutableTreeNode(new OverviewNode(snippet.path() + " · " + snippet.blocks(),
|
||||
usedBy, firstUse.biomeKey(), firstUse));
|
||||
for (BiomePalette use : snippet.uses()) {
|
||||
BiomeEntry biome = catalog.biome(use.biomeKey());
|
||||
String biomeName = biome == null ? use.biomeKey() : biome.name();
|
||||
node.add(new DefaultMutableTreeNode(new OverviewNode(biomeName + " · " + use.lowercaseLabel(), use.biomeKey(),
|
||||
use.biomeKey(), use)));
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
private void expandDownToBiomes(DefaultMutableTreeNode root) {
|
||||
Enumeration<?> nodes = root.breadthFirstEnumeration();
|
||||
while (nodes.hasMoreElements()) {
|
||||
DefaultMutableTreeNode node = (DefaultMutableTreeNode) nodes.nextElement();
|
||||
if (node.getUserObject() instanceof OverviewNode overview && overview.biomeKey() != null) {
|
||||
overviewTree.expandPath(new TreePath(((DefaultMutableTreeNode) node.getParent()).getPath()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void selectInTree() {
|
||||
if (selectedNodeShowsSelectedPalette()) {
|
||||
return;
|
||||
}
|
||||
DefaultMutableTreeNode match = findLayerNode((DefaultMutableTreeNode) treeModel.getRoot());
|
||||
if (match == null) {
|
||||
overviewTree.clearSelection();
|
||||
return;
|
||||
}
|
||||
TreePath path = new TreePath(match.getPath());
|
||||
overviewTree.setSelectionPath(path);
|
||||
overviewTree.makeVisible(path);
|
||||
Rectangle row = overviewTree.getPathBounds(path);
|
||||
if (row != null) {
|
||||
overviewTree.scrollRectToVisible(new Rectangle(0, row.y, 1, row.height));
|
||||
}
|
||||
}
|
||||
|
||||
// The same palette can be listed under its biome and under its snippet; the row the user picked stays picked.
|
||||
private boolean selectedNodeShowsSelectedPalette() {
|
||||
return nodeAt(overviewTree.getSelectionPath()) instanceof OverviewNode node
|
||||
&& node.palette() != null
|
||||
&& node.palette().equals(selectedPalette);
|
||||
}
|
||||
|
||||
private DefaultMutableTreeNode findLayerNode(DefaultMutableTreeNode root) {
|
||||
Enumeration<?> nodes = root.depthFirstEnumeration();
|
||||
while (nodes.hasMoreElements()) {
|
||||
DefaultMutableTreeNode node = (DefaultMutableTreeNode) nodes.nextElement();
|
||||
if (node.getUserObject() instanceof OverviewNode overview
|
||||
&& overview.palette() != null
|
||||
&& overview.palette().equals(selectedPalette)) {
|
||||
return node;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void selectInHereList() {
|
||||
for (int index = 0; index < hereRows.size(); index++) {
|
||||
HereRow row = hereRows.get(index);
|
||||
if (row.palette() != null && row.palette().equals(selectedPalette)) {
|
||||
hereList.setSelectedIndex(index);
|
||||
return;
|
||||
}
|
||||
}
|
||||
hereList.clearSelection();
|
||||
}
|
||||
|
||||
private static Object nodeAt(TreePath path) {
|
||||
return path == null ? null : ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject();
|
||||
}
|
||||
|
||||
private static String biomeText(BiomeEntry biome, String note) {
|
||||
String noteText = note == null ? "" : " · " + escape(note);
|
||||
return "<html>" + escape(biome.name()) + noteText + " <font color='" + StudioTheme.SECONDARY_TEXT.toHtml() + "'>" + escape(biome.key())
|
||||
+ "</font></html>";
|
||||
}
|
||||
|
||||
private static String labeled(String title, String name, String key) {
|
||||
return "<html><font color='" + StudioTheme.SECONDARY_TEXT.toHtml() + "'>" + title + "</font> " + escape(name)
|
||||
+ " <font color='" + StudioTheme.SECONDARY_TEXT.toHtml() + "'>" + escape(key) + "</font></html>";
|
||||
}
|
||||
|
||||
private static String escape(String text) {
|
||||
return text.replace("&", "&").replace("<", "<").replace(">", ">");
|
||||
}
|
||||
|
||||
interface Listener {
|
||||
void onPaletteChosen(BiomePalette palette);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param nesting how many parent biomes deep the row's biome sits
|
||||
*/
|
||||
private record HereRow(String text, String biomeKey, BiomePalette palette, int nesting, boolean header) {
|
||||
private static HereRow header(String text) {
|
||||
return new HereRow(text, null, null, 0, true);
|
||||
}
|
||||
|
||||
private static HereRow biome(String text, String biomeKey, int nesting) {
|
||||
return new HereRow(text, biomeKey, null, nesting, false);
|
||||
}
|
||||
|
||||
private static HereRow palette(String text, BiomePalette palette, int nesting) {
|
||||
return new HereRow(text, palette.biomeKey(), palette, nesting, false);
|
||||
}
|
||||
}
|
||||
|
||||
private record OverviewNode(String label, String detail, String biomeKey, BiomePalette palette) {
|
||||
private String html() {
|
||||
if (detail == null) {
|
||||
return escape(label);
|
||||
}
|
||||
return "<html>" + escape(label) + " <font color='" + StudioTheme.SECONDARY_TEXT.toHtml() + "'>" + escape(detail) + "</font></html>";
|
||||
}
|
||||
}
|
||||
|
||||
private static final class HereRowRenderer extends DefaultListCellRenderer {
|
||||
private static final long serialVersionUID = 2785341117408251320L;
|
||||
private static final int HEADER_INDENT = 11;
|
||||
private static final int BIOME_INDENT = 20;
|
||||
private static final int PALETTE_INDENT = 34;
|
||||
private static final int CHILD_INDENT = 16;
|
||||
|
||||
@Override
|
||||
public Component getListCellRendererComponent(JList<?> list, Object value, int index, boolean selected, boolean focus) {
|
||||
HereRow row = (HereRow) value;
|
||||
boolean selectable = row.biomeKey() != null;
|
||||
super.getListCellRendererComponent(list, row.header() ? row.text().toUpperCase(Locale.ROOT) : row.text(), index,
|
||||
selected && selectable, false);
|
||||
setFont(row.header() ? StudioTheme.sectionFont() : StudioTheme.font());
|
||||
if (row.header()) {
|
||||
setForeground(StudioTheme.MUTED_TEXT);
|
||||
}
|
||||
int indent = row.header() ? HEADER_INDENT
|
||||
: (row.palette() != null ? PALETTE_INDENT : BIOME_INDENT) + row.nesting() * CHILD_INDENT;
|
||||
setBorder(StudioTheme.emptyBorder(row.header() ? 11 : 4, indent, 4, 10));
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class OverviewRenderer extends DefaultTreeCellRenderer {
|
||||
private static final long serialVersionUID = -1950176034017718227L;
|
||||
|
||||
private OverviewRenderer() {
|
||||
setLeafIcon(null);
|
||||
setOpenIcon(null);
|
||||
setClosedIcon(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Component getTreeCellRendererComponent(JTree tree, Object value, boolean selected, boolean expanded,
|
||||
boolean leaf, int row, boolean focus) {
|
||||
Object userObject = ((DefaultMutableTreeNode) value).getUserObject();
|
||||
String text = userObject instanceof OverviewNode node ? node.html() : "";
|
||||
super.getTreeCellRendererComponent(tree, text, selected, expanded, leaf, row, false);
|
||||
boolean groupsOthers = userObject instanceof OverviewNode node && node.biomeKey() == null && !leaf;
|
||||
setFont(groupsOthers ? StudioTheme.boldFont() : StudioTheme.font());
|
||||
return this;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
import com.volmit.iris.Iris;
|
||||
import com.volmit.iris.core.tools.IrisToolbelt;
|
||||
import com.volmit.iris.engine.framework.Engine;
|
||||
import com.volmit.iris.engine.object.IrisBiome;
|
||||
import com.volmit.iris.engine.object.IrisRegion;
|
||||
import com.volmit.iris.engine.platform.PlatformChunkGenerator;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.scheduler.BukkitTask;
|
||||
|
||||
import java.awt.EventQueue;
|
||||
import java.util.UUID;
|
||||
|
||||
final class PlayerLocationTracker {
|
||||
private static final long CHECK_INTERVAL_TICKS = 10L;
|
||||
|
||||
private final UUID playerId;
|
||||
private final UUID worldId;
|
||||
private final Listener listener;
|
||||
private BukkitTask task;
|
||||
private PlayerLocation lastLocation;
|
||||
|
||||
private PlayerLocationTracker(UUID playerId, UUID worldId, Listener listener) {
|
||||
this.playerId = playerId;
|
||||
this.worldId = worldId;
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
static PlayerLocationTracker follow(UUID playerId, UUID worldId, Listener listener) {
|
||||
PlayerLocationTracker tracker = new PlayerLocationTracker(playerId, worldId, listener);
|
||||
tracker.task = Bukkit.getScheduler().runTaskTimer(Iris.instance, tracker::checkLocation, 0L, CHECK_INTERVAL_TICKS);
|
||||
return tracker;
|
||||
}
|
||||
|
||||
void stop() {
|
||||
if (task != null) {
|
||||
task.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
private void checkLocation() {
|
||||
PlayerLocation location = locate();
|
||||
if (location.equals(lastLocation)) {
|
||||
return;
|
||||
}
|
||||
lastLocation = location;
|
||||
EventQueue.invokeLater(() -> listener.onLocationChanged(location));
|
||||
}
|
||||
|
||||
private PlayerLocation locate() {
|
||||
Player player = Bukkit.getPlayer(playerId);
|
||||
if (player == null) {
|
||||
return PlayerLocation.OFFLINE;
|
||||
}
|
||||
if (player.getWorld() == null || !player.getWorld().getUID().equals(worldId)) {
|
||||
return PlayerLocation.IN_OTHER_WORLD;
|
||||
}
|
||||
Engine engine = engineAt(player.getLocation());
|
||||
if (engine == null) {
|
||||
return PlayerLocation.IN_OTHER_WORLD;
|
||||
}
|
||||
Location position = player.getLocation();
|
||||
IrisRegion region = engine.getRegion(position);
|
||||
String surfaceBiome = keyOf(engine.getSurfaceBiome(position.getBlockX(), position.getBlockZ()));
|
||||
String biomeAtPosition = keyOf(engine.getBiomeOrMantle(position));
|
||||
if (region == null || region.getLoadKey() == null || surfaceBiome == null) {
|
||||
return PlayerLocation.IN_OTHER_WORLD;
|
||||
}
|
||||
return new PlayerLocation(Status.IN_REGION, region.getLoadKey(), surfaceBiome, biomeAtPosition);
|
||||
}
|
||||
|
||||
private static String keyOf(IrisBiome biome) {
|
||||
return biome == null ? null : biome.getLoadKey();
|
||||
}
|
||||
|
||||
private static Engine engineAt(Location location) {
|
||||
if (location.getWorld() == null || !IrisToolbelt.isIrisWorld(location.getWorld())) {
|
||||
return null;
|
||||
}
|
||||
PlatformChunkGenerator generator = IrisToolbelt.access(location.getWorld());
|
||||
return generator == null ? null : generator.getEngine();
|
||||
}
|
||||
|
||||
enum Status {
|
||||
OFFLINE,
|
||||
IN_OTHER_WORLD,
|
||||
IN_REGION
|
||||
}
|
||||
|
||||
// Biomes are 3D: underground, the biome at the player's position can differ from the surface biome above it.
|
||||
record PlayerLocation(Status status, String regionKey, String surfaceBiomeKey, String biomeAtPositionKey) {
|
||||
static final PlayerLocation OFFLINE = new PlayerLocation(Status.OFFLINE, null, null, null);
|
||||
static final PlayerLocation IN_OTHER_WORLD = new PlayerLocation(Status.IN_OTHER_WORLD, null, null, null);
|
||||
|
||||
boolean isBelowSurfaceBiome() {
|
||||
return biomeAtPositionKey != null && !biomeAtPositionKey.equals(surfaceBiomeKey);
|
||||
}
|
||||
}
|
||||
|
||||
interface Listener {
|
||||
void onLocationChanged(PlayerLocation location);
|
||||
}
|
||||
}
|
||||
+53
-30
@@ -40,7 +40,10 @@ import java.awt.*;
|
||||
import java.awt.event.KeyEvent;
|
||||
import java.awt.event.KeyListener;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
@@ -53,7 +56,7 @@ public class PregeneratorJob implements PregenListener {
|
||||
private static final Color COLOR_NETWORK_GENERATING = parseColor("#836b8c");
|
||||
private static final Color COLOR_GENERATED = parseColor("#65c295");
|
||||
private static final Color COLOR_CLEANED = parseColor("#34eb93");
|
||||
public static PregeneratorJob instance;
|
||||
private static final AtomicReference<PregeneratorJob> instance = new AtomicReference<>();
|
||||
private final MemoryMonitor monitor;
|
||||
private final PregenTask task;
|
||||
private final boolean saving;
|
||||
@@ -64,67 +67,83 @@ public class PregeneratorJob implements PregenListener {
|
||||
private final Position2 max;
|
||||
private final ChronoLatch cl = new ChronoLatch(TimeUnit.MINUTES.toMillis(1));
|
||||
private final Engine engine;
|
||||
private final ExecutorService service;
|
||||
private JFrame frame;
|
||||
private PregenRenderer renderer;
|
||||
private int rgc = 0;
|
||||
private String[] info;
|
||||
|
||||
public PregeneratorJob(PregenTask task, PregeneratorMethod method, Engine engine) {
|
||||
instance.updateAndGet(old -> {
|
||||
if (old != null) {
|
||||
old.pregenerator.close();
|
||||
old.close();
|
||||
}
|
||||
return this;
|
||||
});
|
||||
this.engine = engine;
|
||||
instance = this;
|
||||
monitor = new MemoryMonitor(50);
|
||||
saving = false;
|
||||
info = new String[]{"Initializing..."};
|
||||
this.task = task;
|
||||
this.pregenerator = new IrisPregenerator(task, method, this);
|
||||
max = new Position2(0, 0);
|
||||
min = new Position2(0, 0);
|
||||
task.iterateRegions((xx, zz) -> {
|
||||
min.setX(Math.min(xx << 5, min.getX()));
|
||||
min.setZ(Math.min(zz << 5, min.getZ()));
|
||||
max.setX(Math.max((xx << 5) + 31, max.getX()));
|
||||
max.setZ(Math.max((zz << 5) + 31, max.getZ()));
|
||||
min = new Position2(Integer.MAX_VALUE, Integer.MAX_VALUE);
|
||||
task.iterateAllChunks((xx, zz) -> {
|
||||
min.setX(Math.min(xx, min.getX()));
|
||||
min.setZ(Math.min(zz, min.getZ()));
|
||||
max.setX(Math.max(xx, max.getX()));
|
||||
max.setZ(Math.max(zz, max.getZ()));
|
||||
});
|
||||
|
||||
if (IrisSettings.get().getGui().isUseServerLaunchedGuis()) {
|
||||
if (IrisSettings.get().getGui().isUseServerLaunchedGuis() && task.isGui()) {
|
||||
open();
|
||||
}
|
||||
|
||||
J.a(this.pregenerator::start, 20);
|
||||
var t = new Thread(() -> {
|
||||
J.sleep(1000);
|
||||
this.pregenerator.start();
|
||||
}, "Iris Pregenerator");
|
||||
t.setPriority(Thread.MIN_PRIORITY);
|
||||
t.start();
|
||||
service = Executors.newVirtualThreadPerTaskExecutor();
|
||||
}
|
||||
|
||||
public static boolean shutdownInstance() {
|
||||
if (instance == null) {
|
||||
PregeneratorJob inst = instance.get();
|
||||
if (inst == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
J.a(() -> instance.pregenerator.close());
|
||||
J.a(inst.pregenerator::close);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static PregeneratorJob getInstance() {
|
||||
return instance;
|
||||
return instance.get();
|
||||
}
|
||||
|
||||
public static boolean pauseResume() {
|
||||
if (instance == null) {
|
||||
PregeneratorJob inst = instance.get();
|
||||
if (inst == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isPaused()) {
|
||||
instance.pregenerator.resume();
|
||||
inst.pregenerator.resume();
|
||||
} else {
|
||||
instance.pregenerator.pause();
|
||||
inst.pregenerator.pause();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public static boolean isPaused() {
|
||||
if (instance == null) {
|
||||
PregeneratorJob inst = instance.get();
|
||||
if (inst == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return instance.paused();
|
||||
return inst.paused();
|
||||
}
|
||||
|
||||
private static Color parseColor(String c) {
|
||||
@@ -154,7 +173,7 @@ public class PregeneratorJob implements PregenListener {
|
||||
}
|
||||
|
||||
public void drawRegion(int x, int z, Color color) {
|
||||
J.a(() -> PregenTask.iterateRegion(x, z, (xx, zz) -> {
|
||||
J.a(() -> task.iterateChunks(x, z, (xx, zz) -> {
|
||||
draw(xx, zz, color);
|
||||
J.sleep(3);
|
||||
}));
|
||||
@@ -174,7 +193,7 @@ public class PregeneratorJob implements PregenListener {
|
||||
J.a(() -> {
|
||||
pregenerator.close();
|
||||
close();
|
||||
instance = null;
|
||||
instance.compareAndSet(this, null);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -193,7 +212,7 @@ public class PregeneratorJob implements PregenListener {
|
||||
public void open() {
|
||||
J.a(() -> {
|
||||
try {
|
||||
frame = new JFrame("Pregen View");
|
||||
frame = GuiHost.createFrame("Pregen View");
|
||||
renderer = new PregenRenderer();
|
||||
frame.addKeyListener(renderer);
|
||||
renderer.l = new ReentrantLock();
|
||||
@@ -214,10 +233,10 @@ public class PregeneratorJob implements PregenListener {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTick(double chunksPerSecond, double chunksPerMinute, double regionsPerMinute, double percent, int generated, int totalChunks, int chunksRemaining, long eta, long elapsed, String method) {
|
||||
public void onTick(double chunksPerSecond, double chunksPerMinute, double regionsPerMinute, double percent, long generated, long totalChunks, long chunksRemaining, long eta, long elapsed, String method, boolean cached) {
|
||||
info = new String[]{
|
||||
(paused() ? "PAUSED" : (saving ? "Saving... " : "Generating")) + " " + Form.f(generated) + " of " + Form.f(totalChunks) + " (" + Form.pc(percent, 0) + " Complete)",
|
||||
"Speed: " + Form.f(chunksPerSecond, 0) + " Chunks/s, " + Form.f(regionsPerMinute, 1) + " Regions/m, " + Form.f(chunksPerMinute, 0) + " Chunks/m",
|
||||
"Speed: " + (cached ? "Cached " : "") + Form.f(chunksPerSecond, 0) + " Chunks/s, " + Form.f(regionsPerMinute, 1) + " Regions/m, " + Form.f(chunksPerMinute, 0) + " Chunks/m",
|
||||
Form.duration(eta, 2) + " Remaining " + " (" + Form.duration(elapsed, 2) + " Elapsed)",
|
||||
"Generation Method: " + method,
|
||||
"Memory: " + Form.memSize(monitor.getUsedBytes(), 2) + " (" + Form.pc(monitor.getUsagePercent(), 0) + ") Pressure: " + Form.memSize(monitor.getPressure(), 0) + "/s",
|
||||
@@ -235,13 +254,16 @@ public class PregeneratorJob implements PregenListener {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChunkGenerated(int x, int z) {
|
||||
if (engine != null) {
|
||||
draw(x, z, engine.draw((x << 4) + 8, (z << 4) + 8));
|
||||
return;
|
||||
}
|
||||
public void onChunkGenerated(int x, int z, boolean cached) {
|
||||
if (renderer == null || frame == null || !frame.isVisible()) return;
|
||||
service.submit(() -> {
|
||||
if (engine != null) {
|
||||
draw(x, z, engine.draw((x << 4) + 8, (z << 4) + 8));
|
||||
return;
|
||||
}
|
||||
|
||||
draw(x, z, COLOR_GENERATED);
|
||||
draw(x, z, COLOR_GENERATED);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -299,8 +321,9 @@ public class PregeneratorJob implements PregenListener {
|
||||
@Override
|
||||
public void onClose() {
|
||||
close();
|
||||
instance = null;
|
||||
instance.compareAndSet(this, null);
|
||||
whenDone.forEach(Runnable::run);
|
||||
service.shutdownNow();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -0,0 +1,268 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
import javax.swing.CellRendererPane;
|
||||
import javax.swing.Icon;
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.JLayer;
|
||||
import javax.swing.JScrollPane;
|
||||
import javax.swing.JTree;
|
||||
import javax.swing.JViewport;
|
||||
import javax.swing.SwingUtilities;
|
||||
import javax.swing.UIManager;
|
||||
import javax.swing.plaf.LayerUI;
|
||||
import javax.swing.plaf.basic.BasicTreeUI;
|
||||
import javax.swing.tree.TreePath;
|
||||
import java.awt.AWTEvent;
|
||||
import java.awt.Color;
|
||||
import java.awt.Component;
|
||||
import java.awt.Cursor;
|
||||
import java.awt.GradientPaint;
|
||||
import java.awt.Graphics;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.Point;
|
||||
import java.awt.Rectangle;
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Keeps the folders around the top row of a scrolled tree pinned at the top, so a long folder never hides where it
|
||||
* is. The next folder pushes the last header up as it arrives, and clicking a header jumps to its folder.
|
||||
*/
|
||||
final class StickyFolderHeaders extends LayerUI<JScrollPane> {
|
||||
private static final long serialVersionUID = -2205474187531254372L;
|
||||
private static final int SHADOW_HEIGHT = 4;
|
||||
private static final Color SHADOW = new ThemeColor(new Color(0, 0, 0, 28), new Color(0, 0, 0, 90));
|
||||
private static final Color NO_SHADOW = new Color(0, 0, 0, 0);
|
||||
// The folder handle sits one indent to the left of the folder's name, as the tree draws it.
|
||||
private static final int HANDLE_NUDGE = 1;
|
||||
|
||||
private final JTree tree;
|
||||
private final CellRendererPane rendererPane = new CellRendererPane();
|
||||
private transient JLayer<JScrollPane> layer;
|
||||
private int hoveredHeader = -1;
|
||||
|
||||
private StickyFolderHeaders(JTree tree) {
|
||||
this.tree = tree;
|
||||
}
|
||||
|
||||
static JLayer<JScrollPane> around(JTree tree, JScrollPane scrollPane) {
|
||||
// Copying the scrolled pixels would drag the pinned headers along, so every scroll repaints instead.
|
||||
scrollPane.getViewport().setScrollMode(JViewport.SIMPLE_SCROLL_MODE);
|
||||
StickyFolderHeaders headers = new StickyFolderHeaders(tree);
|
||||
JLayer<JScrollPane> layer = new JLayer<>(scrollPane, headers);
|
||||
scrollPane.getViewport().addChangeListener(event -> layer.repaint());
|
||||
return layer;
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
public void installUI(JComponent component) {
|
||||
super.installUI(component);
|
||||
layer = (JLayer<JScrollPane>) component;
|
||||
layer.setLayerEventMask(AWTEvent.MOUSE_EVENT_MASK | AWTEvent.MOUSE_MOTION_EVENT_MASK);
|
||||
tree.add(rendererPane);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void uninstallUI(JComponent component) {
|
||||
layer.setLayerEventMask(0);
|
||||
tree.remove(rendererPane);
|
||||
layer = null;
|
||||
super.uninstallUI(component);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void paint(Graphics graphics, JComponent component) {
|
||||
super.paint(graphics, component);
|
||||
List<Header> headers = pinnedHeaders();
|
||||
if (headers.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Graphics2D canvas = (Graphics2D) graphics.create();
|
||||
try {
|
||||
Rectangle viewport = viewportBounds();
|
||||
canvas.clipRect(viewport.x, viewport.y, viewport.width, viewport.height);
|
||||
for (int index = 0; index < headers.size(); index++) {
|
||||
paintHeader(canvas, headers.get(index), index == hoveredHeader, viewport);
|
||||
}
|
||||
Header last = headers.get(headers.size() - 1);
|
||||
int shadowTop = viewport.y + last.top() + tree.getRowHeight();
|
||||
canvas.setPaint(new GradientPaint(0, shadowTop, SHADOW, 0, shadowTop + StudioTheme.scale(SHADOW_HEIGHT), NO_SHADOW));
|
||||
canvas.fillRect(viewport.x, shadowTop, viewport.width, StudioTheme.scale(SHADOW_HEIGHT));
|
||||
} finally {
|
||||
canvas.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private void paintHeader(Graphics2D canvas, Header header, boolean hovered, Rectangle viewport) {
|
||||
int rowHeight = tree.getRowHeight();
|
||||
int top = viewport.y + header.top();
|
||||
Rectangle row = tree.getRowBounds(header.row());
|
||||
int left = viewport.x + row.x - scrolledView().x;
|
||||
canvas.setColor(hovered ? StudioTheme.HOVER_BACKGROUND : tree.getBackground());
|
||||
canvas.fillRect(viewport.x, top, viewport.width, rowHeight);
|
||||
paintFolderHandle(canvas, left, top + rowHeight / 2);
|
||||
Component renderer = tree.getCellRenderer().getTreeCellRendererComponent(
|
||||
tree, header.path().getLastPathComponent(), false, true, false, header.row(), false);
|
||||
rendererPane.paintComponent(canvas, renderer, tree, left, top, viewport.x + viewport.width - left, rowHeight, true);
|
||||
}
|
||||
|
||||
private void paintFolderHandle(Graphics2D canvas, int folderLeft, int middleY) {
|
||||
Icon handle = UIManager.getIcon("Tree.expandedIcon");
|
||||
if (handle == null || !(tree.getUI() instanceof BasicTreeUI treeUI)) {
|
||||
return;
|
||||
}
|
||||
int middleX = folderLeft - treeUI.getRightChildIndent() + HANDLE_NUDGE;
|
||||
handle.paintIcon(tree, canvas, middleX - handle.getIconWidth() / 2, middleY - handle.getIconHeight() / 2);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void processMouseEvent(MouseEvent event, JLayer<? extends JScrollPane> source) {
|
||||
if (event.getID() == MouseEvent.MOUSE_EXITED) {
|
||||
hover(-1);
|
||||
return;
|
||||
}
|
||||
int index = headerAt(event);
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
event.consume();
|
||||
if (event.getID() == MouseEvent.MOUSE_CLICKED && SwingUtilities.isLeftMouseButton(event)) {
|
||||
jumpTo(pinnedHeaders().get(index), index);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void processMouseMotionEvent(MouseEvent event, JLayer<? extends JScrollPane> source) {
|
||||
hover(headerAt(event));
|
||||
}
|
||||
|
||||
private void hover(int index) {
|
||||
if (index == hoveredHeader) {
|
||||
return;
|
||||
}
|
||||
hoveredHeader = index;
|
||||
tree.setCursor(index >= 0 ? Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) : null);
|
||||
layer.repaint();
|
||||
}
|
||||
|
||||
// The folder lands just below the headers of the folders around it.
|
||||
private void jumpTo(Header header, int level) {
|
||||
Rectangle row = tree.getRowBounds(header.row());
|
||||
JViewport viewport = layer.getView().getViewport();
|
||||
int top = Math.max(0, row.y - level * tree.getRowHeight());
|
||||
int bottomLimit = Math.max(0, tree.getHeight() - viewport.getExtentSize().height);
|
||||
viewport.setViewPosition(new Point(viewport.getViewPosition().x, Math.min(top, bottomLimit)));
|
||||
}
|
||||
|
||||
private int headerAt(MouseEvent event) {
|
||||
if (layer == null || event.getComponent() == null) {
|
||||
return -1;
|
||||
}
|
||||
Point point = SwingUtilities.convertPoint(event.getComponent(), event.getPoint(), layer);
|
||||
Rectangle viewport = viewportBounds();
|
||||
if (!viewport.contains(point)) {
|
||||
return -1;
|
||||
}
|
||||
List<Header> headers = pinnedHeaders();
|
||||
for (int index = headers.size() - 1; index >= 0; index--) {
|
||||
int top = viewport.y + headers.get(index).top();
|
||||
if (point.y >= top && point.y < top + tree.getRowHeight()) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* For each depth, the folder holding the row just under the headers above it, as long as that folder's own row
|
||||
* has scrolled out of sight.
|
||||
*/
|
||||
private List<Header> pinnedHeaders() {
|
||||
List<Header> headers = new ArrayList<>();
|
||||
int rowHeight = tree.getRowHeight();
|
||||
if (layer == null || rowHeight <= 0 || tree.getRowCount() == 0) {
|
||||
return headers;
|
||||
}
|
||||
Rectangle view = scrolledView();
|
||||
for (int level = 0; ; level++) {
|
||||
int slotTop = level * rowHeight;
|
||||
int row = tree.getClosestRowForLocation(view.x, view.y + slotTop);
|
||||
TreePath path = row < 0 ? null : tree.getPathForRow(row);
|
||||
if (path == null || !isInsideFolderAt(path, level)) {
|
||||
return headers;
|
||||
}
|
||||
TreePath folder = ancestorAt(path, level);
|
||||
Rectangle lastRow = tree.getRowBounds(lastRowInside(folder, row));
|
||||
int top = Math.min(slotTop, lastRow.y + lastRow.height - view.y - rowHeight);
|
||||
headers.add(new Header(folder, tree.getRowForPath(folder), top));
|
||||
if (top < slotTop) {
|
||||
return headers;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The hidden root is the path's first element, so the folder at depth 0 is its second.
|
||||
private static boolean isInsideFolderAt(TreePath path, int level) {
|
||||
return path.getPathCount() > level + 2;
|
||||
}
|
||||
|
||||
private static TreePath ancestorAt(TreePath path, int level) {
|
||||
TreePath ancestor = path;
|
||||
while (ancestor.getPathCount() > level + 2) {
|
||||
ancestor = ancestor.getParentPath();
|
||||
}
|
||||
return ancestor;
|
||||
}
|
||||
|
||||
// A folder's rows sit together, so the last one is found by halving the rows after a row known to be inside.
|
||||
private int lastRowInside(TreePath folder, int rowInside) {
|
||||
int low = rowInside;
|
||||
int high = tree.getRowCount() - 1;
|
||||
while (low < high) {
|
||||
int middle = (low + high + 1) >>> 1;
|
||||
if (folder.isDescendant(tree.getPathForRow(middle))) {
|
||||
low = middle;
|
||||
} else {
|
||||
high = middle - 1;
|
||||
}
|
||||
}
|
||||
return low;
|
||||
}
|
||||
|
||||
private Rectangle scrolledView() {
|
||||
return layer.getView().getViewport().getViewRect();
|
||||
}
|
||||
|
||||
private Rectangle viewportBounds() {
|
||||
JViewport viewport = layer.getView().getViewport();
|
||||
Point origin = SwingUtilities.convertPoint(viewport, 0, 0, layer);
|
||||
return new Rectangle(origin.x, origin.y, viewport.getWidth(), viewport.getHeight());
|
||||
}
|
||||
|
||||
/**
|
||||
* A pinned folder, its row in the tree and how far below the top of the visible rows it is drawn.
|
||||
*/
|
||||
private record Header(TreePath path, int row, int top) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.RenderingHints;
|
||||
import java.awt.geom.RoundRectangle2D;
|
||||
|
||||
/**
|
||||
* Draws the floating cards (legends, hover details, hints) on top of a map, styled like IntelliJ's popups.
|
||||
*/
|
||||
final class StudioOverlay {
|
||||
private StudioOverlay() {
|
||||
}
|
||||
|
||||
static void drawCard(Graphics2D canvas, int x, int y, int width, int height) {
|
||||
Object antialiasing = canvas.getRenderingHint(RenderingHints.KEY_ANTIALIASING);
|
||||
canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
RoundRectangle2D card = new RoundRectangle2D.Double(x + 0.5D, y + 0.5D, width - 1D, height - 1D,
|
||||
StudioTheme.scale(StudioTheme.OVERLAY_ARC), StudioTheme.scale(StudioTheme.OVERLAY_ARC));
|
||||
canvas.setColor(StudioTheme.OVERLAY_BACKGROUND);
|
||||
canvas.fill(card);
|
||||
canvas.setColor(StudioTheme.OVERLAY_BORDER);
|
||||
canvas.draw(card);
|
||||
canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, antialiasing);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.Box;
|
||||
import javax.swing.BoxLayout;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JProgressBar;
|
||||
import java.awt.Dimension;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* An IntelliJ style status bar: what is shown on the left, work in progress in the middle and where the pointer is on the right.
|
||||
*/
|
||||
final class StudioStatusBar extends JPanel {
|
||||
private static final long serialVersionUID = 6302468216735125611L;
|
||||
private static final int HEIGHT = 26;
|
||||
private static final int PROGRESS_STEPS = 1_000;
|
||||
|
||||
private final JLabel summary = statusLabel();
|
||||
private final JLabel activity = statusLabel();
|
||||
private final JProgressBar progress = new JProgressBar(0, PROGRESS_STEPS);
|
||||
private final JLabel position = statusLabel();
|
||||
|
||||
StudioStatusBar() {
|
||||
setLayout(new BoxLayout(this, BoxLayout.X_AXIS));
|
||||
setBackground(StudioTheme.PANEL_BACKGROUND);
|
||||
setBorder(BorderFactory.createCompoundBorder(
|
||||
BorderFactory.createMatteBorder(1, 0, 0, 0, StudioTheme.SEPARATOR),
|
||||
StudioTheme.emptyBorder(0, 10, 0, 10)
|
||||
));
|
||||
setPreferredSize(StudioTheme.scaledSize(0, HEIGHT));
|
||||
progress.setMaximumSize(StudioTheme.scaledSize(120, 4));
|
||||
progress.setPreferredSize(StudioTheme.scaledSize(120, 4));
|
||||
progress.setVisible(false);
|
||||
allowShrinking(summary);
|
||||
allowShrinking(activity);
|
||||
add(summary);
|
||||
add(Box.createHorizontalGlue());
|
||||
add(activity);
|
||||
add(Box.createHorizontalStrut(StudioTheme.scale(8)));
|
||||
add(progress);
|
||||
add(Box.createHorizontalStrut(StudioTheme.scale(18)));
|
||||
add(position);
|
||||
}
|
||||
|
||||
void showSummary(String text) {
|
||||
setTextIfChanged(summary, text);
|
||||
summary.setForeground(StudioTheme.SECONDARY_TEXT);
|
||||
}
|
||||
|
||||
void showError(String text) {
|
||||
setTextIfChanged(summary, text);
|
||||
summary.setForeground(StudioTheme.ERROR);
|
||||
}
|
||||
|
||||
void showActivity(String text) {
|
||||
setTextIfChanged(activity, text);
|
||||
}
|
||||
|
||||
void showProgress(double completion) {
|
||||
boolean working = completion < 1D;
|
||||
progress.setVisible(working);
|
||||
if (working) {
|
||||
progress.setValue((int) Math.round(Math.max(0D, completion) * PROGRESS_STEPS));
|
||||
}
|
||||
}
|
||||
|
||||
void showPosition(String text) {
|
||||
setTextIfChanged(position, text);
|
||||
}
|
||||
|
||||
// Long text gives way with an ellipsis instead of pushing the other parts of the bar out of view.
|
||||
private static void allowShrinking(JLabel label) {
|
||||
label.setMinimumSize(new Dimension(0, 0));
|
||||
}
|
||||
|
||||
private static JLabel statusLabel() {
|
||||
JLabel label = new JLabel();
|
||||
label.setFont(StudioTheme.smallFont());
|
||||
label.setForeground(StudioTheme.SECONDARY_TEXT);
|
||||
return label;
|
||||
}
|
||||
|
||||
// Status text changes on every mouse move, and an unchanged label shouldn't trigger a relayout.
|
||||
private static void setTextIfChanged(JLabel label, String text) {
|
||||
String next = text == null ? "" : text;
|
||||
if (!Objects.equals(label.getText(), next)) {
|
||||
label.setText(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
import com.formdev.flatlaf.FlatDarkLaf;
|
||||
import com.formdev.flatlaf.FlatLaf;
|
||||
import com.formdev.flatlaf.FlatLightLaf;
|
||||
import com.formdev.flatlaf.util.UIScale;
|
||||
import com.volmit.iris.Iris;
|
||||
import com.volmit.iris.core.IrisSettings;
|
||||
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.UIManager;
|
||||
import javax.swing.border.Border;
|
||||
import java.awt.Color;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.EventQueue;
|
||||
import java.awt.Font;
|
||||
import java.util.HashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* The colours of IntelliJ's "New UI", light and dark, shared by FlatLaf's controls and everything the studio windows
|
||||
* draw themselves. The theme follows the system unless the user picked light or dark.
|
||||
*/
|
||||
final class StudioTheme {
|
||||
static final Color EDITOR_BACKGROUND = new ThemeColor(0xFFFFFF, 0x1E1F22);
|
||||
static final Color PANEL_BACKGROUND = new ThemeColor(0xF7F8FA, 0x2B2D30);
|
||||
static final Color HOVER_BACKGROUND = new ThemeColor(0xDFE1E5, 0x393B40);
|
||||
static final Color INACTIVE_SELECTION = new ThemeColor(0xDFE1E5, 0x43454A);
|
||||
static final Color SELECTION = new ThemeColor(0xD4E2FF, 0x2E436E);
|
||||
static final Color CONTROL_BORDER = new ThemeColor(0xC9CCD6, 0x4E5157);
|
||||
static final Color SEPARATOR = new ThemeColor(0xEBECF0, 0x1E1F22);
|
||||
static final Color TEXT = new ThemeColor(0x000000, 0xDFE1E5);
|
||||
static final ThemeColor SECONDARY_TEXT = new ThemeColor(0x6C707E, 0x9DA0A8);
|
||||
static final Color MUTED_TEXT = new ThemeColor(0x818594, 0x6F737A);
|
||||
static final Color ACCENT = new Color(0x3574F0);
|
||||
static final Color LINK = new ThemeColor(0x315FBD, 0x548AF7);
|
||||
static final Color ERROR = new ThemeColor(0xDB3B4B, 0xF75464);
|
||||
static final Color WARNING = new ThemeColor(0xC27D04, 0xF2C55C);
|
||||
static final Color SUCCESS = new ThemeColor(0x208A3C, 0x5FB865);
|
||||
static final Color OVERLAY_BACKGROUND = new ThemeColor(new Color(0xFF, 0xFF, 0xFF, 240), new Color(0x2B, 0x2D, 0x30, 236));
|
||||
static final Color OVERLAY_BORDER = new ThemeColor(0xDFE1E5, 0x43454A);
|
||||
static final int OVERLAY_ARC = 10;
|
||||
|
||||
private static final boolean DARK_WITHOUT_SYSTEM_PREFERENCE = true;
|
||||
private static final AtomicBoolean INSTALLED = new AtomicBoolean();
|
||||
private static final ExecutorService SYSTEM_CHECK = Executors.newSingleThreadExecutor(runnable -> {
|
||||
Thread thread = new Thread(runnable, "Iris Theme Check");
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
});
|
||||
private static volatile boolean dark = DARK_WITHOUT_SYSTEM_PREFERENCE;
|
||||
private static volatile float pixelScale = 1F;
|
||||
private static final Map<String, String> SHARED_DEFAULTS = Map.ofEntries(
|
||||
Map.entry("Component.focusWidth", "1"),
|
||||
Map.entry("Component.innerFocusWidth", "0"),
|
||||
Map.entry("Component.arc", "8"),
|
||||
Map.entry("Button.arc", "8"),
|
||||
Map.entry("TextComponent.arc", "8"),
|
||||
Map.entry("List.selectionArc", "6"),
|
||||
Map.entry("List.selectionInsets", "0,6,0,6"),
|
||||
Map.entry("Tree.selectionArc", "6"),
|
||||
Map.entry("Tree.selectionInsets", "0,6,0,6"),
|
||||
Map.entry("Tree.border", "0,8,0,0"),
|
||||
Map.entry("ScrollBar.width", "10"),
|
||||
Map.entry("ScrollBar.thumbArc", "999"),
|
||||
Map.entry("ScrollBar.thumbInsets", "2,2,2,2"),
|
||||
Map.entry("ScrollBar.showButtons", "false"),
|
||||
Map.entry("ScrollPane.border", "0,0,0,0"),
|
||||
Map.entry("SplitPane.dividerSize", "1"),
|
||||
Map.entry("SplitPaneDivider.style", "plain"),
|
||||
Map.entry("ProgressBar.arc", "999"),
|
||||
Map.entry("ProgressBar.horizontalSize", "120,4")
|
||||
);
|
||||
private static final Map<String, String> DARK_DEFAULTS = withShared(Map.ofEntries(
|
||||
Map.entry("@background", "#2B2D30"),
|
||||
Map.entry("@foreground", "#DFE1E5"),
|
||||
Map.entry("@accentColor", "#3574F0"),
|
||||
Map.entry("@selectionBackground", "#2E436E"),
|
||||
Map.entry("@selectionInactiveBackground", "#43454A"),
|
||||
Map.entry("@disabledForeground", "#6F737A"),
|
||||
Map.entry("Component.borderColor", "#4E5157"),
|
||||
Map.entry("Component.focusedBorderColor", "#3574F0"),
|
||||
Map.entry("Button.toolbar.hoverBackground", "#393B40"),
|
||||
Map.entry("Button.toolbar.pressedBackground", "#43454A"),
|
||||
Map.entry("Button.toolbar.selectedBackground", "#43454A"),
|
||||
Map.entry("ToggleButton.toolbar.selectedBackground", "#43454A"),
|
||||
Map.entry("ScrollBar.track", "#2B2D30"),
|
||||
Map.entry("SplitPane.background", "#1E1F22"),
|
||||
Map.entry("Separator.foreground", "#1E1F22"),
|
||||
Map.entry("ToolTip.background", "#393B40")
|
||||
));
|
||||
private static final Map<String, String> LIGHT_DEFAULTS = withShared(Map.ofEntries(
|
||||
Map.entry("@background", "#F7F8FA"),
|
||||
Map.entry("@foreground", "#000000"),
|
||||
Map.entry("@accentColor", "#3574F0"),
|
||||
Map.entry("@selectionBackground", "#D4E2FF"),
|
||||
Map.entry("@selectionForeground", "#000000"),
|
||||
Map.entry("@selectionInactiveBackground", "#DFE1E5"),
|
||||
Map.entry("@selectionInactiveForeground", "#000000"),
|
||||
Map.entry("@disabledForeground", "#A8ADBD"),
|
||||
Map.entry("Component.borderColor", "#C9CCD6"),
|
||||
Map.entry("Component.focusedBorderColor", "#3574F0"),
|
||||
Map.entry("Button.toolbar.hoverBackground", "#DFE1E5"),
|
||||
Map.entry("Button.toolbar.pressedBackground", "#C9CCD6"),
|
||||
Map.entry("Button.toolbar.selectedBackground", "#DFE1E5"),
|
||||
Map.entry("ToggleButton.toolbar.selectedBackground", "#DFE1E5"),
|
||||
Map.entry("ScrollBar.track", "#F7F8FA"),
|
||||
Map.entry("SplitPane.background", "#EBECF0"),
|
||||
Map.entry("Separator.foreground", "#EBECF0")
|
||||
));
|
||||
|
||||
private StudioTheme() {
|
||||
}
|
||||
|
||||
static void install() {
|
||||
if (!INSTALLED.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
applyLookAndFeel(darkFor(mode()));
|
||||
pixelScale = UIScale.getUserScaleFactor();
|
||||
} catch (Throwable error) {
|
||||
Iris.reportError(error);
|
||||
Iris.warn("Could not apply the Iris studio theme, the studio windows will use Java's default look.");
|
||||
}
|
||||
}
|
||||
|
||||
static boolean isDark() {
|
||||
return dark;
|
||||
}
|
||||
|
||||
static Mode mode() {
|
||||
return Mode.fromSetting(IrisSettings.get().getGui().getTheme());
|
||||
}
|
||||
|
||||
/**
|
||||
* Remembers the user's choice and switches every studio window to it.
|
||||
*/
|
||||
static void choose(Mode next) {
|
||||
IrisSettings settings = IrisSettings.get();
|
||||
settings.getGui().setTheme(next.settingName());
|
||||
settings.forceSave();
|
||||
if (next == Mode.AUTO) {
|
||||
followSystem();
|
||||
return;
|
||||
}
|
||||
switchTo(next == Mode.DARK);
|
||||
}
|
||||
|
||||
// Studio windows call this when they come to the front, so a change made in the system's settings shows up.
|
||||
static void followSystem() {
|
||||
if (!INSTALLED.get() || mode() != Mode.AUTO) {
|
||||
return;
|
||||
}
|
||||
SYSTEM_CHECK.execute(() -> {
|
||||
boolean systemDark = SystemAppearance.prefersDark().orElse(DARK_WITHOUT_SYSTEM_PREFERENCE);
|
||||
EventQueue.invokeLater(() -> switchTo(systemDark));
|
||||
});
|
||||
}
|
||||
|
||||
private static boolean darkFor(Mode chosen) {
|
||||
return switch (chosen) {
|
||||
case LIGHT -> false;
|
||||
case DARK -> true;
|
||||
case AUTO -> SystemAppearance.prefersDark().orElse(DARK_WITHOUT_SYSTEM_PREFERENCE);
|
||||
};
|
||||
}
|
||||
|
||||
private static void switchTo(boolean nextDark) {
|
||||
if (nextDark == dark) {
|
||||
return;
|
||||
}
|
||||
applyLookAndFeel(nextDark);
|
||||
FlatLaf.updateUI();
|
||||
}
|
||||
|
||||
private static void applyLookAndFeel(boolean nextDark) {
|
||||
dark = nextDark;
|
||||
FlatLaf.setGlobalExtraDefaults(nextDark ? DARK_DEFAULTS : LIGHT_DEFAULTS);
|
||||
if (nextDark) {
|
||||
FlatDarkLaf.setup();
|
||||
} else {
|
||||
FlatLightLaf.setup();
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, String> withShared(Map<String, String> themeDefaults) {
|
||||
Map<String, String> merged = new HashMap<>(SHARED_DEFAULTS);
|
||||
merged.putAll(themeDefaults);
|
||||
return Map.copyOf(merged);
|
||||
}
|
||||
|
||||
/**
|
||||
* FlatLaf grows fonts and its own controls to the screen's DPI; every size the studio sets itself has to grow with them.
|
||||
*/
|
||||
static int scale(int pixels) {
|
||||
return Math.round(pixels * pixelScale);
|
||||
}
|
||||
|
||||
static double scale(double pixels) {
|
||||
return pixels * pixelScale;
|
||||
}
|
||||
|
||||
static Dimension scaledSize(int width, int height) {
|
||||
return new Dimension(scale(width), scale(height));
|
||||
}
|
||||
|
||||
static Border emptyBorder(int top, int left, int bottom, int right) {
|
||||
return BorderFactory.createEmptyBorder(scale(top), scale(left), scale(bottom), scale(right));
|
||||
}
|
||||
|
||||
static Font font() {
|
||||
return themeFont("defaultFont", Font.PLAIN, 13);
|
||||
}
|
||||
|
||||
static Font smallFont() {
|
||||
return themeFont("small.font", Font.PLAIN, 11);
|
||||
}
|
||||
|
||||
static Font boldFont() {
|
||||
return font().deriveFont(Font.BOLD);
|
||||
}
|
||||
|
||||
static Font titleFont() {
|
||||
return font().deriveFont(Font.BOLD, font().getSize2D() + 1F);
|
||||
}
|
||||
|
||||
static Font sectionFont() {
|
||||
return smallFont().deriveFont(Font.BOLD);
|
||||
}
|
||||
|
||||
static Font monospacedFont() {
|
||||
return themeFont("monospaced.font", Font.PLAIN, 12);
|
||||
}
|
||||
|
||||
private static Font themeFont(String key, int fallbackStyle, int fallbackSize) {
|
||||
Font font = UIManager.getFont(key);
|
||||
return font != null ? font : new Font(Font.SANS_SERIF, fallbackStyle, fallbackSize);
|
||||
}
|
||||
|
||||
enum Mode {
|
||||
AUTO("Match system"),
|
||||
LIGHT("Light"),
|
||||
DARK("Dark");
|
||||
|
||||
private final String label;
|
||||
|
||||
Mode(String label) {
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
String label() {
|
||||
return label;
|
||||
}
|
||||
|
||||
String settingName() {
|
||||
return name().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
static Mode fromSetting(String setting) {
|
||||
for (Mode mode : values()) {
|
||||
if (mode.settingName().equalsIgnoreCase(setting)) {
|
||||
return mode;
|
||||
}
|
||||
}
|
||||
return AUTO;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
import com.formdev.flatlaf.FlatClientProperties;
|
||||
import com.formdev.flatlaf.icons.FlatSearchIcon;
|
||||
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.ButtonGroup;
|
||||
import javax.swing.JButton;
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JPopupMenu;
|
||||
import javax.swing.JRadioButtonMenuItem;
|
||||
import javax.swing.JScrollPane;
|
||||
import javax.swing.JTextField;
|
||||
import javax.swing.JToggleButton;
|
||||
import javax.swing.JToolBar;
|
||||
import javax.swing.ScrollPaneConstants;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Insets;
|
||||
|
||||
/**
|
||||
* Builds the IntelliJ style controls the studio windows share: a flat main toolbar, borderless toolbar buttons and search boxes.
|
||||
*/
|
||||
final class StudioToolbar {
|
||||
private static final int TOOLBAR_HEIGHT_PADDING = 5;
|
||||
|
||||
private StudioToolbar() {
|
||||
}
|
||||
|
||||
static JToolBar create() {
|
||||
JToolBar toolbar = new JToolBar();
|
||||
toolbar.setFloatable(false);
|
||||
toolbar.setRollover(true);
|
||||
toolbar.setBackground(StudioTheme.PANEL_BACKGROUND);
|
||||
toolbar.setBorder(BorderFactory.createCompoundBorder(
|
||||
BorderFactory.createMatteBorder(0, 0, 1, 0, StudioTheme.SEPARATOR),
|
||||
StudioTheme.emptyBorder(TOOLBAR_HEIGHT_PADDING, 8, TOOLBAR_HEIGHT_PADDING, 8)
|
||||
));
|
||||
return toolbar;
|
||||
}
|
||||
|
||||
static JLabel label(String text) {
|
||||
JLabel label = new JLabel(text);
|
||||
label.setForeground(StudioTheme.SECONDARY_TEXT);
|
||||
label.setBorder(StudioTheme.emptyBorder(0, 6, 0, 6));
|
||||
return label;
|
||||
}
|
||||
|
||||
static JButton button(String text, String tooltip, Runnable action) {
|
||||
JButton button = toolbarButton(text, tooltip);
|
||||
button.addActionListener(event -> action.run());
|
||||
return button;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a menu to pick light, dark or the system's theme.
|
||||
*/
|
||||
static JButton themeButton() {
|
||||
JButton button = toolbarButton("Theme", "Light, dark, or whatever the system uses");
|
||||
button.addActionListener(event -> themeMenu().show(button, 0, button.getHeight()));
|
||||
return button;
|
||||
}
|
||||
|
||||
private static JPopupMenu themeMenu() {
|
||||
JPopupMenu menu = new JPopupMenu();
|
||||
ButtonGroup choices = new ButtonGroup();
|
||||
StudioTheme.Mode current = StudioTheme.mode();
|
||||
for (StudioTheme.Mode mode : StudioTheme.Mode.values()) {
|
||||
JRadioButtonMenuItem item = new JRadioButtonMenuItem(mode.label(), mode == current);
|
||||
item.addActionListener(event -> StudioTheme.choose(mode));
|
||||
choices.add(item);
|
||||
menu.add(item);
|
||||
}
|
||||
return menu;
|
||||
}
|
||||
|
||||
private static JButton toolbarButton(String text, String tooltip) {
|
||||
JButton button = new JButton(text);
|
||||
button.setToolTipText(tooltip);
|
||||
button.setFocusable(false);
|
||||
button.setMargin(new Insets(4, 8, 4, 8));
|
||||
button.putClientProperty(FlatClientProperties.BUTTON_TYPE, FlatClientProperties.BUTTON_TYPE_TOOLBAR_BUTTON);
|
||||
return button;
|
||||
}
|
||||
|
||||
static JToggleButton toggle(String text, String tooltip) {
|
||||
JToggleButton toggle = new JToggleButton(text);
|
||||
toggle.setToolTipText(tooltip);
|
||||
toggle.setFocusable(false);
|
||||
toggle.setMargin(new Insets(4, 8, 4, 8));
|
||||
toggle.putClientProperty(FlatClientProperties.BUTTON_TYPE, FlatClientProperties.BUTTON_TYPE_TOOLBAR_BUTTON);
|
||||
return toggle;
|
||||
}
|
||||
|
||||
// An underlined tab like IntelliJ's tool window tabs.
|
||||
static JToggleButton tab(String text) {
|
||||
JToggleButton tab = new JToggleButton(text);
|
||||
tab.setFocusable(false);
|
||||
tab.putClientProperty(FlatClientProperties.BUTTON_TYPE, FlatClientProperties.BUTTON_TYPE_TAB);
|
||||
return tab;
|
||||
}
|
||||
|
||||
static void makeSearchField(JTextField field, String placeholder) {
|
||||
field.putClientProperty(FlatClientProperties.PLACEHOLDER_TEXT, placeholder);
|
||||
field.putClientProperty(FlatClientProperties.TEXT_FIELD_LEADING_ICON, new FlatSearchIcon());
|
||||
field.putClientProperty(FlatClientProperties.TEXT_FIELD_SHOW_CLEAR_BUTTON, true);
|
||||
field.setToolTipText(placeholder);
|
||||
}
|
||||
|
||||
static JPanel padded(JComponent content, int top, int left, int bottom, int right) {
|
||||
JPanel panel = new JPanel(new BorderLayout());
|
||||
panel.setOpaque(false);
|
||||
panel.setBorder(StudioTheme.emptyBorder(top, left, bottom, right));
|
||||
panel.add(content, BorderLayout.CENTER);
|
||||
return panel;
|
||||
}
|
||||
|
||||
static JScrollPane scroll(JComponent content) {
|
||||
JScrollPane scrollPane = new JScrollPane(content);
|
||||
scrollPane.setBorder(BorderFactory.createEmptyBorder());
|
||||
scrollPane.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
|
||||
scrollPane.getVerticalScrollBar().setUnitIncrement(18);
|
||||
return scrollPane;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Asks the operating system whether its user prefers dark or light apps, through the same settings desktop apps
|
||||
* follow: the freedesktop portal or GNOME's setting on Linux, the apps theme on Windows, the appearance on macOS.
|
||||
*/
|
||||
final class SystemAppearance {
|
||||
private static final long COMMAND_TIMEOUT_MILLIS = 2000L;
|
||||
private static final String PORTAL_DARK = "uint32 1";
|
||||
private static final String PORTAL_LIGHT = "uint32 2";
|
||||
|
||||
private SystemAppearance() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty when the system has no preference or could not be asked.
|
||||
*/
|
||||
static Optional<Boolean> prefersDark() {
|
||||
String os = System.getProperty("os.name", "").toLowerCase(Locale.ROOT);
|
||||
if (os.contains("win")) {
|
||||
return windowsPrefersDark();
|
||||
}
|
||||
if (os.contains("mac")) {
|
||||
return macPrefersDark();
|
||||
}
|
||||
return linuxPrefersDark();
|
||||
}
|
||||
|
||||
private static Optional<Boolean> linuxPrefersDark() {
|
||||
Optional<String> portal = output(List.of("gdbus", "call", "--session", "--dest", "org.freedesktop.portal.Desktop",
|
||||
"--object-path", "/org/freedesktop/portal/desktop", "--method", "org.freedesktop.portal.Settings.ReadOne",
|
||||
"org.freedesktop.appearance", "color-scheme"));
|
||||
if (portal.filter(answer -> answer.contains(PORTAL_DARK)).isPresent()) {
|
||||
return Optional.of(true);
|
||||
}
|
||||
if (portal.filter(answer -> answer.contains(PORTAL_LIGHT)).isPresent()) {
|
||||
return Optional.of(false);
|
||||
}
|
||||
Optional<String> gnome = output(List.of("gsettings", "get", "org.gnome.desktop.interface", "color-scheme"));
|
||||
if (gnome.filter(scheme -> scheme.contains("prefer-dark")).isPresent()) {
|
||||
return Optional.of(true);
|
||||
}
|
||||
if (gnome.filter(scheme -> scheme.contains("prefer-light")).isPresent()) {
|
||||
return Optional.of(false);
|
||||
}
|
||||
return output(List.of("gsettings", "get", "org.gnome.desktop.interface", "gtk-theme"))
|
||||
.map(theme -> theme.toLowerCase(Locale.ROOT).contains("dark"));
|
||||
}
|
||||
|
||||
private static Optional<Boolean> windowsPrefersDark() {
|
||||
return output(List.of("reg", "query", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize",
|
||||
"/v", "AppsUseLightTheme"))
|
||||
.filter(answer -> answer.contains("AppsUseLightTheme"))
|
||||
.map(answer -> answer.trim().endsWith("0x0"));
|
||||
}
|
||||
|
||||
// The setting only exists while macOS is dark, so a failed read means light.
|
||||
private static Optional<Boolean> macPrefersDark() {
|
||||
return Optional.of(output(List.of("defaults", "read", "-g", "AppleInterfaceStyle"))
|
||||
.filter(style -> style.trim().equalsIgnoreCase("dark"))
|
||||
.isPresent());
|
||||
}
|
||||
|
||||
private static Optional<String> output(List<String> command) {
|
||||
try {
|
||||
Process process = new ProcessBuilder(command).redirectErrorStream(true).start();
|
||||
if (!process.waitFor(COMMAND_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)) {
|
||||
process.destroyForcibly();
|
||||
return Optional.empty();
|
||||
}
|
||||
if (process.exitValue() != 0) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try (InputStream stream = process.getInputStream()) {
|
||||
return Optional.of(new String(stream.readAllBytes(), StandardCharsets.UTF_8));
|
||||
}
|
||||
} catch (IOException missingCommand) {
|
||||
return Optional.empty();
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
import java.awt.Color;
|
||||
|
||||
/**
|
||||
* A colour with a light and a dark version that always answers with the one of the current theme, like IntelliJ's
|
||||
* JBColor. Components keep the same colour object across a theme switch and simply paint in the other version.
|
||||
*/
|
||||
final class ThemeColor extends Color {
|
||||
private static final long serialVersionUID = 2286435620386470207L;
|
||||
private final Color light;
|
||||
private final Color dark;
|
||||
|
||||
ThemeColor(int lightRgb, int darkRgb) {
|
||||
this(new Color(lightRgb), new Color(darkRgb));
|
||||
}
|
||||
|
||||
ThemeColor(Color light, Color dark) {
|
||||
super(dark.getRGB(), true);
|
||||
this.light = light;
|
||||
this.dark = dark;
|
||||
}
|
||||
|
||||
// Every other colour query of Color, and Java2D's painting, go through this one.
|
||||
@Override
|
||||
public int getRGB() {
|
||||
return current().getRGB();
|
||||
}
|
||||
|
||||
String toHtml() {
|
||||
return String.format("#%06X", getRGB() & 0xFFFFFF);
|
||||
}
|
||||
|
||||
private Color current() {
|
||||
return StudioTheme.isDark() ? dark : light;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
/**
|
||||
* An affine transform built up like Minecraft's pose stack: each step applies to points before the steps added
|
||||
* earlier. Rotations follow the right-hand rule, the way Minecraft turns model parts.
|
||||
*/
|
||||
final class Transform {
|
||||
static final Transform IDENTITY = new Transform(new double[]{1D, 0D, 0D, 0D, 1D, 0D, 0D, 0D, 1D}, Vec3.ZERO);
|
||||
|
||||
private final double[] matrix;
|
||||
private final Vec3 translation;
|
||||
|
||||
private Transform(double[] matrix, Vec3 translation) {
|
||||
this.matrix = matrix;
|
||||
this.translation = translation;
|
||||
}
|
||||
|
||||
Transform translate(double x, double y, double z) {
|
||||
return then(new Transform(IDENTITY.matrix, new Vec3(x, y, z)));
|
||||
}
|
||||
|
||||
Transform rotateX(double degrees) {
|
||||
double cos = cosine(degrees);
|
||||
double sin = sine(degrees);
|
||||
return then(new Transform(new double[]{1D, 0D, 0D, 0D, cos, -sin, 0D, sin, cos}, Vec3.ZERO));
|
||||
}
|
||||
|
||||
Transform rotateY(double degrees) {
|
||||
double cos = cosine(degrees);
|
||||
double sin = sine(degrees);
|
||||
return then(new Transform(new double[]{cos, 0D, sin, 0D, 1D, 0D, -sin, 0D, cos}, Vec3.ZERO));
|
||||
}
|
||||
|
||||
Transform rotateZ(double degrees) {
|
||||
double cos = cosine(degrees);
|
||||
double sin = sine(degrees);
|
||||
return then(new Transform(new double[]{cos, -sin, 0D, sin, cos, 0D, 0D, 0D, 1D}, Vec3.ZERO));
|
||||
}
|
||||
|
||||
Transform scale(double x, double y, double z) {
|
||||
return then(new Transform(new double[]{x, 0D, 0D, 0D, y, 0D, 0D, 0D, z}, Vec3.ZERO));
|
||||
}
|
||||
|
||||
/**
|
||||
* This transform applied after {@code next}.
|
||||
*/
|
||||
Transform then(Transform next) {
|
||||
double[] product = new double[9];
|
||||
for (int row = 0; row < 3; row++) {
|
||||
for (int column = 0; column < 3; column++) {
|
||||
product[row * 3 + column] = matrix[row * 3] * next.matrix[column]
|
||||
+ matrix[row * 3 + 1] * next.matrix[3 + column]
|
||||
+ matrix[row * 3 + 2] * next.matrix[6 + column];
|
||||
}
|
||||
}
|
||||
return new Transform(product, direction(next.translation).plus(translation));
|
||||
}
|
||||
|
||||
Vec3 point(Vec3 point) {
|
||||
return direction(point).plus(translation);
|
||||
}
|
||||
|
||||
Vec3 direction(Vec3 direction) {
|
||||
return new Vec3(
|
||||
matrix[0] * direction.x() + matrix[1] * direction.y() + matrix[2] * direction.z(),
|
||||
matrix[3] * direction.x() + matrix[4] * direction.y() + matrix[5] * direction.z(),
|
||||
matrix[6] * direction.x() + matrix[7] * direction.y() + matrix[8] * direction.z());
|
||||
}
|
||||
|
||||
// Quarter turns stay exact, so faces turned by a blockstate still line up with the block's sides.
|
||||
private static double cosine(double degrees) {
|
||||
return sine(degrees + 90D);
|
||||
}
|
||||
|
||||
private static double sine(double degrees) {
|
||||
double turned = ((degrees % 360D) + 360D) % 360D;
|
||||
if (turned == 0D || turned == 180D) {
|
||||
return 0D;
|
||||
}
|
||||
if (turned == 90D) {
|
||||
return 1D;
|
||||
}
|
||||
if (turned == 270D) {
|
||||
return -1D;
|
||||
}
|
||||
return Math.sin(Math.toRadians(turned));
|
||||
}
|
||||
}
|
||||
+18
-17
@@ -16,31 +16,32 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.volmit.iris.util.collection;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.HashSet;
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
public class KSet<T> extends HashSet<T> {
|
||||
private static final long serialVersionUID = 1L;
|
||||
/**
|
||||
* A position or direction in a block, in block units.
|
||||
*/
|
||||
record Vec3(double x, double y, double z) {
|
||||
static final Vec3 ZERO = new Vec3(0D, 0D, 0D);
|
||||
|
||||
public KSet() {
|
||||
super();
|
||||
Vec3 plus(Vec3 other) {
|
||||
return new Vec3(x + other.x, y + other.y, z + other.z);
|
||||
}
|
||||
|
||||
public KSet(Collection<? extends T> c) {
|
||||
super(c);
|
||||
Vec3 minus(Vec3 other) {
|
||||
return new Vec3(x - other.x, y - other.y, z - other.z);
|
||||
}
|
||||
|
||||
public KSet(int initialCapacity, float loadFactor) {
|
||||
super(initialCapacity, loadFactor);
|
||||
Vec3 times(double factor) {
|
||||
return new Vec3(x * factor, y * factor, z * factor);
|
||||
}
|
||||
|
||||
public KSet(int initialCapacity) {
|
||||
super(initialCapacity);
|
||||
}
|
||||
|
||||
public KSet<T> copy() {
|
||||
return new KSet<T>(this);
|
||||
double component(int axis) {
|
||||
return switch (axis) {
|
||||
case 0 -> x;
|
||||
case 1 -> y;
|
||||
default -> z;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
import com.volmit.iris.Iris;
|
||||
import com.volmit.iris.core.gui.components.RenderType;
|
||||
import com.volmit.iris.engine.IrisComplex;
|
||||
import com.volmit.iris.engine.framework.Engine;
|
||||
import com.volmit.iris.engine.object.IrisWorld;
|
||||
import com.volmit.iris.util.format.Form;
|
||||
import com.volmit.iris.util.scheduling.J;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import static com.volmit.iris.util.data.registry.Attributes.MAX_HEALTH;
|
||||
|
||||
final class VisionOverlay {
|
||||
private final Engine engine;
|
||||
private final UUID openerId;
|
||||
private final AtomicBoolean playerRefreshQueued = new AtomicBoolean();
|
||||
private volatile List<GuiMarker> playerMarkers = List.of();
|
||||
|
||||
VisionOverlay(Engine engine, UUID openerId) {
|
||||
this.engine = engine;
|
||||
this.openerId = openerId;
|
||||
}
|
||||
|
||||
List<GuiMarker> lastKnownPlayers() {
|
||||
queuePlayerRefresh();
|
||||
return playerMarkers;
|
||||
}
|
||||
|
||||
void requestEntities(Consumer<List<GuiMarker>> sink) {
|
||||
runOnServerThread(() -> sink.accept(collectEntities()));
|
||||
}
|
||||
|
||||
void teleport(int blockX, int blockZ) {
|
||||
runOnServerThread(() -> teleportOpener(blockX, blockZ));
|
||||
}
|
||||
|
||||
Optional<String> openInEditor(double worldX, double worldZ, RenderType type) {
|
||||
IrisComplex complex = engine.getComplex();
|
||||
File file = switch (type) {
|
||||
case BIOME, HEIGHT ->
|
||||
complex.getTrueBiomeStream().get(worldX, worldZ).openInVSCode();
|
||||
case BIOME_LAND -> complex.getLandBiomeStream().get(worldX, worldZ).openInVSCode();
|
||||
case BIOME_SEA -> complex.getSeaBiomeStream().get(worldX, worldZ).openInVSCode();
|
||||
case REGION -> complex.getRegionStream().get(worldX, worldZ).openInVSCode();
|
||||
case CAVE_LAND -> complex.getCaveBiomeStream().get(worldX, worldZ).openInVSCode();
|
||||
case CONTINENT -> null;
|
||||
};
|
||||
return Optional.ofNullable(file).map(File::getName);
|
||||
}
|
||||
|
||||
private void queuePlayerRefresh() {
|
||||
if (!playerRefreshQueued.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
boolean scheduled = runOnServerThread(() -> {
|
||||
try {
|
||||
playerMarkers = collectPlayers();
|
||||
} finally {
|
||||
playerRefreshQueued.set(false);
|
||||
}
|
||||
});
|
||||
if (!scheduled) {
|
||||
playerRefreshQueued.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private List<GuiMarker> collectPlayers() {
|
||||
List<GuiMarker> markers = new ArrayList<>();
|
||||
for (Player player : engine.getWorld().getPlayers()) {
|
||||
Location at = player.getLocation();
|
||||
markers.add(GuiMarker.player(player.getName(), at.getX(), at.getZ()));
|
||||
}
|
||||
return List.copyOf(markers);
|
||||
}
|
||||
|
||||
private List<GuiMarker> collectEntities() {
|
||||
IrisWorld world = engine.getWorld();
|
||||
if (!world.hasRealWorld()) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
List<GuiMarker> markers = new ArrayList<>();
|
||||
for (LivingEntity entity : world.getEntitiesByClass(LivingEntity.class)) {
|
||||
if (!(entity instanceof Player)) {
|
||||
markers.add(marker(entity));
|
||||
}
|
||||
}
|
||||
return List.copyOf(markers);
|
||||
}
|
||||
|
||||
private static GuiMarker marker(LivingEntity entity) {
|
||||
Location at = entity.getLocation();
|
||||
String label = Form.capitalizeWords(entity.getType().name().toLowerCase(Locale.ROOT).replace('_', ' '));
|
||||
double maxHealth = 0D;
|
||||
try {
|
||||
maxHealth = entity.getAttribute(MAX_HEALTH).getValue();
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
return GuiMarker.entity(label, at.getX(), at.getY(), at.getZ(), entity.getHealth(), maxHealth);
|
||||
}
|
||||
|
||||
private void teleportOpener(int blockX, int blockZ) {
|
||||
IrisWorld target = engine.getWorld();
|
||||
Player player = selectPlayer(target.getPlayers());
|
||||
if (player == null || !target.hasRealWorld() || engine.isClosed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
World world = target.realWorld();
|
||||
int blockY = engine.getMinHeight() + engine.getHeight(blockX, blockZ, false) + 2;
|
||||
Location destination = new Location(world, blockX + 0.5D, blockY, blockZ + 0.5D);
|
||||
player.teleportAsync(destination).whenComplete((success, failure) -> {
|
||||
if (failure != null) {
|
||||
Iris.reportError(failure);
|
||||
Iris.warn("Vision could not teleport %s to %d, %d.", player.getName(), blockX, blockZ);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private Player selectPlayer(List<Player> players) {
|
||||
if (openerId == null) {
|
||||
return players.isEmpty() ? null : players.get(0);
|
||||
}
|
||||
for (Player player : players) {
|
||||
if (openerId.equals(player.getUniqueId())) {
|
||||
return player;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean runOnServerThread(Runnable task) {
|
||||
try {
|
||||
J.s(task);
|
||||
return true;
|
||||
} catch (Throwable e) {
|
||||
Iris.reportError(e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,649 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
import com.volmit.iris.Iris;
|
||||
import com.volmit.iris.core.gui.components.IrisRenderer;
|
||||
import com.volmit.iris.core.gui.components.MapLabels;
|
||||
import com.volmit.iris.core.gui.components.MapTile;
|
||||
import com.volmit.iris.core.gui.components.RenderType;
|
||||
|
||||
import java.awt.EventQueue;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ArrayBlockingQueue;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import java.util.concurrent.ThreadFactory;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Renders the map as tiles on fixed zoom levels, so zooming within a level and panning reuse tiles that are already drawn.
|
||||
* Tiles keep rendering while the view moves, as long as they are still on screen.
|
||||
*/
|
||||
final class VisionRenderController implements AutoCloseable {
|
||||
static final int TILE_PIXELS = 64;
|
||||
static final double MINIMUM_VIEW_BLOCKS_PER_PIXEL = 1D / 8D;
|
||||
static final double MAXIMUM_BLOCKS_PER_PIXEL = 4_096D;
|
||||
|
||||
private static final double MINIMUM_RENDER_BLOCKS_PER_PIXEL = 1D;
|
||||
private static final int MAXIMUM_VISIBLE_TILES = 32_768;
|
||||
private static final int PROBE_QUEUE_CAPACITY = 1;
|
||||
private static final int COARSER_FALLBACK_LEVELS = 6;
|
||||
private static final long CACHE_BYTES = 256L * 1024L * 1024L;
|
||||
|
||||
private final Runnable listener;
|
||||
private final int workerCount;
|
||||
private final ThreadPoolExecutor renderExecutor;
|
||||
private final ThreadPoolExecutor probeExecutor;
|
||||
private final WeightedTileCache cache;
|
||||
private final Set<TileKey> tilesInFlight = new HashSet<>();
|
||||
private final AtomicInteger activeWorkers = new AtomicInteger();
|
||||
private final AtomicLong viewSequence = new AtomicLong();
|
||||
private final AtomicLong probeSequence = new AtomicLong();
|
||||
private final AtomicBoolean closed = new AtomicBoolean();
|
||||
private final AtomicBoolean publicationQueued = new AtomicBoolean();
|
||||
private final AtomicBoolean publicationDirty = new AtomicBoolean();
|
||||
private volatile Frame currentFrame;
|
||||
|
||||
VisionRenderController(Runnable listener) {
|
||||
this.listener = Objects.requireNonNull(listener, "listener");
|
||||
int processors = Math.max(1, Runtime.getRuntime().availableProcessors());
|
||||
this.workerCount = Math.max(1, Math.min(6, processors / 2));
|
||||
AtomicInteger threadSequence = new AtomicInteger();
|
||||
this.renderExecutor = new ThreadPoolExecutor(
|
||||
workerCount,
|
||||
workerCount,
|
||||
30L,
|
||||
TimeUnit.SECONDS,
|
||||
new LinkedBlockingQueue<>(),
|
||||
daemonFactory("Iris Vision Render", Thread.NORM_PRIORITY, threadSequence)
|
||||
);
|
||||
this.renderExecutor.allowCoreThreadTimeOut(true);
|
||||
this.probeExecutor = new ThreadPoolExecutor(
|
||||
1,
|
||||
1,
|
||||
0L,
|
||||
TimeUnit.MILLISECONDS,
|
||||
new ArrayBlockingQueue<>(PROBE_QUEUE_CAPACITY),
|
||||
daemonFactory("Iris Vision Probe", Thread.MIN_PRIORITY, threadSequence),
|
||||
new ThreadPoolExecutor.DiscardOldestPolicy()
|
||||
);
|
||||
this.cache = new WeightedTileCache(CACHE_BYTES);
|
||||
GuiHost.interruptOnPluginShutdown(renderExecutor);
|
||||
GuiHost.interruptOnPluginShutdown(probeExecutor);
|
||||
}
|
||||
|
||||
// Snapping to powers of two keeps every zoom within a level on the same tiles; nearest keeps tiles within 1.41x of screen size.
|
||||
static double renderBlocksPerPixel(double viewBlocksPerPixel) {
|
||||
double level = Math.pow(2D, Math.round(Math.log(viewBlocksPerPixel) / Math.log(2D)));
|
||||
return Math.max(MINIMUM_RENDER_BLOCKS_PER_PIXEL, Math.min(MAXIMUM_BLOCKS_PER_PIXEL, level));
|
||||
}
|
||||
|
||||
Frame request(RenderSpec spec) {
|
||||
Objects.requireNonNull(spec, "spec");
|
||||
if (closed.get()) {
|
||||
throw new IllegalStateException("Vision render controller is closed");
|
||||
}
|
||||
double renderScale = renderBlocksPerPixel(spec.blocksPerPixel());
|
||||
List<VisibleTile> tiles = visibleTiles(spec, renderScale);
|
||||
Frame frame = new Frame(viewSequence.incrementAndGet(), spec, renderScale, tiles);
|
||||
for (VisibleTile tile : tiles) {
|
||||
tile.setTile(cachedTileServing(tile.key()));
|
||||
}
|
||||
currentFrame = frame;
|
||||
probeSequence.incrementAndGet();
|
||||
probeExecutor.getQueue().clear();
|
||||
startWorkers();
|
||||
publish();
|
||||
return frame;
|
||||
}
|
||||
|
||||
/**
|
||||
* A tile from another zoom level covering the given one, to draw while the real tile renders.
|
||||
*/
|
||||
Fallback fallback(Frame frame, VisibleTile tile) {
|
||||
MapTile withoutHeights = cache.get(tile.key().withHeights(false));
|
||||
if (withoutHeights != null) {
|
||||
return new Fallback(List.of(new FallbackPiece(withoutHeights, 0, 0, TILE_PIXELS, 0D, 0D, 1D)));
|
||||
}
|
||||
Fallback coarser = coarserFallback(frame, tile);
|
||||
return coarser != null ? coarser : finerFallback(frame, tile);
|
||||
}
|
||||
|
||||
// A tile drawn with heights serves just as well where no heights are needed, so turning shading off redraws nothing.
|
||||
private MapTile cachedTileServing(TileKey key) {
|
||||
MapTile exact = cache.get(key);
|
||||
if (exact != null || key.heights()) {
|
||||
return exact;
|
||||
}
|
||||
return cache.get(key.withHeights(true));
|
||||
}
|
||||
|
||||
Progress progress(Frame frame) {
|
||||
if (frame == null) {
|
||||
return new Progress(0, 0, 0);
|
||||
}
|
||||
int ready = 0;
|
||||
for (VisibleTile tile : frame.tiles()) {
|
||||
if (tile.tile() != null) {
|
||||
ready++;
|
||||
}
|
||||
}
|
||||
return new Progress(frame.tiles().size(), ready, activeWorkers.get());
|
||||
}
|
||||
|
||||
<T> void submitProbe(Frame frame, Callable<T> probe, Consumer<T> consumer) {
|
||||
Objects.requireNonNull(probe, "probe");
|
||||
Objects.requireNonNull(consumer, "consumer");
|
||||
if (frame == null || closed.get() || currentFrame != frame) {
|
||||
return;
|
||||
}
|
||||
long probeRevision = probeSequence.incrementAndGet();
|
||||
probeExecutor.getQueue().clear();
|
||||
try {
|
||||
probeExecutor.execute(() -> runProbe(probeRevision, probe, consumer));
|
||||
} catch (RejectedExecutionException ignored) {
|
||||
probeExecutor.getQueue().clear();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (!closed.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
currentFrame = null;
|
||||
renderExecutor.shutdownNow();
|
||||
probeExecutor.shutdownNow();
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
static List<VisibleTile> visibleTiles(RenderSpec spec, double renderScale) {
|
||||
Objects.requireNonNull(spec, "spec");
|
||||
double blocksPerPixel = spec.blocksPerPixel();
|
||||
double tileSpan = TILE_PIXELS * renderScale;
|
||||
double halfWidth = spec.width() * blocksPerPixel * 0.5D;
|
||||
double halfHeight = spec.height() * blocksPerPixel * 0.5D;
|
||||
long minimumX = floorTile(spec.centerX() - halfWidth, tileSpan);
|
||||
long maximumX = floorTile(Math.nextDown(spec.centerX() + halfWidth), tileSpan);
|
||||
long minimumZ = floorTile(spec.centerZ() - halfHeight, tileSpan);
|
||||
long maximumZ = floorTile(Math.nextDown(spec.centerZ() + halfHeight), tileSpan);
|
||||
long tileCount = Math.multiplyExact(maximumX - minimumX + 1L, maximumZ - minimumZ + 1L);
|
||||
if (tileCount > MAXIMUM_VISIBLE_TILES) {
|
||||
throw new IllegalArgumentException("Vision viewport contains too many tiles");
|
||||
}
|
||||
|
||||
ArrayList<VisibleTile> tiles = new ArrayList<>((int) tileCount);
|
||||
double centerTileX = spec.centerX() / tileSpan;
|
||||
double centerTileZ = spec.centerZ() / tileSpan;
|
||||
for (long tileZ = minimumZ; tileZ <= maximumZ; tileZ++) {
|
||||
for (long tileX = minimumX; tileX <= maximumX; tileX++) {
|
||||
double deltaX = tileX + 0.5D - centerTileX;
|
||||
double deltaZ = tileZ + 0.5D - centerTileZ;
|
||||
TileKey key = new TileKey(spec.contentRevision(), spec.type(), spec.heights(), renderScale, tileX, tileZ);
|
||||
tiles.add(new VisibleTile(key, deltaX * deltaX + deltaZ * deltaZ));
|
||||
}
|
||||
}
|
||||
tiles.sort(Comparator.comparingDouble(VisibleTile::distanceSquared)
|
||||
.thenComparingLong(VisibleTile::tileZ)
|
||||
.thenComparingLong(VisibleTile::tileX));
|
||||
return List.copyOf(tiles);
|
||||
}
|
||||
|
||||
private static long floorTile(double coordinate, double tileSpan) {
|
||||
double tile = Math.floor(coordinate / tileSpan);
|
||||
if (tile < Long.MIN_VALUE || tile > Long.MAX_VALUE) {
|
||||
throw new IllegalArgumentException("Vision viewport exceeds tile coordinate range");
|
||||
}
|
||||
return (long) tile;
|
||||
}
|
||||
|
||||
private static ThreadFactory daemonFactory(String name, int priority, AtomicInteger sequence) {
|
||||
return (Runnable runnable) -> {
|
||||
Thread thread = new Thread(runnable, name + " " + sequence.incrementAndGet());
|
||||
thread.setDaemon(true);
|
||||
thread.setPriority(priority);
|
||||
thread.setUncaughtExceptionHandler((Thread failedThread, Throwable error) -> Iris.reportError(error));
|
||||
return thread;
|
||||
};
|
||||
}
|
||||
|
||||
private void startWorkers() {
|
||||
while (!closed.get()) {
|
||||
int active = activeWorkers.get();
|
||||
if (active >= workerCount || !hasWork()) {
|
||||
return;
|
||||
}
|
||||
if (!activeWorkers.compareAndSet(active, active + 1)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
renderExecutor.execute(this::renderUntilIdle);
|
||||
} catch (RejectedExecutionException ignored) {
|
||||
activeWorkers.decrementAndGet();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Each worker keeps taking the most central missing tile of whatever frame is current, until the screen is covered.
|
||||
private void renderUntilIdle() {
|
||||
try {
|
||||
TileJob job;
|
||||
while ((job = claimNextTile()) != null) {
|
||||
render(job);
|
||||
}
|
||||
} finally {
|
||||
activeWorkers.decrementAndGet();
|
||||
}
|
||||
startWorkers();
|
||||
}
|
||||
|
||||
private synchronized boolean hasWork() {
|
||||
Frame frame = currentFrame;
|
||||
return frame != null && frame.nextMissing(tilesInFlight, false) != null;
|
||||
}
|
||||
|
||||
private synchronized TileJob claimNextTile() {
|
||||
Frame frame = currentFrame;
|
||||
if (frame == null || closed.get()) {
|
||||
return null;
|
||||
}
|
||||
VisibleTile tile = frame.nextMissing(tilesInFlight, true);
|
||||
if (tile == null) {
|
||||
return null;
|
||||
}
|
||||
tilesInFlight.add(tile.key());
|
||||
return new TileJob(frame.spec().renderer(), tile.key());
|
||||
}
|
||||
|
||||
private synchronized void release(TileKey key) {
|
||||
tilesInFlight.remove(key);
|
||||
}
|
||||
|
||||
private void render(TileJob job) {
|
||||
TileKey key = job.key();
|
||||
try {
|
||||
double tileSpan = TILE_PIXELS * key.blocksPerPixel();
|
||||
MapTile tile = job.renderer().renderTile(
|
||||
key.tileX() * tileSpan,
|
||||
key.tileZ() * tileSpan,
|
||||
key.blocksPerPixel(),
|
||||
TILE_PIXELS,
|
||||
key.type(),
|
||||
key.heights(),
|
||||
() -> !isWanted(key)
|
||||
);
|
||||
cache.put(key, tile);
|
||||
Frame frame = currentFrame;
|
||||
VisibleTile visible = frame == null ? null : frame.tile(key);
|
||||
if (visible != null) {
|
||||
visible.setTile(tile);
|
||||
publish();
|
||||
}
|
||||
} catch (CancellationException ignored) {
|
||||
} catch (Throwable error) {
|
||||
Iris.debug("Vision tile render failed: " + error.getClass().getSimpleName() + ": " + error.getMessage());
|
||||
} finally {
|
||||
release(key);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isWanted(TileKey key) {
|
||||
Frame frame = currentFrame;
|
||||
return !closed.get() && frame != null && frame.tile(key) != null;
|
||||
}
|
||||
|
||||
private Fallback coarserFallback(Frame frame, VisibleTile tile) {
|
||||
TileKey key = tile.key();
|
||||
for (int level = 1; level <= COARSER_FALLBACK_LEVELS; level++) {
|
||||
long scale = 1L << level;
|
||||
int sourceSize = TILE_PIXELS >> level;
|
||||
double parentScale = key.blocksPerPixel() * scale;
|
||||
if (sourceSize < 1 || parentScale > MAXIMUM_BLOCKS_PER_PIXEL) {
|
||||
return null;
|
||||
}
|
||||
long parentX = Math.floorDiv(key.tileX(), scale);
|
||||
long parentZ = Math.floorDiv(key.tileZ(), scale);
|
||||
MapTile parent = cache.get(new TileKey(key.contentRevision(), key.type(), key.heights(), parentScale, parentX,
|
||||
parentZ));
|
||||
if (parent != null) {
|
||||
int sourceX = (int) (key.tileX() - parentX * scale) * sourceSize;
|
||||
int sourceZ = (int) (key.tileZ() - parentZ * scale) * sourceSize;
|
||||
return new Fallback(List.of(new FallbackPiece(parent, sourceX, sourceZ, sourceSize, 0D, 0D, 1D)));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Fallback finerFallback(Frame frame, VisibleTile tile) {
|
||||
TileKey key = tile.key();
|
||||
double childScale = key.blocksPerPixel() / 2D;
|
||||
if (childScale < MINIMUM_RENDER_BLOCKS_PER_PIXEL) {
|
||||
return null;
|
||||
}
|
||||
List<FallbackPiece> pieces = new ArrayList<>(4);
|
||||
for (int childZ = 0; childZ < 2; childZ++) {
|
||||
for (int childX = 0; childX < 2; childX++) {
|
||||
MapTile child = cache.get(new TileKey(key.contentRevision(), key.type(), key.heights(), childScale,
|
||||
key.tileX() * 2L + childX, key.tileZ() * 2L + childZ));
|
||||
if (child != null) {
|
||||
pieces.add(new FallbackPiece(child, 0, 0, TILE_PIXELS, childX * 0.5D, childZ * 0.5D, 0.5D));
|
||||
}
|
||||
}
|
||||
}
|
||||
return pieces.isEmpty() ? null : new Fallback(List.copyOf(pieces));
|
||||
}
|
||||
|
||||
private <T> void runProbe(long probeRevision, Callable<T> probe, Consumer<T> consumer) {
|
||||
try {
|
||||
if (!isProbeCurrent(probeRevision)) {
|
||||
return;
|
||||
}
|
||||
T result = probe.call();
|
||||
if (!isProbeCurrent(probeRevision)) {
|
||||
return;
|
||||
}
|
||||
EventQueue.invokeLater(() -> {
|
||||
if (isProbeCurrent(probeRevision)) {
|
||||
consumer.accept(result);
|
||||
}
|
||||
});
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
} catch (Throwable error) {
|
||||
Iris.debug("Vision probe failed: " + error.getClass().getSimpleName() + ": " + error.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isProbeCurrent(long probeRevision) {
|
||||
return !closed.get() && probeSequence.get() == probeRevision;
|
||||
}
|
||||
|
||||
private void publish() {
|
||||
if (closed.get()) {
|
||||
return;
|
||||
}
|
||||
publicationDirty.set(true);
|
||||
queuePublication();
|
||||
}
|
||||
|
||||
private void queuePublication() {
|
||||
if (!publicationQueued.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
EventQueue.invokeLater(() -> {
|
||||
try {
|
||||
if (publicationDirty.getAndSet(false) && !closed.get()) {
|
||||
listener.run();
|
||||
}
|
||||
} finally {
|
||||
publicationQueued.set(false);
|
||||
if (publicationDirty.get() && !closed.get()) {
|
||||
queuePublication();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
record RenderSpec(
|
||||
IrisRenderer renderer,
|
||||
RenderType type,
|
||||
long contentRevision,
|
||||
double centerX,
|
||||
double centerZ,
|
||||
double blocksPerPixel,
|
||||
int width,
|
||||
int height,
|
||||
boolean heights
|
||||
) {
|
||||
RenderSpec {
|
||||
Objects.requireNonNull(renderer, "renderer");
|
||||
Objects.requireNonNull(type, "type");
|
||||
if (!Double.isFinite(centerX) || !Double.isFinite(centerZ)) {
|
||||
throw new IllegalArgumentException("Vision center must be finite");
|
||||
}
|
||||
if (!Double.isFinite(blocksPerPixel)
|
||||
|| blocksPerPixel < MINIMUM_VIEW_BLOCKS_PER_PIXEL
|
||||
|| blocksPerPixel > MAXIMUM_BLOCKS_PER_PIXEL) {
|
||||
throw new IllegalArgumentException("Vision scale is outside the supported range");
|
||||
}
|
||||
if (width < 1 || height < 1) {
|
||||
throw new IllegalArgumentException("Vision viewport must be positive");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static final class Frame {
|
||||
private final long viewRevision;
|
||||
private final RenderSpec spec;
|
||||
private final double renderBlocksPerPixel;
|
||||
private final List<VisibleTile> tiles;
|
||||
private final Map<TileKey, VisibleTile> tilesByKey;
|
||||
private int nextCandidate;
|
||||
|
||||
private Frame(long viewRevision, RenderSpec spec, double renderBlocksPerPixel, List<VisibleTile> tiles) {
|
||||
this.viewRevision = viewRevision;
|
||||
this.spec = Objects.requireNonNull(spec, "spec");
|
||||
this.renderBlocksPerPixel = renderBlocksPerPixel;
|
||||
this.tiles = tiles;
|
||||
this.tilesByKey = new HashMap<>(tiles.size() * 2);
|
||||
for (VisibleTile tile : tiles) {
|
||||
tilesByKey.put(tile.key(), tile);
|
||||
}
|
||||
}
|
||||
|
||||
long viewRevision() {
|
||||
return viewRevision;
|
||||
}
|
||||
|
||||
RenderSpec spec() {
|
||||
return spec;
|
||||
}
|
||||
|
||||
double renderBlocksPerPixel() {
|
||||
return renderBlocksPerPixel;
|
||||
}
|
||||
|
||||
List<VisibleTile> tiles() {
|
||||
return tiles;
|
||||
}
|
||||
|
||||
private VisibleTile tile(TileKey key) {
|
||||
return tilesByKey.get(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* The tile covering a world position together with the pixel of it there, or null when that tile is not drawn yet.
|
||||
*/
|
||||
TilePixel pixelAt(double worldX, double worldZ) {
|
||||
double tileSpan = TILE_PIXELS * renderBlocksPerPixel;
|
||||
long tileX = (long) Math.floor(worldX / tileSpan);
|
||||
long tileZ = (long) Math.floor(worldZ / tileSpan);
|
||||
VisibleTile visible = tilesByKey.get(new TileKey(spec.contentRevision(), spec.type(), spec.heights(),
|
||||
renderBlocksPerPixel, tileX, tileZ));
|
||||
MapTile tile = visible == null ? null : visible.tile();
|
||||
if (tile == null) {
|
||||
return null;
|
||||
}
|
||||
int pixelX = pixelWithin(worldX - tileX * tileSpan);
|
||||
int pixelZ = pixelWithin(worldZ - tileZ * tileSpan);
|
||||
return new TilePixel(tile, pixelX, pixelZ);
|
||||
}
|
||||
|
||||
private int pixelWithin(double offsetBlocks) {
|
||||
return Math.max(0, Math.min(TILE_PIXELS - 1, (int) Math.floor(offsetBlocks / renderBlocksPerPixel)));
|
||||
}
|
||||
|
||||
// Tiles are sorted centre first, and everything before the cursor is drawn or claimed, so each search resumes there.
|
||||
private VisibleTile nextMissing(Set<TileKey> inFlight, boolean advance) {
|
||||
for (int index = nextCandidate; index < tiles.size(); index++) {
|
||||
VisibleTile tile = tiles.get(index);
|
||||
if (tile.tile() == null && !inFlight.contains(tile.key())) {
|
||||
if (advance) {
|
||||
nextCandidate = index + 1;
|
||||
}
|
||||
return tile;
|
||||
}
|
||||
}
|
||||
if (advance) {
|
||||
nextCandidate = tiles.size();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static final class VisibleTile {
|
||||
private final TileKey key;
|
||||
private final double distanceSquared;
|
||||
private volatile MapTile tile;
|
||||
|
||||
private VisibleTile(TileKey key, double distanceSquared) {
|
||||
this.key = key;
|
||||
this.distanceSquared = distanceSquared;
|
||||
}
|
||||
|
||||
TileKey key() {
|
||||
return key;
|
||||
}
|
||||
|
||||
long tileX() {
|
||||
return key.tileX();
|
||||
}
|
||||
|
||||
long tileZ() {
|
||||
return key.tileZ();
|
||||
}
|
||||
|
||||
double distanceSquared() {
|
||||
return distanceSquared;
|
||||
}
|
||||
|
||||
MapTile tile() {
|
||||
return tile;
|
||||
}
|
||||
|
||||
private void setTile(MapTile tile) {
|
||||
this.tile = tile;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param heights whether the tile also carries terrain heights, for shading
|
||||
*/
|
||||
record TileKey(long contentRevision, RenderType type, boolean heights, double blocksPerPixel, long tileX, long tileZ) {
|
||||
TileKey {
|
||||
Objects.requireNonNull(type, "type");
|
||||
if (!Double.isFinite(blocksPerPixel) || blocksPerPixel <= 0D) {
|
||||
throw new IllegalArgumentException("Vision cache scale must be finite and positive");
|
||||
}
|
||||
}
|
||||
|
||||
TileKey withHeights(boolean nextHeights) {
|
||||
return new TileKey(contentRevision, type, nextHeights, blocksPerPixel, tileX, tileZ);
|
||||
}
|
||||
}
|
||||
|
||||
record TilePixel(MapTile tile, int pixelX, int pixelZ) {
|
||||
MapLabels.Label subject() {
|
||||
return tile.labels().label(tile.subjectAt(pixelX, pixelZ));
|
||||
}
|
||||
|
||||
MapLabels.Label region() {
|
||||
return tile.labels().label(tile.regionAt(pixelX, pixelZ));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pieces of cached tiles that together cover a missing tile; offsets and size are fractions of the tile.
|
||||
*/
|
||||
record Fallback(List<FallbackPiece> pieces) {
|
||||
}
|
||||
|
||||
record FallbackPiece(MapTile tile, int sourceX, int sourceZ, int sourceSize, double offsetX, double offsetZ,
|
||||
double size) {
|
||||
}
|
||||
|
||||
record Progress(int total, int ready, int activeWorkers) {
|
||||
double completion() {
|
||||
if (total == 0) {
|
||||
return 1D;
|
||||
}
|
||||
return Math.min(1D, ready / (double) total);
|
||||
}
|
||||
}
|
||||
|
||||
private record TileJob(IrisRenderer renderer, TileKey key) {
|
||||
}
|
||||
|
||||
private static final class WeightedTileCache {
|
||||
private final long maximumBytes;
|
||||
private final LinkedHashMap<TileKey, CacheEntry> entries;
|
||||
private long bytes;
|
||||
|
||||
private WeightedTileCache(long maximumBytes) {
|
||||
this.maximumBytes = maximumBytes;
|
||||
this.entries = new LinkedHashMap<>(128, 0.75F, true);
|
||||
}
|
||||
|
||||
synchronized MapTile get(TileKey key) {
|
||||
CacheEntry entry = entries.get(key);
|
||||
return entry == null ? null : entry.tile();
|
||||
}
|
||||
|
||||
synchronized void put(TileKey key, MapTile tile) {
|
||||
long tileBytes = tile.bytes();
|
||||
CacheEntry previous = entries.put(key, new CacheEntry(tile, tileBytes));
|
||||
if (previous != null) {
|
||||
bytes -= previous.bytes();
|
||||
}
|
||||
bytes += tileBytes;
|
||||
while (bytes > maximumBytes && !entries.isEmpty()) {
|
||||
Iterator<Map.Entry<TileKey, CacheEntry>> iterator = entries.entrySet().iterator();
|
||||
Map.Entry<TileKey, CacheEntry> eldest = iterator.next();
|
||||
bytes -= eldest.getValue().bytes();
|
||||
iterator.remove();
|
||||
}
|
||||
}
|
||||
|
||||
synchronized void clear() {
|
||||
entries.clear();
|
||||
bytes = 0L;
|
||||
}
|
||||
}
|
||||
|
||||
private record CacheEntry(MapTile tile, long bytes) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
record VisionViewport(double centerX, double centerZ, double blocksPerPixel) {
|
||||
VisionViewport {
|
||||
if (!Double.isFinite(centerX) || !Double.isFinite(centerZ)) {
|
||||
throw new IllegalArgumentException("Vision viewport center must be finite");
|
||||
}
|
||||
if (!Double.isFinite(blocksPerPixel) || blocksPerPixel <= 0D) {
|
||||
throw new IllegalArgumentException("Vision viewport scale must be finite and positive");
|
||||
}
|
||||
}
|
||||
|
||||
double worldX(double screenX, int width) {
|
||||
return centerX + (screenX - width * 0.5D) * blocksPerPixel;
|
||||
}
|
||||
|
||||
double worldZ(double screenZ, int height) {
|
||||
return centerZ + (screenZ - height * 0.5D) * blocksPerPixel;
|
||||
}
|
||||
|
||||
VisionViewport zoomAt(
|
||||
double screenX,
|
||||
double screenZ,
|
||||
int width,
|
||||
int height,
|
||||
double factor,
|
||||
double minimumScale,
|
||||
double maximumScale
|
||||
) {
|
||||
if (!Double.isFinite(factor) || factor <= 0D) {
|
||||
throw new IllegalArgumentException("Vision zoom factor must be finite and positive");
|
||||
}
|
||||
double anchorX = worldX(screenX, width);
|
||||
double anchorZ = worldZ(screenZ, height);
|
||||
double nextScale = Math.max(minimumScale, Math.min(maximumScale, blocksPerPixel * factor));
|
||||
return new VisionViewport(
|
||||
anchorX - (screenX - width * 0.5D) * nextScale,
|
||||
anchorZ - (screenZ - height * 0.5D) * nextScale,
|
||||
nextScale
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.volmit.iris.core.gui;
|
||||
|
||||
import com.sun.jna.platform.linux.LibC;
|
||||
import com.volmit.iris.Iris;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* Java does not recognise the non-reparenting Xwayland window managers of compositors such as niri, sway and Hyprland.
|
||||
* It waits for a reparent that never comes and paints only a corner of each window. Java reads this hint from the
|
||||
* native environment once, at the first decorated window, so it is set through libc before Iris opens any window.
|
||||
*/
|
||||
final class XwaylandWindowManagerHint {
|
||||
private static final String NON_REPARENTING_VARIABLE = "_JAVA_AWT_WM_NONREPARENTING";
|
||||
private static final int KEEP_EXISTING_VALUE = 0;
|
||||
private static final List<String> REPARENTING_DESKTOPS = List.of("GNOME", "KDE");
|
||||
private static final AtomicBoolean APPLIED = new AtomicBoolean();
|
||||
|
||||
private XwaylandWindowManagerHint() {
|
||||
}
|
||||
|
||||
static void apply() {
|
||||
if (!APPLIED.compareAndSet(false, true) || !isNeeded(System.getenv(), System.getProperty("os.name", ""))) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (LibC.INSTANCE.setenv(NON_REPARENTING_VARIABLE, "1", KEEP_EXISTING_VALUE) != 0) {
|
||||
warnUnapplied();
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
Iris.reportError(e);
|
||||
warnUnapplied();
|
||||
}
|
||||
}
|
||||
|
||||
static boolean isNeeded(Map<String, String> environment, String osName) {
|
||||
return osName.toLowerCase(Locale.ROOT).contains("linux")
|
||||
&& isSet(environment, "WAYLAND_DISPLAY")
|
||||
&& isSet(environment, "DISPLAY")
|
||||
&& !isSet(environment, NON_REPARENTING_VARIABLE)
|
||||
&& !isReparentingDesktop(environment.get("XDG_CURRENT_DESKTOP"));
|
||||
}
|
||||
|
||||
private static boolean isReparentingDesktop(String currentDesktop) {
|
||||
if (currentDesktop == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Arrays.stream(currentDesktop.split(":"))
|
||||
.map(desktop -> desktop.trim().toUpperCase(Locale.ROOT))
|
||||
.anyMatch(REPARENTING_DESKTOPS::contains);
|
||||
}
|
||||
|
||||
private static boolean isSet(Map<String, String> environment, String variable) {
|
||||
String value = environment.get(variable);
|
||||
return value != null && !value.isBlank();
|
||||
}
|
||||
|
||||
private static void warnUnapplied() {
|
||||
Iris.warn("Iris could not adjust Java for this Wayland desktop, so its windows may stay blank. "
|
||||
+ "Start the server with %s=1 set to fix that.", NON_REPARENTING_VARIABLE);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.volmit.iris.core.gui.components;
|
||||
|
||||
import com.volmit.iris.engine.IrisComplex;
|
||||
import com.volmit.iris.engine.framework.Engine;
|
||||
import com.volmit.iris.engine.object.IrisBiome;
|
||||
import com.volmit.iris.engine.object.IrisBiomeGeneratorLink;
|
||||
import com.volmit.iris.engine.object.IrisDimension;
|
||||
import com.volmit.iris.engine.object.IrisRegion;
|
||||
import com.volmit.iris.util.stream.ProceduralStream;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.util.Arrays;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.BooleanSupplier;
|
||||
|
||||
/**
|
||||
* Draws map tiles for one generation of the pack: a hotload needs a new renderer, which also starts new labels.
|
||||
*/
|
||||
public final class IrisRenderer {
|
||||
private static final int BLUE = rgb(45, 91, 156);
|
||||
private static final int YELLOW = rgb(211, 164, 67);
|
||||
private static final int GREEN = rgb(78, 137, 83);
|
||||
private static final int DEEP_WATER = rgb(20, 48, 92);
|
||||
private static final int SHALLOW_WATER = rgb(50, 112, 154);
|
||||
private static final int LOWLAND = rgb(78, 128, 76);
|
||||
private static final int HIGHLAND = rgb(151, 139, 92);
|
||||
private static final int ROCK = rgb(126, 119, 112);
|
||||
private static final int SNOW = rgb(226, 230, 232);
|
||||
private static final int UNKNOWN_SUBJECT = rgb(90, 92, 98);
|
||||
private static final int LARGEST_SAMPLE_BLOCK_PIXELS = 16;
|
||||
private static final double LARGEST_SAMPLE_BLOCK_BLOCKS = 64D;
|
||||
private static final double DEFAULT_DEPTH_BELOW_SEA_BLOCKS = 48D;
|
||||
private static final double DEFAULT_HEIGHT_ABOVE_SEA_BLOCKS = 160D;
|
||||
|
||||
private final Engine engine;
|
||||
private final MapLabels labels = new MapLabels();
|
||||
private final ConcurrentHashMap<LabelColorKey, Integer> labelColors = new ConcurrentHashMap<>();
|
||||
|
||||
public IrisRenderer(Engine engine) {
|
||||
this.engine = Objects.requireNonNull(engine, "engine");
|
||||
}
|
||||
|
||||
public MapLabels labels() {
|
||||
return labels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the square of {@code size} pixels whose top left pixel samples the given world position.
|
||||
*
|
||||
* @param withHeights also sample terrain heights for shading; the height view always has them
|
||||
*/
|
||||
public MapTile renderTile(
|
||||
double startX,
|
||||
double startZ,
|
||||
double blocksPerPixel,
|
||||
int size,
|
||||
RenderType type,
|
||||
boolean withHeights,
|
||||
BooleanSupplier cancelled
|
||||
) {
|
||||
if (!Double.isFinite(startX) || !Double.isFinite(startZ) || !Double.isFinite(blocksPerPixel) || blocksPerPixel <= 0D) {
|
||||
throw new IllegalArgumentException("Map tile coordinates and scale must be finite");
|
||||
}
|
||||
if (size < 1) {
|
||||
throw new IllegalArgumentException("Map tile size must be positive");
|
||||
}
|
||||
Objects.requireNonNull(type, "type");
|
||||
Objects.requireNonNull(cancelled, "cancelled");
|
||||
checkCancelled(cancelled);
|
||||
|
||||
TileGrid grid = new TileGrid(size + 2, startX - blocksPerPixel, startZ - blocksPerPixel, blocksPerPixel, cancelled);
|
||||
int[] identities = sampleIdentities(grid, type);
|
||||
double[] heights = withHeights || type == RenderType.HEIGHT ? sampleHeights(grid) : null;
|
||||
short[] subjects = new short[identities.length];
|
||||
short[] regions = new short[identities.length];
|
||||
for (int index = 0; index < identities.length; index++) {
|
||||
subjects[index] = subjectOf(identities[index]);
|
||||
regions[index] = regionOf(identities[index]);
|
||||
}
|
||||
int[] colors = type == RenderType.HEIGHT ? null : tileColors(size, type, subjects);
|
||||
MapLabels.Kind subjectKind = type == RenderType.REGION ? MapLabels.Kind.REGION : MapLabels.Kind.BIOME;
|
||||
return new MapTile(size, blocksPerPixel, labels, subjectKind, colors, defaultHeightRange(), subjects, regions, heights);
|
||||
}
|
||||
|
||||
/**
|
||||
* The colour a biome or region has on the map in the given view, for legends.
|
||||
*/
|
||||
public int legendColor(MapLabels.Label label, RenderType type) {
|
||||
RenderType colorType = type == RenderType.HEIGHT ? RenderType.BIOME : type;
|
||||
return labelColor(label.id(), colorType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Until the map has measured the heights on screen, land colours cover the band most terrain sits in.
|
||||
*/
|
||||
public MapStyle.HeightRange defaultHeightRange() {
|
||||
double seaLevel = fluidHeight();
|
||||
return new MapStyle.HeightRange(Math.max(0D, seaLevel - DEFAULT_DEPTH_BELOW_SEA_BLOCKS), seaLevel,
|
||||
Math.min(engine.getHeight(), seaLevel + DEFAULT_HEIGHT_ABOVE_SEA_BLOCKS));
|
||||
}
|
||||
|
||||
public static int heightColor(double height, MapStyle.HeightRange range) {
|
||||
double seaLevel = range.seaLevel();
|
||||
if (height <= seaLevel) {
|
||||
double waterDepthRange = Math.max(1D, seaLevel - range.lowest());
|
||||
return blend(DEEP_WATER, SHALLOW_WATER, (height - range.lowest()) / waterDepthRange);
|
||||
}
|
||||
|
||||
double landRange = Math.max(1D, range.highest() - seaLevel);
|
||||
double land = clamp((height - seaLevel) / landRange, 0D, 1D);
|
||||
if (land < 0.45D) {
|
||||
return blend(LOWLAND, HIGHLAND, land / 0.45D);
|
||||
}
|
||||
if (land < 0.78D) {
|
||||
return blend(HIGHLAND, ROCK, (land - 0.45D) / 0.33D);
|
||||
}
|
||||
return blend(ROCK, SNOW, (land - 0.78D) / 0.22D);
|
||||
}
|
||||
|
||||
private int[] sampleIdentities(TileGrid grid, RenderType type) {
|
||||
IdentitySampler sampler = new IdentitySampler(grid, identityShader(type));
|
||||
sampler.renderAll(sampleBlockPixels(grid.step()));
|
||||
return sampler.identities;
|
||||
}
|
||||
|
||||
private double[] sampleHeights(TileGrid grid) {
|
||||
return new HeightLattice(grid, engine.getComplex().getHeightStream()).interpolatedHeights();
|
||||
}
|
||||
|
||||
// Large uniform squares are filled after a few samples; squares stay small enough to not skip tiny biomes.
|
||||
private static int sampleBlockPixels(double blocksPerPixel) {
|
||||
int largestPixels = (int) Math.floor(LARGEST_SAMPLE_BLOCK_BLOCKS / blocksPerPixel);
|
||||
return Integer.highestOneBit(Math.max(1, Math.min(LARGEST_SAMPLE_BLOCK_PIXELS, largestPixels)));
|
||||
}
|
||||
|
||||
private IdentityShader identityShader(RenderType type) {
|
||||
IrisComplex complex = engine.getComplex();
|
||||
ProceduralStream<IrisRegion> regionStream = complex.getRegionStream();
|
||||
LabelLookup lookup = new LabelLookup(labels);
|
||||
if (type == RenderType.REGION) {
|
||||
return (double x, double z) -> {
|
||||
short region = lookup.idOf(regionStream.get(x, z));
|
||||
return identity(region, region);
|
||||
};
|
||||
}
|
||||
ProceduralStream<IrisBiome> biomeStream = subjectBiomeStream(complex, type);
|
||||
return (double x, double z) -> identity(lookup.idOf(biomeStream.get(x, z)), lookup.idOf(regionStream.get(x, z)));
|
||||
}
|
||||
|
||||
private static ProceduralStream<IrisBiome> subjectBiomeStream(IrisComplex complex, RenderType type) {
|
||||
return switch (type) {
|
||||
case BIOME_LAND -> complex.getLandBiomeStream();
|
||||
case BIOME_SEA -> complex.getSeaBiomeStream();
|
||||
case CAVE_LAND -> complex.getCaveBiomeStream();
|
||||
case BIOME, HEIGHT, CONTINENT, REGION -> complex.getBaseBiomeStream();
|
||||
};
|
||||
}
|
||||
|
||||
private int[] tileColors(int size, RenderType type, short[] subjects) {
|
||||
int[] colors = new int[size * size];
|
||||
short previousSubject = MapLabels.NONE;
|
||||
int previousColor = UNKNOWN_SUBJECT;
|
||||
for (int pixelZ = 0; pixelZ < size; pixelZ++) {
|
||||
for (int pixelX = 0; pixelX < size; pixelX++) {
|
||||
int gridIndex = (pixelZ + 1) * (size + 2) + pixelX + 1;
|
||||
short subject = subjects[gridIndex];
|
||||
if (subject != previousSubject) {
|
||||
previousSubject = subject;
|
||||
previousColor = labelColor(subject, type);
|
||||
}
|
||||
colors[pixelZ * size + pixelX] = previousColor;
|
||||
}
|
||||
}
|
||||
return colors;
|
||||
}
|
||||
|
||||
private double fluidHeight() {
|
||||
IrisDimension dimension = engine.getDimension();
|
||||
return dimension == null ? 0D : dimension.getFluidHeight();
|
||||
}
|
||||
|
||||
private int labelColor(short id, RenderType type) {
|
||||
MapLabels.Label label = labels.label(id);
|
||||
if (label == null) {
|
||||
return UNKNOWN_SUBJECT;
|
||||
}
|
||||
return labelColors.computeIfAbsent(new LabelColorKey(type, id), key -> subjectColor(label.subject(), type));
|
||||
}
|
||||
|
||||
private int subjectColor(Object subject, RenderType type) {
|
||||
if (type == RenderType.CONTINENT && subject instanceof IrisBiome biome) {
|
||||
return continentColor(biome);
|
||||
}
|
||||
if (subject instanceof IrisRegion region) {
|
||||
return region.getColor(engine.getComplex(), type).getRGB() & 0xFFFFFF;
|
||||
}
|
||||
if (subject instanceof IrisBiome biome) {
|
||||
return biome.getColor(engine, type).getRGB() & 0xFFFFFF;
|
||||
}
|
||||
return UNKNOWN_SUBJECT;
|
||||
}
|
||||
|
||||
private static int continentColor(IrisBiome biome) {
|
||||
List<IrisBiomeGeneratorLink> generators = biome.getGenerators();
|
||||
if (generators.isEmpty()) {
|
||||
return GREEN;
|
||||
}
|
||||
IrisBiomeGeneratorLink generator = generators.get(0);
|
||||
if (generator.getMax() <= 0D) {
|
||||
return BLUE;
|
||||
}
|
||||
if (generator.getMin() < 0D) {
|
||||
return YELLOW;
|
||||
}
|
||||
return GREEN;
|
||||
}
|
||||
|
||||
private static int identity(short subject, short region) {
|
||||
return (subject << 16) | (region & 0xFFFF);
|
||||
}
|
||||
|
||||
private static short subjectOf(int identity) {
|
||||
return (short) (identity >> 16);
|
||||
}
|
||||
|
||||
private static short regionOf(int identity) {
|
||||
return (short) identity;
|
||||
}
|
||||
|
||||
private static int rgb(int red, int green, int blue) {
|
||||
return new Color(red, green, blue).getRGB() & 0xFFFFFF;
|
||||
}
|
||||
|
||||
private static int blend(int first, int second, double progress) {
|
||||
double bounded = clamp(progress, 0D, 1D);
|
||||
int red = (int) Math.round(((first >> 16) & 0xFF) * (1D - bounded) + ((second >> 16) & 0xFF) * bounded);
|
||||
int green = (int) Math.round(((first >> 8) & 0xFF) * (1D - bounded) + ((second >> 8) & 0xFF) * bounded);
|
||||
int blue = (int) Math.round((first & 0xFF) * (1D - bounded) + (second & 0xFF) * bounded);
|
||||
return (red << 16) | (green << 8) | blue;
|
||||
}
|
||||
|
||||
private static double clamp(double value, double minimum, double maximum) {
|
||||
return Math.max(minimum, Math.min(maximum, value));
|
||||
}
|
||||
|
||||
private static void checkCancelled(BooleanSupplier cancelled) {
|
||||
if (cancelled.getAsBoolean() || Thread.currentThread().isInterrupted()) {
|
||||
throw new CancellationException("Map tile render cancelled");
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface IdentityShader {
|
||||
int identity(double x, double z);
|
||||
}
|
||||
|
||||
private record LabelColorKey(RenderType type, short id) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The square of world positions one tile samples, including the one pixel ring borrowed from its neighbours.
|
||||
*/
|
||||
private record TileGrid(int size, double startX, double startZ, double step, BooleanSupplier cancelled) {
|
||||
double worldX(int gridX) {
|
||||
return startX + gridX * step;
|
||||
}
|
||||
|
||||
double worldZ(int gridZ) {
|
||||
return startZ + gridZ * step;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Labels the biomes and regions one tile meets, asking the shared labels only for ones this tile has not seen yet.
|
||||
*/
|
||||
private static final class LabelLookup {
|
||||
private final MapLabels labels;
|
||||
private final IdentityHashMap<Object, Short> seen = new IdentityHashMap<>();
|
||||
|
||||
private LabelLookup(MapLabels labels) {
|
||||
this.labels = labels;
|
||||
}
|
||||
|
||||
short idOf(IrisBiome biome) {
|
||||
if (biome == null) {
|
||||
return MapLabels.NONE;
|
||||
}
|
||||
return seen.computeIfAbsent(biome, subject -> labels.idOf(biome));
|
||||
}
|
||||
|
||||
short idOf(IrisRegion region) {
|
||||
if (region == null) {
|
||||
return MapLabels.NONE;
|
||||
}
|
||||
return seen.computeIfAbsent(region, subject -> labels.idOf(region));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Samples the biome and region of every grid cell in squares, filling a square without sampling its inside when
|
||||
* its corners and centre agree, and splitting it into quarters otherwise.
|
||||
*/
|
||||
private static final class IdentitySampler {
|
||||
private final TileGrid grid;
|
||||
private final IdentityShader shader;
|
||||
private final int[] identities;
|
||||
private final boolean[] sampled;
|
||||
|
||||
private IdentitySampler(TileGrid grid, IdentityShader shader) {
|
||||
this.grid = grid;
|
||||
this.shader = shader;
|
||||
this.identities = new int[grid.size() * grid.size()];
|
||||
this.sampled = new boolean[identities.length];
|
||||
}
|
||||
|
||||
void renderAll(int blockPixels) {
|
||||
int size = grid.size();
|
||||
for (int gridZ = 0; gridZ < size; gridZ += blockPixels) {
|
||||
int height = Math.min(blockPixels, size - gridZ);
|
||||
for (int gridX = 0; gridX < size; gridX += blockPixels) {
|
||||
render(gridX, gridZ, Math.min(blockPixels, size - gridX), height);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void render(int gridX, int gridZ, int width, int height) {
|
||||
checkCancelled(grid.cancelled());
|
||||
if (width == 1 && height == 1) {
|
||||
sample(gridX, gridZ);
|
||||
return;
|
||||
}
|
||||
if (fillIfUniform(gridX, gridZ, width, height)) {
|
||||
return;
|
||||
}
|
||||
int leftWidth = Math.max(1, width / 2);
|
||||
int rightWidth = width - leftWidth;
|
||||
int topHeight = Math.max(1, height / 2);
|
||||
int bottomHeight = height - topHeight;
|
||||
render(gridX, gridZ, leftWidth, topHeight);
|
||||
if (rightWidth > 0) {
|
||||
render(gridX + leftWidth, gridZ, rightWidth, topHeight);
|
||||
}
|
||||
if (bottomHeight > 0) {
|
||||
render(gridX, gridZ + topHeight, leftWidth, bottomHeight);
|
||||
if (rightWidth > 0) {
|
||||
render(gridX + leftWidth, gridZ + topHeight, rightWidth, bottomHeight);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean fillIfUniform(int gridX, int gridZ, int width, int height) {
|
||||
int maximumX = gridX + width - 1;
|
||||
int maximumZ = gridZ + height - 1;
|
||||
int identity = sample(gridX, gridZ);
|
||||
boolean uniform = sample(maximumX, gridZ) == identity
|
||||
&& sample(gridX, maximumZ) == identity
|
||||
&& sample(maximumX, maximumZ) == identity
|
||||
&& sample(gridX + width / 2, gridZ + height / 2) == identity;
|
||||
if (!uniform) {
|
||||
return false;
|
||||
}
|
||||
for (int row = gridZ; row <= maximumZ; row++) {
|
||||
int start = row * grid.size() + gridX;
|
||||
Arrays.fill(identities, start, start + width, identity);
|
||||
Arrays.fill(sampled, start, start + width, true);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private int sample(int gridX, int gridZ) {
|
||||
int index = gridZ * grid.size() + gridX;
|
||||
if (!sampled[index]) {
|
||||
identities[index] = shader.identity(grid.worldX(gridX), grid.worldZ(gridZ));
|
||||
sampled[index] = true;
|
||||
}
|
||||
return identities[index];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Terrain heights from samples on a coarse lattice, smoothly interpolated in between. Iris heights are costly to
|
||||
* sample, and shading turns the steps of cheaper shortcuts into visible creases, so a smooth curve through fewer
|
||||
* exact samples looks better and draws an order of magnitude faster than sampling every pixel. The lattice lines
|
||||
* up across tiles, so slopes continue seamlessly from one tile into the next.
|
||||
*/
|
||||
private static final class HeightLattice {
|
||||
private static final int LATTICE_PIXELS = 4;
|
||||
private static final int LATTICE_MARGIN = 2;
|
||||
|
||||
private final TileGrid grid;
|
||||
private final ProceduralStream<Double> stream;
|
||||
private final int pointsPerSide;
|
||||
private final double[] points;
|
||||
|
||||
private HeightLattice(TileGrid grid, ProceduralStream<Double> stream) {
|
||||
this.grid = grid;
|
||||
this.stream = stream;
|
||||
this.pointsPerSide = (grid.size() - 2) / LATTICE_PIXELS + LATTICE_MARGIN * 2 + 1;
|
||||
this.points = new double[pointsPerSide * pointsPerSide];
|
||||
}
|
||||
|
||||
double[] interpolatedHeights() {
|
||||
samplePoints();
|
||||
int size = grid.size();
|
||||
double[] heights = new double[size * size];
|
||||
for (int gridZ = 0; gridZ < size; gridZ++) {
|
||||
for (int gridX = 0; gridX < size; gridX++) {
|
||||
heights[gridZ * size + gridX] = heightAt(gridX - 1, gridZ - 1);
|
||||
}
|
||||
}
|
||||
return heights;
|
||||
}
|
||||
|
||||
// Lattice point 0 sits on the tile's first pixel; the margin covers the border ring and the curve's reach.
|
||||
private void samplePoints() {
|
||||
double latticeStep = grid.step() * LATTICE_PIXELS;
|
||||
double firstX = grid.worldX(1) - LATTICE_MARGIN * latticeStep;
|
||||
double firstZ = grid.worldZ(1) - LATTICE_MARGIN * latticeStep;
|
||||
for (int pointZ = 0; pointZ < pointsPerSide; pointZ++) {
|
||||
checkCancelled(grid.cancelled());
|
||||
for (int pointX = 0; pointX < pointsPerSide; pointX++) {
|
||||
points[pointZ * pointsPerSide + pointX] = stream.getDouble(firstX + pointX * latticeStep,
|
||||
firstZ + pointZ * latticeStep);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private double heightAt(int pixelX, int pixelZ) {
|
||||
int cellX = Math.floorDiv(pixelX, LATTICE_PIXELS);
|
||||
int cellZ = Math.floorDiv(pixelZ, LATTICE_PIXELS);
|
||||
double fractionX = (pixelX - cellX * LATTICE_PIXELS) / (double) LATTICE_PIXELS;
|
||||
double fractionZ = (pixelZ - cellZ * LATTICE_PIXELS) / (double) LATTICE_PIXELS;
|
||||
int baseX = cellX + LATTICE_MARGIN;
|
||||
int baseZ = cellZ + LATTICE_MARGIN;
|
||||
double[] rows = new double[4];
|
||||
for (int row = 0; row < 4; row++) {
|
||||
int offset = (baseZ - 1 + row) * pointsPerSide + baseX - 1;
|
||||
rows[row] = catmullRom(points[offset], points[offset + 1], points[offset + 2], points[offset + 3], fractionX);
|
||||
}
|
||||
return catmullRom(rows[0], rows[1], rows[2], rows[3], fractionZ);
|
||||
}
|
||||
|
||||
private static double catmullRom(double before, double start, double end, double after, double fraction) {
|
||||
double squared = fraction * fraction;
|
||||
double cubed = squared * fraction;
|
||||
return 0.5D * (2D * start
|
||||
+ (end - before) * fraction
|
||||
+ (2D * before - 5D * start + 4D * end - after) * squared
|
||||
+ (3D * start - before - 3D * end + after) * cubed);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 1192 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user